Open-Source Wikis

/

GitLab

/

Background

/

Design decisions

gitlab-org/gitlab

Design decisions

A short tour of the major architectural choices that have stuck.

Why a Rails monolith

GitLab started in 2011 as a Sinatra/Rails app and has stayed a Rails monolith since. The team has explicitly chosen the "majestic monolith" path — extracting services only when there's a forcing function. The advantages:

  • A single codebase makes cross-feature changes (e.g., "add a label to MRs and to issues") cheap.
  • Shared abstractions (services, finders, policies) reduce surface area.
  • Tooling (RuboCop cops, Danger, Lefthook) can enforce conventions across the whole codebase.

The disadvantages — boot time, test suite size, deploy coupling — are managed by tooling rather than by splitting the monolith.

Services have been extracted only where the boundary is sharp:

  • Gitaly (Git RPC) — clear boundary, separate workload profile.
  • Workhorse (reverse proxy) — needed for streaming, hard to do well in Ruby.
  • KAS (cluster integration) — must speak gRPC long-poll to clusters.
  • Pages — different security model.
  • AI Gateway — different deploy cadence and provider integrations.

Why bounded contexts

Before bounded contexts (config/bounded_contexts.yml, ~75 namespaces), the codebase had a tendency to spread cross-cutting concerns into shared modules at the top level (Gitlab::*). The bounded-contexts cop forces new domain code into a dedicated namespace (Ci::, MergeRequests::, Authn::, etc.), which:

  • Makes ownership explicit.
  • Keeps autoload paths predictable.
  • Matches the team's mental model.

Application-layer code (controllers, REST endpoints, views) is exempt — it sees many domains and doesn't fit the model.

Why feature categories

config/feature_categories.yml (~155 categories) tags every controller, service, worker, and gem. Categories drive:

  • Sidekiq cluster routing.
  • Log and metric labels.
  • Reviewer/maintainer routing in Danger.
  • SLI ownership.
  • Audit-event tagging.

It's metadata, not directory structure — multiple categories can live under one bounded context.

Why decomposed databases

Until ~2022 GitLab ran against a single Postgres database. CI tables grew much faster than the rest, locking the whole DB during peak periods. The decomposition:

  • Split out ci to its own database.
  • Then split sec (security findings).
  • The jh decomposition is downstream-only.

The cost was steep: every cross-DB foreign key had to become a "loose foreign key" (no real FK; cleanup happens via Sidekiq), and load balancing had to learn about multiple databases. The benefit was sustained: each DB scales independently and locks don't cascade.

The work continues — Cells will further isolate by organization.

Why declarative_policy

The team built declarative_policy because Pundit-style policies didn't compose well across hundreds of overlapping conditions. The DSL:

  • Makes conditions cacheable per-request.
  • Lets EE prepend rules without overriding.
  • Supports score-based ordering (cheap conditions first).

Why event-store on Sidekiq

Domain events were originally implemented as inline Sidekiq calls (AfterCommitWorker.perform_async). The pattern leaked: producers had to know all consumers. The EventStore inverts this — producers publish, consumers subscribe. Sidekiq is the transport because it was already there; nothing else needed to change.

Why hashed storage

Originally, projects were stored on disk at <namespace>/<project>.git. Renames meant moving directories, which meant downtime. Hashed storage uses @hashed/<sha>/<sha>.git, computed from the project ID:

  • Renames are zero-cost.
  • Storage paths don't leak project names.
  • Backup/restore is simpler.

The migration ran for years; the legacy path is finally gone.

Why Workhorse

Streaming large files through Puma blocks worker threads and uses memory linearly. Workhorse intercepts those paths and streams to/from object storage directly. Going further:

  • Git over HTTP is implemented in Workhorse (talks to Gitaly via gRPC).
  • Image resizing avoids spawning Ruby ImageMagick processes.
  • Static asset caching avoids hitting Rails entirely.

Why monorepo gems

Extracting gems out of the monolith to other repos would slow development (cross-repo PRs, version pinning). Keeping them inside gems/ gives the structural benefit (own dependencies, own tests, own docs) without the operational cost. They're loaded via path: in the Gemfile and deployed atomically with the monolith.

Why Vue 3 still in progress

Vue 2 reached end-of-life in late 2023; GitLab's migration started earlier but still has hundreds of components on the legacy API. The migration uses @vue/compat, a per-component opt-in compatibility layer. The slow pace reflects:

  • Custom Vue 2 plugins that need rewrites.
  • Apollo / Vuex tight coupling to Vue 2 internals in some places.
  • A risk-averse policy of one component at a time.

config/vue3migration/ tracks per-component status.

Why Sidekiq, not GoodJob/etc.

Sidekiq is fast and battle-tested. The team has built so much around its API (middleware, dedup, routing, cron) that switching would be a multi-year project for marginal benefit.

Built by Factory AutoWiki from public repository content. It is a generated preview for codebase exploration, not source-maintained documentation.

Design decisions – GitLab wiki | Factory