Two related rules for keeping a system’s writes governable as it grows:
- One door in for writes — every mutation of a domain concept flows through a single explicit entry point, not a scatter of call sites.
- Only inert shapes cross boundaries — what you hand to a caller is a value, not a live, mutable, writable handle to your data.
Both are Command Query Separation taken up to the architectural level (its CQRS extension): reads and writes travel different paths, return different shapes, and are enforced as different things.
Single ingress: one door in
If there is exactly one place that performs a given write — a command object with
a single public call — then that place is where invariants are checked,
sequencing is decided, side effects are ordered, and locks (if any) are taken.
The implicit, invisible alternative is behaviour scattered across callbacks and
call sites that no one can see at once (see constraints over conventions).
One door makes the invisible visible: it’s discoverable, testable, and
enforceable — you can mechanically forbid writes that don’t go through it.
The window-in-the-wall problem
A single write door is useless if your reads hand back live, writable objects.
Every controller, job, rake task… is now an active liability.
If a query returns a live ORM record, the caller can simply call .update! on it
and walk straight around your command. You built a door and left a window open. An
ORM relation is worse still: it’s a writable handle — return
Seat.where(reserved: false) and a caller can chain .update_all(reserved: true) and mutate the whole set without ever touching your domain logic.
Cross a boundary as a value, not an entity
The fix is that anything crossing a boundary should be an inert shape with no
behaviour — a value object: immutable at construction, no
save, no associations to lazily load, no relation to chain off. A struct of
plain fields can’t reserve a seat, can’t fire an N+1, can’t be a back door. The
trade-off is real — you hand-write the shape and the read instead of returning the
record — but, in the author’s framing, a struct is a contract a reviewer can read
in five seconds, where a leaked live object is a debugging session waiting to
happen.
Reads are their own typed path
Give reads the same dignity as writes: dedicated query objects with one entry point and a typed return value (the read model), separate from the write commands. Benefits:
- The return type is a checkable contract — return the wrong thing (a live model instead of the value object) and it fails at the boundary, not three layers downstream.
- Reads can’t accidentally write, because the query path has no mutation methods.
- You can enforce purity mechanically (lint/type rules that reject
save/updateinside a query).
Design reads for batches, not items
A read model is also where you kill [[n-plus-one-queries-and-batching|N+1 queries]]. The shape to avoid is a per-item query inside a loop (fetch 12 things → 12 round trips). Design the query to take many keys and return a map keyed by id, so N queries collapse to one. When a view spans several domains, a coordinator calls each domain’s batched query once and assembles the result — turning (domains × rows) round trips into (one per domain). When you genuinely can’t gather all keys up front (GraphQL resolvers, serializers), a batch-loader defers and coalesces the lookups into a single query.
My read
The line that reframed it for me: a single write door is only as strong as your weakest read. I’d internalised “validate on the way in” but not “control what leaves” — and handing back a live, mutable record quietly undoes all the care on the write side. The value-object boundary connects straight to the anemic domain model worry: the point of a read model isn’t to strip behaviour off your entities, it’s to stop entities from leaking out as mutation handles. Rich models on the inside; inert values on the wire.
Related Concepts
- Command Query Separation — this is its architectural (CQRS) extension
- Value Objects — the inert shape that crosses boundaries
- Constraints over Conventions — why a single door needs constraints behind it
- Encapsulation — don’t hand out writable internals
- Vanilla Rails vs. the Sharp Parts — when this structure is worth its cost
Further reading
- Rails: The Sharp Parts — Queries, Read Models, and Batching (Brandon Weaver) — the ActiveRecord mechanics (
includes/preload/eager_load,pluck/pick, batch-loader) are in Rails: the sharp parts - Design Principles