Storage Model
How Ductor's Postgres layer is built — sqlc-generated queries, the CQRS Manager, narrow interface projections, and backward-compatible migrations.
The infrastructure layer's Postgres implementation follows a few firm conventions: type-safe queries via sqlc, a CQRS split between reads and writes, narrow interface projections so each caller sees only what it needs, and backward-compatible migrations. This page explains how they fit together.
sqlc, not raw SQL
New queries are written as SQL and compiled to type-safe Go by
sqlc. The generated Querier interface (package
sqlcstorage) is the low-level data-access surface; every column type, argument,
and result struct is generated from the schema, so a query that doesn't match
the schema fails at generation time rather than at runtime.
# sqlc.yaml (excerpt)
sql:
- engine: "postgresql"
schema: "infrastructure/storage/postgres/sqlc/schema.sql"
queries: "infrastructure/storage/postgres/sqlc/queries"
gen:
go:
package: "sqlcstorage"
sql_package: "pgx/v5"
emit_interface: truesqlc runs on the pgx/v5 driver and connection pool. Type overrides map Postgres
types to the Go types the domain expects (for example, timestamptz → time.Time,
and non-nullable UUID primary keys → plain string).
When sqlc can't express it
A few queries need runtime-dynamic SQL — WHERE clauses built from
whitelisted sort fields, PL/pgSQL function calls, or session-level set_config
for row-level security. Those go through an in-repo parameterized SQL builder
(infrastructure/storage/postgres/builders/, $N args, whitelisted fields)
rather than string concatenation. Everything else is sqlc.
The CQRS Manager
On top of the generated Querier, Ductor composes a CQRS surface that
separates reads from writes:
QueryStorecomposes 30-plus domain-specific Reader interfaces — everything a read-only consumer needs.CommandStorecomposes all the Writer interfaces plus transactions.Manageris the union ofQueryStoreandCommandStore. There is a single implementation, ininfrastructure/storage/postgres/cqrs/store/manager.go.
Domain-scoped repositories embed a shared DBHandle (the querier, the connection
pool, and an optional transaction handle) so they can all participate in the same
transaction when needed and share cache-invalidation hooks.
Project from broad to narrow
The Manager is deliberately broad — it can do everything. But application
services don't take the Manager; they take a narrow local interface that
lists only the methods that service uses. An adapter projects the broad manager
down to the narrow contract:
This keeps mock surfaces small in tests and makes each call site's real data dependencies legible — you can read a service's interface and know exactly which tables it touches. New code is encouraged to prefer these narrow per-aggregate interfaces over the composite. See Layered Architecture.
Migrations are backward-compatible
Schema changes are ordered goose migrations under
infrastructure/storage/postgres/cqrs/migrations/, applied with the migrate
subcommand (ductor migrate). The firm rule: migrations must be
backward-compatible within a release — no breaking column removes that would
strand a running older binary mid-deploy. Add columns and backfill; don't drop
what the currently-deployed code still reads.
State-machine columns (run status, attempt status, timer status, idempotency
status) are mirrored by Go constants that are documented as the single source of
truth for the corresponding SQL CHECK constraint literals — so the allowed
values in code and in the database can't silently drift apart.
Contract harness tests
A storage convention worth calling out: new storage implementations must pass a
contract harness — a reusable test suite
(<component>_contract_harness_test.go) that any implementation of an interface
runs against. The same behavioral tests exercise the Postgres implementation and
any in-memory fake, so a fake used in unit tests is guaranteed to behave like the
real store. Integration coverage uses
testcontainers to spin up real Postgres and
Redis.
Where to go next
- Data Flow — what each table stores and who writes it.
- Layered Architecture — where storage sits in the dependency graph.
- Events & Event Sourcing — the event-sourced aggregates alongside the relational tables.