Back to Gitlabhq

Backend Architecture Principles

.ai/principles/distilled/backend-architecture.md

19.2.011.8 KB
Original Source
<!-- Auto-generated from docs.gitlab.com by gitlab-ai-principles-distiller — do not edit manually -->

Prerequisite: If you haven't already, also read .ai/principles/distilled/backend-ruby.md - it contains foundational rules that apply to all backend work.

Backend Architecture Principles

Checklist

Abstraction Layer Usage

  • DO NOT use high-level Finder classes (e.g., ProjectsFinder) inside other Finders; use the underlying query primitives directly
  • DO NOT call ActiveRecord methods (e.g., where, find_by) directly from controllers, API endpoints, service classes, finders, presenters, or serializers — only from model class/instance methods
  • DO NOT use Presenters or Serializers inside Service classes
  • DO NOT use Service classes, Presenters, Serializers, or Workers inside Finders
  • DO NOT use service classes, presenters, or serializers inside model class/instance methods
  • DO NOT invoke a Worker directly with SomeWorker.new.perform; use SomeWorker.perform_async or SomeWorker.perform_in
  • DO NOT execute database queries in views; move all data retrieval into the controller or a presenter and pass the result as an instance variable
  • DO NOT put business logic in views; extract conditionals that evaluate model state beyond nil?, present?, or boolean attribute checks into a helper, presenter, or ViewComponent
  • Use the abstraction table to verify that each layer only calls permitted abstractions before approving cross-layer calls

Service Classes

  • Ensure service class initializer takes the acted-upon model as the first positional argument
  • Ensure service classes that act on behalf of a user include current_user: as a keyword argument
  • Ensure the #execute method takes no arguments (all data passed via initializer)
  • Ensure #execute returns a ServiceResponse object when a return value is needed
  • Use ServiceResponse.success / ServiceResponse.error with a message: and optional payload: or reason:
  • Use domain-specific reason: symbols in ServiceResponse.error (e.g., :job_not_retriable, :duplicate_package); use Rails HTTP status symbols only for common failures like :not_found or :forbidden
  • Inherit from BaseContainerService, BaseProjectService, or BaseGroupService where appropriate
  • DO NOT put business logic that changes application state in objects that are not service classes (use finders or value objects for read-only operations)

Finders

  • Ensure finder #execute methods return ActiveRecord::Relation; add exceptions to spec/support/finder_collection_allowlist.yml only when necessary
  • DO NOT reuse other finders inside a finder

Naming and Ubiquitous Language

  • Use ubiquitous language from the product/user documentation instead of CRUD terminology when naming service classes and domain objects (e.g., Epic::AddExistingIssueService not EpicIssues::CreateService)
  • Use CRUD names only when they match the existing ubiquitous language and are unambiguous (e.g., Projects::CreateService)
  • Ensure new classes and database tables use ubiquitous language; use self.table_name= when model name diverges from table name

Bounded Contexts and Namespaces

  • Ensure every Ruby class is nested inside a top-level namespace present in config/bounded_contexts.yml; resolve Gitlab/BoundedContexts RuboCop offenses by nesting into an existing context
  • DO NOT nest feature-specific classes under Projects:: or Groups:: namespaces unless the concept is strictly about projects or groups themselves
  • DO NOT use stage or group names as bounded context namespaces (feature categories can be reassigned)
  • Define event classes and publish events within the same bounded context (top-level Ruby namespace) where the triggering feature lives
  • Place FOSS event subscriptions in lib/gitlab/bounded_contexts/subscriptions/[context]_subscriptions.rb; for domains that exist in both FOSS and EE, place EE code in ee/lib/ee/gitlab/event_store/subscriptions/[context]_subscriptions.rb; EE-only domains in ee/lib/gitlab/event_store/subscriptions/[context]_subscriptions.rb
  • Add newly created subscription groups to the SUBSCRIPTION_GROUPS constant in lib/gitlab/event_store.rb (or EE_SUBSCRIPTION_GROUPS in ee/lib/ee/gitlab/event_store.rb for EE)

Omniscient Classes

  • DO NOT add new methods or data to omniscient classes (Project, User, MergeRequest, Ci::Pipeline, or any class >1000 LOC); create a dedicated class instead
  • Prefer thin domain wrapper objects (e.g., AntiAbuse::UserTrustScore.new(user)) over adding methods to large models
  • Use dependency inversion to encapsulate related behavior in a bounded context rather than adding it to a shared model

Use-Case-Oriented Design

  • DO NOT reuse a single service class for radically different use cases with different permissions, preconditions, or side-effects; create separate service classes per use case
  • Ensure each service class enforces a single, cohesive set of permissions and parameters

EventStore

  • Use Cloud Events (inheriting from Gitlab::EventStore::CloudEvent) for all new events; DO NOT create new legacy Gitlab::EventStore::Event subclasses (enforced by the Gitlab/EventStoreCloudEventInheritance RuboCop cop)
  • Define Cloud Event classes under app/events/<namespace>/; set event_category and event_type via class-level methods and implement data_schema returning a valid JSON Schema for the data field
  • Name events in past tense: <DomainObject><Action>Event (e.g., Ci::PipelineCreatedEvent, not Ci::CreatePipelineEvent); elide the domain object when obvious from the bounded context (e.g., MergeRequest::ApprovedEvent not MergeRequest::MergeRequestApprovedEvent)
  • Design events to be semantic (describe what occurred, not the intended subscriber action), specific (narrowly defined, use properties for additional info), and scoped (only publish events about domain objects within your bounded context)
  • Define event schemas as valid JSON Schema; mark unique identifiers as required and all other properties as optional
  • Publish only properties needed by subscribers; DO NOT tailor the payload to a specific subscriber
  • Dispatch events from service classes; use model state machine transitions as an exception, not ActiveRecord callbacks
  • DO NOT publish events about domain objects outside your bounded context
  • Introduce new Sidekiq subscriber workers in a prior deployment (or behind a feature flag) before registering the subscription
  • Use conditional dispatch (if: lambda) only for cheap synchronous checks; handle complex conditions inside handle_event
  • Access Cloud Event envelope fields (e.g., current_user, organization) directly on the event object; access custom payload via event.event_data
  • Follow the multi-rollout process when renaming events, adding required properties, or removing properties (expand → migrate → contract across separate milestones)
  • Use publish_event RSpec matcher to test publishers; use it_behaves_like 'subscribes to event' shared example to test subscribers
  • Define CE events and publish them in CE code; define EE events and publish them in EE code; subscribers may cross CE/EE boundaries
  • DO NOT use EventStore when logic must run synchronously as part of the main business transaction rather than as a side-effect
  • Use Gitlab::EventStore.publish_group when publishing multiple events of the same type in a single transaction to reduce Sidekiq load; configure group_size on the subscription as needed
  • Consider configuring a concurrency limit on subscriber workers if there is a risk of saturating shared resources

Modules and Instance Variables

  • DO NOT share instance variables across multiple mixed-in modules; keep instance variables contained within the module that owns them
  • Prefer the @var ||= value single-assignment pattern when memoizing in a module
  • DO NOT use instance variables in view partials; pass locals explicitly and fetch with local_assigns.fetch(:key)

Abstract Methods

  • Use Gitlab::AbstractMethodError (not NotImplementedError, NoMethodError, or a generic string raise) for abstract methods that subclasses must implement
  • Prefer composition and duck typing over inheritance; use abstract methods only for framework integration points or shared-template components (e.g., ViewComponents)

Application Limits

  • Add new plan limits as a column in plan_limits with a non-null default, then fine-tune per plan using create_or_update_plan_limit in a separate migration
  • Cover all GitLab.com plans (default, free, premium, premium_trial, ultimate, ultimate_trial, ultimate_trial_paid_customer, opensource) in limit migrations; omitting a plan causes those customers to receive the default (possibly 0/unlimited)
  • Expose every new plan limit column through the admin Plan Limits API by adding it as an optional parameter on PUT /application/plan_limits and to the response entity in lib/api/entities/plan_limit.rb; plan_limits is cell-scoped configuration, so this API is the only way administrators can tune limits per cell
  • Use PlanLimits#exceeded? or the Limitable concern to enforce limits; DO NOT implement ad-hoc count checks
  • Use Rack::Attack for middleware-level rate limiting (add fields to ApplicationSetting's rate_limits JSONB column, update the JSON schema validator, extend Gitlab::RackAttack, and document the new settings) and Gitlab::ApplicationRateLimiter for controller/API-level throttling

Backwards Compatibility Across Updates

  • Follow the expand-and-contract pattern for breaking changes: expand (backward-compatible addition) → migrate (update consumers) → contract (remove old code) across separate milestones
  • DO NOT bundle expand and migrate phases into the same milestone for Sidekiq worker parameter changes (Puma may restart before Sidekiq, causing job failures)
  • Ensure new GraphQL fields or REST API fields added in release N are not used by frontend code until release N+1 (or are behind a default-disabled feature flag, or degrade gracefully)
  • DO NOT add a NOT NULL column constraint without a default value when old application nodes are still inserting rows without that column
  • Ensure route changes follow expand-and-contract: add new route first, then generate new-format links, then remove old route

Cells Compatibility

  • Design all new code with Cells compatibility in mind; follow the Cells development principles
  • DO NOT introduce plan limits that must stay consistent across cells — plan_limits is cell-scoped configuration and is not migrated between cells

CE/EE Code Separation

  • CE code (outside ee/) must not directly reference EE:: namespaced classes
  • EE extensions use prepend_mod pattern in CE files
  • If CE code needs EE-aware behavior, use prepend_mod hooks or Gitlab.ee? guards
  • Flag direct references to EE:: namespaced classes in CE code (prevents FOSS build failures)

ActiveRecord Callbacks

  • Callbacks should only modify data on the current model, not associated records
  • Question if callback logic should be in a service layer instead
  • Flag callbacks with side effects (external API calls, updating other records, complex business logic)
  • Flag bulk operations on associated records in callbacks (performance concern as associations grow)
  • Acceptable uses: data normalization on current model only (trimming whitespace, setting defaults)

Authorization

  • Before changing authorization logic, read the existing authorize! / authorize_admin! call and verify what permission it currently enforces; the required fix may be documentation- or test-only with no code change needed

Authoritative sources

For the full picture, see:

  • doc/development/reusing_abstractions.md
  • doc/development/software_design.md
  • doc/development/eventstore/_index.md
  • doc/development/module_with_instance_variables.md
  • doc/development/application_limits.md
  • doc/development/multi_version_compatibility.md