Skip to content
GantryCD

Backend internals

The backend is intentionally layered, and the layers are kept narrow on purpose. The detailed rules are in Code Boundaries; this is the shape and the why.

The request flow

A user API request passes through four layers, in order:

Middleware  →  Handler  →  Service  →  Repository
 (authn,       (parse,      (business    (SQL only)
  context)      authz,       logic)
                map errors)
LayerOwnsMust not
HandlerHTTP parsing, validation, authz, calling services, mapping errors to status codes.Contain business logic.
ServiceBusiness rules, orchestration, transaction-scoped state changes, queue invariants.Run authz; parse HTTP.
RepositorySQL, and only SQL. Prefer one focused query over several generic ones.Hold business logic.
Jobs (internal/backend/jobs/)Background orchestration, via services/repos.Write SQL directly.

Runner-facing routes use the same shape, with runner-JWT middleware instead of session auth.

The recurring temptation is to blur a boundary “to save a few lines.” Don’t — the layering is what keeps authz on the hot path, SQL out of business logic, and the codebase navigable.

Authorization is not a service

Authz lives entirely in internal/authz/ and answers every question with a single parameterized SQL query — no data is pulled into Go to branch on. It’s built for the hot path: handlers run it, services assume it already ran.

  • A check is (user, org, resource_type, action, resource_id)Granted or Denied.
  • Permissions come from roles; roles are reached by direct assignment or SSO group binding.
  • Super-admin bypasses everything; otherwise an active membership is required.

The whole package is three queries built from shared SQL fragments. If a check seems to need more, that’s a signal you’re solving the wrong problem at the wrong layer. The full design — including how single-resource routes return 404 rather than 403 to avoid a name-enumeration oracle — is in Authorization.

CQRS transactions

Read and write transactions are distinct types, so the compiler keeps reads off the write path:

readTx, _ := pool.BeginReadTx(ctx)
entity, _ := repo.GetByID(ctx, readTx, id)        // reads take *sql.Tx

writeTx, _ := pool.BeginWriteTx(ctx)
entity, _ = repo.GetByID(ctx, writeTx.Unwrap(), id) // read inside a write: Unwrap()
_ = repo.Update(ctx, writeTx, entity)               // writes take db.WriteTx

The rule of thumb: write methods take db.WriteTx; read methods take *sql.Tx; a read inside a write transaction calls .Unwrap().

Domain and contracts

  • pkg/domain/ holds entities, validation, and typed errors (pkg/domain/errors.go). Handlers map those to HTTP status with WriteError — never http.Error directly, which would break the JSON-error contract. Check errors with errors.As; don’t add IsXxx wrappers (they were removed deliberately).
  • pkg/contracts/ holds the backend↔runner wire types like RunContext. HTTP types never leak into domain or repository code.

Logging

Logging is context-based: contextutil.GetLogger(ctx), never a logger stored on a struct. Attribute keys are snake_case (org_id, run_id) because they become metric labels and dashboard filters — mixing styles breaks queries.

Naming

Boolean methods follow two narrow forms: Is* for state predicates (Run.IsTerminal), Has* for presence checks. There is no Can* — authorization questions go through internal/authz, not entity methods.