Skip to content

Latest commit

 

History

History
106 lines (76 loc) · 11.3 KB

File metadata and controls

106 lines (76 loc) · 11.3 KB

Architecture

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.

Design principles

  1. Explicit over magical — SQL lives in versioned files; behavior is visible in code review. No ORM graph or hidden SQL generation in application code.
  2. 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.Error so structured internal/apperror values become stable JSON and status codes; see [ERROR_HANDLING.md](ERROR_HANDLING.md).
  3. Application orchestrationinternal/service coordinates use cases: transactions, policy checks, and calls into stores. Map persistence errors to internal/apperror here (not in handlers). Keep this free of Chi- or pgx-specific types where practical, except when translating store errors.
  4. Pure domain coreinternal/domain holds models, invariants, and validation helpers. It must not import database drivers, routers, or generated store code.
  5. Context everywhere — Pass context.Context through HTTP, services, and store calls. Use deadlines for outbound work and honor cancellation on shutdown.
  6. Structured logging — Use log/slog with consistent keys (for example request_id, method, path, status, duration).
  7. 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.

Layout (today)

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.

Import rules (enforced by convention)

  • internal/domain imports nothing from internal/store, internal/httpapi, or cmd.
  • internal/service may import internal/domain and internal/store/... but not Chi.
  • internal/httpapi may import internal/service (when present) and internal/store only if unavoidable; prefer services calling stores and handlers staying thin.
  • internal/store may import internal/domain for row mapping types when needed; avoid importing internal/httpapi.

Ci may later add import-lint or module boundaries; until then, reviewers hold the line.

Authentication, tenants, and RBAC

  1. Authenticationinternal/authn.Authenticator identifies 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.
  2. Tenant context — Under /v1, **Authenticate** stores the principal on context. Routes under /v1/organizations/{orgID} run **RequireOrgMembership**, which loads organization_memberships and attaches internal/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.”
  3. Authorization**internal/authz** implements scope checks. **internal/domain/rbac** defines roles, stable scope strings, and **EffectiveScopes(role, dbExtras)** (role baseline plus optional scopes from the row). Services invoke authz before sensitive reads or writes; handlers do not embed role switch logic.
  4. Principals and future API clients — Table **principals** has a **kind** column (user today). **organization_memberships.principal_id** references principals.id so service accounts / API clients can gain memberships in a later migration without reshaping this FK.

Accounting core (chart of accounts, fiscal years, periods)

  • Datagl_accounts, fiscal_years, and accounting_periods are keyed by **organization_id**. All sqlc queries filter on org (and fiscal year where relevant).
  • Scopesaccounting:read (list/read) and accounting: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 (openlocked only). 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.

Reporting and SAF-T (future)

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.

Persistence workflow

  1. Change sql/migrations for DDL.
  2. Update sql/schema so sqlc analysis matches deployed shape.
  3. Add or change sql/queries and run make sqlc (or make sqlc-docker if local sqlc tooling fails).
  4. Call generated code from services using pgx.Tx when a use case must be atomic.

Background jobs (Postgres queue)

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.

  1. API inserts a row (status=pending, JSON payload, organization_id for tenant isolation).
  2. cmd/worker runs a loop: optionally requeue stale running jobs (workers that died mid-job; the claim bump on attempts is undone so crashes do not consume retries), then ClaimNextBackgroundJob using FOR UPDATE SKIP LOCKED so multiple worker processes can share the table without double work.
  3. Handlers live in internal/worker/handlers and are registered by job_type string (for example import.processing, export.generation, document.processing). Unknown types fail the job with a clear error.
  4. Retries — On handler error, the worker sets status=pending, increments are driven by attempts (bumped at claim time), stores last_error, and sets run_after with exponential backoff. After max_attempts, the job is failed with completed_at set.
  5. Successstatus=succeeded, optional JSON result, 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.

Public API and integrations

  • 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-Key on selected POSTs (journal create/post/reverse); persistence in api_idempotency_records (migration 000011).
  • Future API clientsapi_clients and principals.kind = integration prepare machine auth (e.g. X-API-Key) without a second HTTP surface.

Operations

  • Local developmentdocker compose up starts Postgres, the API, and the background worker; set DATABASE_URL and HTTP_ADDR as in [.env.example](.env.example).
  • HealthGET /health/live is liveness; GET /health/ready checks database reachability via a trivial sqlc query.