This repository is a modular monolith: one deployable API with clear internal boundaries, not a network of microservices. The goal is maintainability and honest layering over framework cleverness.
- Explicit over magical — SQL lives in versioned files; behavior is visible in code review. No ORM graph or hidden SQL generation in application code.
- Thin transport — HTTP handlers validate shape, call application services, and encode responses. They do not compose business rules or open transactions directly except through a dedicated application layer when that stays clearer. Errors: handlers use
respond.Errorso structuredinternal/apperrorvalues become stable JSON and status codes; see[ERROR_HANDLING.md](ERROR_HANDLING.md). - Application orchestration —
internal/servicecoordinates use cases: transactions, policy checks, and calls into stores. Map persistence errors tointernal/apperrorhere (not in handlers). Keep this free of Chi- or pgx-specific types where practical, except when translating store errors. - Pure domain core —
internal/domainholds models, invariants, and validation helpers. It must not import database drivers, routers, or generated store code. - Context everywhere — Pass
context.Contextthrough HTTP, services, and store calls. Use deadlines for outbound work and honor cancellation on shutdown. - Structured logging — Use
log/slogwith consistent keys (for examplerequest_id,method,path,status,duration). - OpenAPI-first — The HTTP contract is described in
[openapi/openapi.yaml](openapi/openapi.yaml). Evolve handlers and the spec together; add codegen only when the team agrees on a generator.
| Path | Purpose |
|---|---|
cmd/api |
Process entry: load config, logger, run internal/app. |
cmd/worker |
Background job runner: polls Postgres, claims jobs, runs registered handlers. |
internal/app |
Process lifecycle: signals, HTTP listen/shutdown; delegates construction to bootstrap. |
internal/app/bootstrap |
Composition root: database pool, sqlc store, router, http.Server. |
internal/worker |
Job registry + poll/claim/retry loop; handlers in internal/worker/handlers. |
internal/config |
Environment-based configuration; fail fast on missing required values. |
internal/apperror |
Typed application errors returned from services; HTTP mapping lives in internal/httpapi/respond. |
internal/authn |
Authentication boundary (Authenticator); dev header implementation today, JWT/OIDC later. |
internal/authz |
Scope checks (Require, CanReadOrg, …); called from services, not scattered in handlers. |
internal/tenant |
Request context: authenticated PrincipalID, org-scoped Organization after membership middleware. |
internal/requestctx |
Request correlation id on context.Context (shared by middleware and respond). |
internal/httpapi |
Chi router, middleware, handlers; encode/decode only at this edge. |
internal/service |
Application/use-case layer (orchestration). |
internal/domain |
Core types and rules without infrastructure imports (includes domain/accounting validators). |
internal/database |
Postgres pool, optional startup migrations (embedded files under sql/migrations). |
sql/migrations |
Versioned DDL embedded in the binary (embed.go) and used by the migrate CLI. |
internal/store/db |
sqlc-generated queries and shared DB interfaces. |
sql/queries |
Hand-written SQL for sqlc. |
sql/schema |
Schema snapshot sqlc uses to analyze queries; keep aligned with migrations. |
internal/pgtypeconv |
Small helpers (OrgUUID, DateUTC, …) for sqlc/pgtype params shared by services. |
internal/integrationtest |
Shared DATABASE_URL pool + standard test org for //go:build integration tests. |
Persistence access: New SQL lives in sql/queries and is consumed via internal/store/db with db.New(pool) or db.New(tx) inside the service that owns the transaction. Thin wrappers under internal/store/accounting (and similar) are optional for non-transactional CRUD; services that already manage pgx.Tx may call sqlc directly.
Add new capability by introducing a focused package (for example internal/accounting), wiring routes in internal/httpapi, and extending SQL—not by starting a new service.
internal/domainimports nothing frominternal/store,internal/httpapi, orcmd.internal/servicemay importinternal/domainandinternal/store/...but not Chi.internal/httpapimay importinternal/service(when present) andinternal/storeonly if unavoidable; prefer services calling stores and handlers staying thin.internal/storemay importinternal/domainfor row mapping types when needed; avoid importinginternal/httpapi.
Ci may later add import-lint or module boundaries; until then, reviewers hold the line.
- Authentication —
internal/authn.Authenticatoridentifies the caller. The default implementation reads**X-Principal-ID** (a UUID) for local/dev use. Swap in JWT/Bearer or mTLS without changing route registration; keep middleware calling the same interface. - Tenant context — Under
/v1,**Authenticate** stores the principal oncontext. Routes under/v1/organizations/{orgID}run**RequireOrgMembership**, which loadsorganization_membershipsand attachesinternal/tenant.Organization(role, effective scopes). Invalid/missing membership returns 404 with a generic message so clients cannot distinguish “org does not exist” from “no access.” - Authorization —
**internal/authz** implements scope checks.**internal/domain/rbac**defines roles, stable scope strings, and**EffectiveScopes(role, dbExtras)**(role baseline plus optionalscopesfrom the row). Services invokeauthzbefore sensitive reads or writes; handlers do not embed roleswitchlogic. - Principals and future API clients — Table
**principals** has a**kind**column (usertoday).**organization_memberships.principal_id**referencesprincipals.idso service accounts / API clients can gain memberships in a later migration without reshaping this FK.
- Data —
gl_accounts,fiscal_years, andaccounting_periodsare keyed by**organization_id**. All sqlc queries filter on org (and fiscal year where relevant). - Scopes —
accounting:read(list/read) andaccounting:manage(create, lock). Owner/admin have both; member/viewer have read only. Enforced in[internal/service/accounting](internal/service/accounting)via[internal/authz](internal/authz). - Domain rules — Non-overlapping fiscal years (inclusive dates), periods contained in their fiscal year without overlap, digit-only account codes, one-way period lock (
open→lockedonly). Implemented in[internal/domain/accounting](internal/domain/accounting)and covered by unit tests. - HTTP — Routes live under
/v1/organizations/{orgID}/accounting/...after tenant middleware.
Journals, ledger balances, trial balance, and SAF-T exports remain future modules that reuse these tables and respect locked periods when posting is added.
Treat ledger views, trial balance, and SAF-T exports as separate internal packages that share the same database and deployment. Prefer read models or SQL tuned for reporting where ad-hoc joins in transactional code would blur concerns.
- Change
sql/migrationsfor DDL. - Update
sql/schemaso sqlc analysis matches deployed shape. - Add or change
sql/queriesand runmake sqlc(ormake sqlc-dockerif local sqlc tooling fails). - Call generated code from services using
pgx.Txwhen a use case must be atomic.
Async work uses a single background_jobs table in Postgres—no Redis or separate queue broker. This keeps operations simple: one database, one migration story, and a worker process that polls and claims rows.
- API inserts a row (
status=pending, JSONpayload,organization_idfor tenant isolation). cmd/workerruns a loop: optionally requeue stalerunningjobs (workers that died mid-job; the claim bump onattemptsis undone so crashes do not consume retries), thenClaimNextBackgroundJobusingFOR UPDATE SKIP LOCKEDso multiple worker processes can share the table without double work.- Handlers live in
internal/worker/handlersand are registered byjob_typestring (for exampleimport.processing,export.generation,document.processing). Unknown types fail the job with a clear error. - Retries — On handler error, the worker sets
status=pending, increments are driven byattempts(bumped at claim time), storeslast_error, and setsrun_afterwith exponential backoff. Aftermax_attempts, the job isfailedwithcompleted_atset. - Success —
status=succeeded, optional JSONresult, locks cleared.
Run the API and worker as separate processes (see docker-compose.yml: api and worker share the same image; the worker overrides command to /app/worker). Configuration: internal/config/worker.go (WORKER_POLL_INTERVAL, WORKER_STALE_LOCK_AFTER, WORKER_ID).
Concrete example: POST .../accounting/jobs/import-validation enqueues import.processing; the worker runs journalimport.ValidateBatch for the batch id in the payload. GET .../accounting/jobs/{jobID} returns status and errors for polling.
- URL versioning — Product routes are under
/v1. See docs/API_GUIDELINES.md for pagination, idempotency, error shape, and how internal apps and external integrators share the same API. - Idempotency — Optional
Idempotency-Keyon selected POSTs (journal create/post/reverse); persistence inapi_idempotency_records(migration000011). - Future API clients —
api_clientsandprincipals.kind = integrationprepare machine auth (e.g.X-API-Key) without a second HTTP surface.
- Local development —
docker compose upstarts Postgres, the API, and the background worker; setDATABASE_URLandHTTP_ADDRas in[.env.example](.env.example). - Health —
GET /health/liveis liveness;GET /health/readychecks database reachability via a trivial sqlc query.