Architecture

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: true

sqlc runs on the pgx/v5 driver and connection pool. Type overrides map Postgres types to the Go types the domain expects (for example, timestamptztime.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:

  • QueryStore composes 30-plus domain-specific Reader interfaces — everything a read-only consumer needs.
  • CommandStore composes all the Writer interfaces plus transactions.
  • Manager is the union of QueryStore and CommandStore. There is a single implementation, in infrastructure/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:

adapter adapter adapter cqrs.Manager RoutingStorageCQRS(what routing needs) ClaimStorageCQRS(what claims need) WorkflowOpsStore(what workflow ops need)

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