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)
| Layer | Owns | Must not |
|---|---|---|
| Handler | HTTP parsing, validation, authz, calling services, mapping errors to status codes. | Contain business logic. |
| Service | Business rules, orchestration, transaction-scoped state changes, queue invariants. | Run authz; parse HTTP. |
| Repository | SQL, 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)→GrantedorDenied. - 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 withWriteError— neverhttp.Errordirectly, which would break the JSON-error contract. Check errors witherrors.As; don’t addIsXxxwrappers (they were removed deliberately).pkg/contracts/holds the backend↔runner wire types likeRunContext. 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.