Skip to main content

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​

  1. Each module owns its OpenAPI spec at internal/modules/<name>/transport/api/openapi.yaml
  2. Module specs use full paths (e.g., /recipes, /recipes/{id}) and are self-contained
  3. Shared types (like Error) live in internal/shared/api/schemas.yaml and are referenced via $ref
  4. redocly join auto-discovers and merges all module specs into a single bundled spec
  5. oapi-codegen generates a ServerInterface and models from each module's spec
  6. The bundled spec is embedded via go:embed and served at GET /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 — build depends on generate, so builds always regenerate first
  • CI check — runs pnpm generate and fails if git diff detects 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​

  1. Client sends Authorization: Bearer <token> (case-insensitive scheme per RFC 7235)
  2. RequireAuth middleware extracts the token and validates it against the Identity service's JWKS
  3. Validation checks: signature (EdDSA/Ed25519), issuer, audience, expiration, and sub claim
  4. On success, the authenticated user is injected into the request context
  5. 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​

MiddlewareBehavior
RequireAuthReturns 401 if no valid token is present
OptionalAuthPasses through without auth, but populates context if a valid token is present

Key files​

FileResponsibility
internal/shared/auth/config.goAuth configuration (AUTH_JWKS_URL, etc.)
internal/shared/auth/jwks.goJWKS fetching, caching, and JWT validation
internal/shared/auth/context.goUser struct, UserFromContext, ContextWithUser
internal/shared/middleware/auth.goRequireAuth 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.