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
cito its own database. - Then split
sec(security findings). - The
jhdecomposition 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.
Related
Built by Factory AutoWiki from public repository content. It is a generated preview for codebase exploration, not source-maintained documentation.