Database
How the API connects to PostgreSQL, manages migrations, and generates type-safe query code.
Schema-per-Module
Each module owns a dedicated PostgreSQL schema. The recipe module uses the recipe schema, a future ingredient module would use ingredient, and so on. This keeps tables isolated between bounded contexts — no cross-module foreign keys.
All SQL uses schema-qualified table names (recipe.recipes, not just recipes). This is explicit and avoids any reliance on search_path configuration.
Migrations
Migrations use goose with SQL files embedded in the Go binary.
File location
Each module stores its migrations alongside its infra code:
internal/modules/recipe/infra/
migrations/
00001_create_schema.sql
00002_create_recipes_table.sql
migrations.go # //go:embed migrations/*.sql
Per-module version tracking
Each module gets its own goose version table (e.g., goose_recipe_version). Version numbers are scoped per module — every module starts at 00001 with no global coordination needed.
Writing a migration
Create a new SQL file in the module's infra/migrations/ directory. Use the next sequential number:
-- +goose Up
CREATE TABLE recipe.ingredients (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
recipe_id UUID NOT NULL REFERENCES recipe.recipes(id),
name TEXT NOT NULL,
quantity TEXT NOT NULL
);
-- +goose Down
DROP TABLE IF EXISTS recipe.ingredients;
Always include a -- +goose Down section for rollbacks.
Running migrations
Local development — either approach works:
# Standalone CLI (recommended)
cd apps/api && make migrate
# Or auto-migrate on startup
# Set DB_AUTO_MIGRATE=true in .env, then start the server
Production — always use the standalone migrate binary. Run it once before deploying new API instances to avoid race conditions:
go run ./cmd/migrate
# or: ./bin/migrate (if pre-built)
Query Generation with sqlc
sqlc generates type-safe Go code from annotated SQL queries. This mirrors the OpenAPI-first approach used for HTTP endpoints — SQL is the source of truth.
File structure
internal/modules/recipe/infra/
queries/
recipes.sql # Annotated SQL queries
sqlc/
sqlc.yaml # sqlc configuration
db/
db.go # Generated (do not edit)
models.go # Generated (do not edit)
recipes.sql.go # Generated (do not edit)
Writing queries
Add annotated SQL to a file in infra/queries/. Use schema-qualified table names:
-- name: ListRecipes :many
SELECT id, title, created_at, updated_at
FROM recipe.recipes
ORDER BY created_at DESC;
-- name: CreateRecipe :one
INSERT INTO recipe.recipes (title)
VALUES ($1)
RETURNING id, title, created_at, updated_at;
The annotation comments (-- name: ... :many/:one/:exec) tell sqlc what Go function signature to generate. See the sqlc docs for all options.
Generating code
After changing queries or migrations:
cd apps/api && make sqlc
Or regenerate everything (sqlc + OpenAPI):
pnpm generate --filter=api
Using generated code in a repository
The generated Queries struct accepts any DBTX interface (connection pool or transaction). The repository wraps it and maps between pgtype types and clean domain types:
func NewRecipeRepository(db recipedb.DBTX) *RecipeRepository {
return &RecipeRepository{q: recipedb.New(db)}
}
The toDomain helper converts sqlc's pgtype.UUID / pgtype.Timestamptz to the domain's uuid.UUID / time.Time:
func toDomain(row recipedb.RecipeRecipe) domain.Recipe {
return domain.Recipe{
ID: uuid.UUID(row.ID.Bytes),
Title: row.Title,
CreatedAt: row.CreatedAt.Time,
UpdatedAt: row.UpdatedAt.Time,
}
}
Connection Pool
A single pgxpool.Pool is created at startup in main.go and injected into each module's Register() function. Modules do not create their own connections.
Pool settings are configured via DB_* environment variables — see Getting Started for the full list.