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:
- Middleware authenticates and enriches context.
- Handler validates input and runs authz in a dedicated read transaction.
- Service executes business logic.
- 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
WriteErrorwith a typed*domain.Xxxerror — neverhttp.Errordirectly, 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; mixingorgIDin inlineslog.Stringcalls 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.