API
Go modular monolith serving the Chompardo backend. Runs at http://127.0.0.1:4000 (proxied via https://main-api.chompardo.dev).
Project Structure
apps/api/
cmd/
api/ Entry point — wires modules, starts HTTP server
migrate/ Standalone migration CLI for production deployments
openapi.yaml Root API metadata (title, version) for spec bundling
internal/
database/ Shared DB connectivity (pool, migrations)
modules/ Bounded contexts (one folder per domain module)
recipe/ Example module
domain/ Entities, value objects, repository interfaces
application/ Use cases and services
infra/ Concrete implementations (repos, sqlc, migrations)
migrations/ Goose SQL migration files
queries/ sqlc-annotated SQL queries
sqlc/ sqlc config (sqlc.yaml)
db/ sqlc-generated code (do not edit)
transport/ Transport layer (HTTP, events, etc.)
api/ OpenAPI spec, codegen config, generated server code + handlers
module.go Registers module routes, exposes Migrations FS
shared/
api/ Shared schemas, bundled spec embed, generated types
auth/ JWT validation (JWKS, user context helpers)
middleware/ Cross-cutting middleware (CORS, logging, auth, recovery)
pkg/ Public utilities (importable by other Go modules)
Dependency Rule
Dependencies point inward: transport → application → domain ← infra
- domain defines interfaces, everything else implements or calls them
- application depends only on domain interfaces
- infra implements domain interfaces (e.g., Postgres repository)
- transport calls application services to handle requests
OpenAPI-First Code Generation
The API uses an OpenAPI-first approach with oapi-codegen. The spec is the source of truth — you design the API contract first, then generate server code from it.
Pipeline
- Each module owns its OpenAPI spec at
internal/modules/<name>/transport/api/openapi.yaml - Module specs use full paths (e.g.,
/recipes,/recipes/{id}) and are self-contained - Shared types (like
Error) live ininternal/shared/api/schemas.yamland are referenced via$ref redocly joinauto-discovers and merges all module specs into a single bundled specoapi-codegengenerates aServerInterfaceand models from each module's spec- The bundled spec is embedded via
go:embedand served atGET /openapi.yaml
Module specs ──┐
├── redocly join ──→ bundled spec (embedded, served at /openapi.yaml)
Root metadata ─┘
Module spec ──→ oapi-codegen ──→ ServerInterface + models (per module)
Shared schemas ──→ oapi-codegen ──→ shared Go types (Error, etc.)
Safety Nets
- Turborepo pipeline —
builddepends ongenerate, so builds always regenerate first - CI check — runs
pnpm generateand fails ifgit diffdetects stale generated files
Authentication
The API validates JWT Bearer tokens issued by the Identity service. All module routes require authentication; operational endpoints (/healthz, /openapi.yaml) are public.
How it works
- Client sends
Authorization: Bearer <token>(case-insensitive scheme per RFC 7235) RequireAuthmiddleware extracts the token and validates it against the Identity service's JWKS- Validation checks: signature (EdDSA/Ed25519), issuer, audience, expiration, and
subclaim - On success, the authenticated user is injected into the request context
- Handlers retrieve the user via
auth.UserFromContext(ctx)
Unauthenticated requests receive a 401 with a WWW-Authenticate: Bearer header per RFC 6750.
JWKS caching
Public keys are fetched from the Identity service's /api/auth/jwks endpoint at startup and cached with automatic background refresh. If a token arrives with an unknown kid, the library re-fetches the JWKS to handle key rotation.
The JWKS fetch is eager — the API will fail to start if the Identity service is unavailable. In container orchestration environments, ensure the Identity service is healthy before starting the API.
Middleware variants
| Middleware | Behavior |
|---|---|
RequireAuth | Returns 401 if no valid token is present |
OptionalAuth | Passes through without auth, but populates context if a valid token is present |
Key files
| File | Responsibility |
|---|---|
internal/shared/auth/config.go | Auth configuration (AUTH_JWKS_URL, etc.) |
internal/shared/auth/jwks.go | JWKS fetching, caching, and JWT validation |
internal/shared/auth/context.go | User struct, UserFromContext, ContextWithUser |
internal/shared/middleware/auth.go | RequireAuth and OptionalAuth middleware |
Database
Connection
The API connects to PostgreSQL via pgx v5 (pgxpool). A single connection pool is created at startup in main.go and injected into each module's Register() function.
Local Postgres database api is auto-created by infra/local/postgres/01-init.sh:
- Role:
api - Password:
api_secret - Port:
5432
Schema-per-Module
Each module owns its own PostgreSQL schema (e.g., recipe module uses the recipe schema). This provides:
- Namespace isolation — tables from different modules never collide
- Clear ownership — a module's schema is defined and migrated alongside its code
- No cross-module foreign keys — modules are bounded contexts and communicate through application-level interfaces, not database joins
All SQL uses schema-qualified table names (e.g., recipe.recipes) rather than relying on search_path. This is explicit and survives connection pool recycling.
Migrations
Migrations use goose v3 with embedded SQL files. Each module has its own:
- Migration files in
infra/migrations/(embedded via//go:embed) - Goose version table named
goose_<module>_version(e.g.,goose_recipe_version)
Since each module tracks versions independently, migration numbering starts at 00001 per module — no global coordination required.
The first migration for every module creates its schema:
-- +goose Up
CREATE SCHEMA IF NOT EXISTS recipe;
Query Generation
SQL queries use sqlc to generate type-safe Go code from annotated SQL. Each module has its own sqlc config at infra/sqlc/sqlc.yaml that generates code into infra/db/.
SQL queries (infra/queries/)
──→ sqlc generate
──→ type-safe Go code (infra/db/)
The generated Queries struct accepts any DBTX interface (pool or transaction), and the repository layer wraps it to map between pgtype types and clean domain types.