Skip to content

Are We Drifting? — Part 7: Models at Boundaries

Are We Drifting? — Part 7: Models at Boundaries

Section titled “Are We Drifting? — Part 7: Models at Boundaries”

The vocabularies arc was about closed values. This part is about closed models — and the most expensive drift of all, the kind where two whole layers fuse into one and can no longer move independently.

There is a tempting shortcut in every backend: define one Project (or Workout, or User) struct, and use it everywhere. The database reads and writes it. The API serializes it. The domain logic mutates it. The event payload is it. The frontend types mirror it.

It feels DRY. It is in fact the tightest coupling you can build.

Because now a database column rename is an API break. A field the frontend needs forces a storage migration. A JSON tag bleeds into your business logic. An internal field you never meant to expose ships to clients because it happened to be on the struct. The layers cannot drift apart, which sounds good — but only because they have drifted together into a single thing that must change all at once, forever.

So the question for models is the inverse of the one for values. For values we ask “do the copies still agree?” For models we ask: can each layer change without dragging the others with it?

The principle: separate models at important boundaries

Section titled “The principle: separate models at important boundaries”

The architecture philosophy we build against is blunt about this:

Do not use one model everywhere. Use separate models at important boundaries.

The boundaries, and what each model is for:

DB rows store data (storage shape: nullable columns, FKs, indexes)
Domain models enforce rules (a project must have an owner; a name can't be empty)
Commands express intent (authenticated, authorized — the server sets ActorID)
Events record facts (something that already happened; see Part 8)
Read models serve screens (joins, denormalized, query-optimized)
API/DTO models serve clients (compatibility, security, pagination — promises)
External models isolate vendors (a third-party shape stops at the adapter)

The point is not ceremony. It is that each of these is shaped by a different force. A DB row is shaped by storage and indexing. An API model is shaped by client compatibility and security — and once published, it is expensive to change. A domain model is shaped by business rules and should not know what a JSON field name or a SQL null wrapper is. Smashing them together means every one of those forces pulls on every layer.

The translation between them is not waste; it is the seam that lets one side change without the other noticing:

CreateProjectRequest (what the client sends)
-> CreateProjectCommand (trusted intent: server adds ActorID)
-> domain.Project (rules enforced)
-> db.ProjectRow (storage shape)
-> ProjectCreatedEvent (the fact)
-> ProjectView (what the screen needs)

One discipline worth singling out: where a domain model wraps a generated storage row, it should do so by private composition, not public embedding. Embedding the DB struct re-exposes every column and lets anything mutate state outside your rules; holding it as a private field and exposing behavior (Rename, Archive) keeps the invariants where they belong.

We apply the principle hardest at the two boundaries that hurt most when they fuse: storage and the wire.

Storage models are generated and private. Our SQL is hand-authored; sqlc generates the Go row structs and query methods from it, and those generated models live behind the repository layer. A check-sqlc gate fails the build if the committed generated code drifts from the SQL — the same one source → projection → gate shape as the vocabularies, applied to the database access layer.

The repo interface seam keeps storage from leaking up. A handler depends on a repository interface, not on the concrete Postgres type — so pgx and the row structs stay below the seam, and the handler can be tested against a fake. This is a governed sub-system on the backend (the “Repo Interface Seam”): the boundary is a structural rule, not a habit.

The wire model is the proto contract. API messages are defined in protobuf and generated into Go and TypeScript. The client never sees a database row; it sees a generated message shaped for clients. That seam is what Part 5: The Error Manifest and the later contract parts lean on.

The full six-model taxonomy above is the philosophy. Our Go API is deliberately leaner: handlers are thin and do their validation-and-mapping in one pass rather than through a separate pure domain layer or an explicit command type — there is no flow.go equivalent on the backend. The seams we enforce mechanically are the storage seam (sqlc + repo interface) and the wire seam (proto). The command/domain/read-model distinctions are applied by judgment where a domain has real invariants, and skipped where it is just CRUD. Events — the “facts” boundary — are real, but they live in a dedicated plane that gets its own part next.

That selectiveness is itself a rule from the philosophy:

If a model is mostly CRUD, wrapping the generated DB model is fine. If it has real invariants, hide the DB model behind methods. If it becomes central to the business, graduate it to a hand-written domain type.

You do not pay for boundaries you do not need. You pay for the two that always earn it — storage and the wire — and you enforce those with a gate.

What “mechanically enforced” actually means here

Section titled “What “mechanically enforced” actually means here”

“Enforced at the storage and wire seams” is not a policy. It is five named gates, each catching a specific way those seams can break.

make check-sqlc regenerates the sqlc output and fails if the committed files differ from what the hand-authored SQL would produce — the same one-source-→-projection-→-gate shape as the vocabulary layer, applied to database access.

make check-sql-scope catches the silent corruption that leaks data across tenant boundaries: a query that touches workouts, programs, or media_assets without a user_id filter fails the build before the PR is reviewed.

Both run inside lint-be, so neither is a suggestion you remember on a good day.

The interface seam is enforced by two depguard rules in apps/api/.golangci.yml.

handler-no-db-driver refuses any import of github.com/jackc/pgx/v5 or pgxpool from **/internal/*/handler.go — if the handler can’t import the DB driver, it is structurally forced to go through the interface.

repo-no-transport refuses connectrpc.com/connect and net/http from **/internal/*/repo.go — the repo returns domain errors; only the handler maps them to Connect codes.

Together those two rules mean the only dependency between the layers is the reader interface declared by the handler in its own package.

The last gate is a compile assertion in every handler file:

var _ workoutv1connect.WorkoutServiceHandler = (*Service)(nil)

If the concrete *Service stops satisfying the generated handler interface — because a proto message changed, or a method signature drifted — the build refuses to link.

Drift scenarioNamed gateWhere it runs
Generated DB row structs diverge from hand-authored SQLmake check-sqlclint-be → CI
A query touches workouts, programs, or media_assets without a user_id filtermake check-sql-scopelint-be → CI
handler.go imports pgx or pgxpool directlydepguard: handler-no-db-drivergolangci-lint
repo.go imports connectrpc.com/connect or net/httpdepguard: repo-no-transportgolangci-lint
*Service drifts from the generated handler interfacevar _ workoutv1connect.WorkoutServiceHandler = (*Service)(nil)compile time

One honest residual: depguard catches the package-import half of the seam, but it cannot verify that the reader field in Service is declared as the interface type rather than a *Repo pointer.

That residual is a named review note — when adding a new domain, the reviewer confirms Service.reader (or its equivalent) is the interface, not the concrete struct pointer.

There is also one live exception: internal/programs is excluded from all linting until its Connect-RPC rewrite lands (TODO(programs-rpc)).

The gate is not there because we trust the process. It is there because we learned — with real incidents — that each of these seams breaks silently and expensively when it isn’t mechanically held.

The coherence check this removes is the slow, structural one: if I change this storage detail, what else breaks?

With the seams in place, the answer is bounded. Reshape a table and regenerate — the repo interface absorbs it, and nothing above the seam recompiles against pgx. Add a client-facing field to a proto message — storage does not move. The layers are free to drift apart deliberately, which is the only kind of freedom that lets you move fast in one place without auditing all the others.

It is the same trade as everywhere in this series: a little declared duplication at the boundary, in exchange for never hand-tracing a change across layers that were never supposed to be the same thing.

And the rails that buy this freedom are cheap to lay now: wiring the repo-interface seam or adding a depguard rule is an afternoon, not a quarter — the cost of the boundary has collapsed, while the cost of the coupling it prevents has not.

One of those boundaries — events, the record of facts that already happened — turns out to be the most powerful anti-drift tool in the stack. Part 8: Projected Truth is about projected truth: an append-only event log as the one source, read models as disposable projections, and the ability to rebuild any view from the log when you suspect it has drifted.