Skip to content
GantryCD

Code Boundaries

This repo is intentionally layered. Keep responsibilities narrow and do not blur boundaries to save a few lines of code.

Layering

Request flow for user APIs:

  1. Middleware authenticates and enriches context.
  2. Handler validates input and runs authz in a dedicated read transaction.
  3. Service executes business logic.
  4. Repositories execute SQL.

Runner-facing APIs follow the same shape, except runner JWT middleware replaces session auth.

Package Roles

internal/backend/handlers/

Handlers own:

  • HTTP parsing and validation
  • path/query/body decoding
  • authz invocation
  • calling services
  • mapping domain errors to HTTP responses (always via WriteError with a typed *domain.Xxx error — never http.Error directly, which breaks the JSON-error contract)

Handlers do not own business logic.

Org-id source

When a route flows through Authorizer.WithUserCanX(...) middleware, that middleware validates the path {org_id} and sets the value on the context. Handlers on those routes read contextutil.GetOrgID(ctx) and do not re-read or re-validate r.PathValue("org_id"). The exception is routes whose authz is handler-side (Stack create, Cloud-integration create, SCM-integration create, plus all backstage routes which gate on super-admin instead of WithUserCanX); those handlers extract from the path because the context value isn’t set.

internal/authz/

Authz is not a service. It is a set of direct SQL checks optimized for hot-path access decisions.

Rules:

  • run authz from handlers, not services
  • use one read transaction per handler authz check
  • keep checks to one SQL query when possible
  • push branching into SQL with CASE, EXISTS, and CTEs

If you think authz must run inside a write transaction, stop and justify it explicitly first.

internal/backend/services/

Services own:

  • business rules
  • orchestration across repositories and integrations
  • transaction-scoped state changes
  • queue invariants and workflow progression

Services do not own authz and do not parse HTTP.

internal/backend/repositories/

Repositories own SQL only.

Prefer:

  • one focused query over several generic helpers
  • server-side filtering/joining/ordering over service-layer data wrangling
  • atomic SQL state transitions over alternating SQL and Go checks

Important invariant: any write path that can change stack occupancy must finish with RunRepository.PromoteNextOnStack(...) inside the same write transaction.

internal/backend/jobs/

Jobs are background orchestration only.

Rules:

  • no direct SQL in jobs
  • data access goes through repositories
  • business rules go through services, unless the job is itself the owner of a background concern such as self-heal task processing

Transactions

The repo uses type-safe CQRS transaction routing:

readTx, _ := pool.BeginReadTx(ctx)
entity, _ := repo.GetByID(ctx, readTx, id)

writeTx, _ := pool.BeginWriteTx(ctx)
entity, _ = repo.GetByID(ctx, writeTx.Unwrap(), id)
_ = repo.Update(ctx, writeTx, entity)

Rules:

  • reads use BeginReadTx
  • writes use BeginWriteTx
  • reads inside a write transaction use writeTx.Unwrap()
  • repository write methods accept db.WriteTx
  • repository read methods accept *sql.Tx

Workload classes & timeouts

Every pool entry point routes on the context’s workload class. Interactive (request-path) work uses the default pools: short Postgres session timeouts (DB_STATEMENT_TIMEOUT et al.) plus the per-request deadline from middleware.RequestTimeout. Background work — anything not serving a live HTTP request — must run under a context marked with db.WithBackgroundWorkload(ctx), which routes to the separate jobs pools (own connection budget, generous timeouts). The runtime marks the scheduler, queue workers, dispatchers, and metric scrapes at their entry points; a new background entry point must mark its root context the same way. Never mark a request-path context.

Timeout errors map in handlers.WriteError: a fired request deadline is a 504, statement_timeout (SQLSTATE 57014) is a 504, lock_timeout (55P03) is a 503, and deadlock_detected (40P01) is a 503 that tells the caller to retry. Never introduce an unbounded wait on the request path.

Commit outcome ambiguity

WriteTx.Commit can return domain.TransactionOutcomeUnknownError: the COMMIT was interrupted (deadline, dead connection, cancelled replication wait) and may still have been durably applied. Propagate it — handlers map it to a 504 that tells the user to re-check, never to a plain failure. Code that retries a write after a commit error must treat this error as “possibly applied” and re-read before retrying.

Domain And Contracts

  • pkg/domain/ holds validation rules, entities, and typed domain errors.
  • pkg/contracts/ holds backend/runner wire contracts.

Do not leak HTTP types into domain or repository code.

Logging

Logging is context-based:

  • attach structured request/run identity earlier in the stack
  • retrieve with contextutil.GetLogger(ctx)
  • do not keep loggers as service struct fields
  • slog attribute keys are snake_case (org_id, user_id, run_id) — these become metric labels and dashboard filters; mixing orgID in inline slog.String calls breaks query consistency

Naming

Boolean methods on entities follow two narrow forms; pick one to match the predicate’s shape:

  • Is* — state predicates (Run.IsTerminal, Stack.IsLocked, Org.IsPersonal)
  • Has* — property-presence checks (OrgIdentitySource.HasStoredOIDCClientSecret)

There is no Can*. Authorization questions go through internal/authz, not entity methods.

Free Is* helpers (e.g. an IsNotFoundError(err) wrapper around errors.As) were removed deliberately; do not reintroduce them. Inline the errors.As check at the call site. Non-trivial free predicates (IsValidPermission, IsAPIClientError) are fine — they hold real logic, not a one-line wrapper.

Where To Look Next