Two related rules for keeping a system’s writes governable as it grows:

  1. One door in for writes — every mutation of a domain concept flows through a single explicit entry point, not a scatter of call sites.
  2. 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/update inside 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.

Further reading