StateGENERALSCALE-SPECIFICCONTESTED

State Ownership

Which module is allowed to mutate this piece of domain state — and what it means that the answer is currently "any of them".

The requirement, the obvious build, and why it breaks

Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.

The question

Six modules can write this field. Which one is responsible for it being correct, and how would anyone tell?

The requirement

An order's fulfilment state is written by the checkout service, the warehouse integration, an admin tool, a nightly reconciliation job, a partner webhook and a support macro. When it is wrong, there is no owner to ask.

The obvious build

The state lives in the database and anyone with the connection can update it. Ownership is a social convention, and the team knows who looks after orders.

Why it breaks

Six writers means six mental models of what the state means, and they only have to disagree once to produce data nobody can explain (Shared-State Coupling).

How it breaks as requirements change
  • Six writers means six mental models of what the state means, and they only have to disagree once to produce data nobody can explain (Shared-State Coupling).
  • Debugging becomes archaeology: "how did this order become shipped?" requires reading six codebases, two of which you do not own, and a job that runs at 2am.
  • The convention is invisible to new joiners and to the other team, so it decays continuously and silently. Nothing enforces it and nothing reports its violation.
  • A rule added to one writer — "never ship without a capture" — protects one sixth of the paths, and the other five continue as before (Invariant Leaks).
  • The reconciliation job becomes load-bearing. It exists to repair the damage the shared write access causes, so removing the shared access now requires unpicking a dependency the business relies on.
RequirementConstraintsInvariantsResponsibilitiesBoundariesInterfacesStateDependenciesFailureImplementationTestsFeedbackEvolution

What limits the solution, and what must never stop being true

This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.

Constraints
  • The warehouse integration is maintained by another team on a separate release cadence.
  • The reconciliation job exists precisely because the state goes wrong, so removing its write access requires fixing the underlying cause first.
  • Two of the six writers are outside the main codebase and cannot be refactored, only bounded.
Invariants
  • Every piece of mutable domain state has exactly one module that may write it.
  • A write from anywhere else is either impossible or an explicit, audited exception.
  • The owner can state, without checking, what the state means and what makes it change.

Who owns what, and where the seams fall

Responsibilities decide boundaries; boundaries decide what an interface has to say.

Responsibilities
  • One module owns the state: it defines the transitions, enforces the guards, and is the only writer (State Machines).
  • Every other participant owns *reporting an event* — "the carrier accepted this parcel" — and owns nothing about what that implies for the state.
  • The owner owns interpreting events into transitions, which is where the business rules go and where they can be reviewed in one place.
  • The team owning the external integration owns their side of a contract, not a column in your database (Internal Module Contracts).
Boundaries
  • The boundary is the write interface: the owning module exposes operations, not a table. Everyone else sends events and receives results (Designing a Module Interface).
  • The database is not a boundary, it is a shared medium. Anything with the connection string can bypass every rule, which is why ownership has to be structural and, for the highest-cost rules, also enforced by constraints (Database Constraints).
  • Reads are deliberately not restricted. Anyone may read the state; only one module may change it. That asymmetry is what makes the design affordable.

Six writers, no owner

The responsibility table below describes the current state of the order lifecycle field. It is not a description of a module — it is a description of a field, which is already the finding, because a field with six authors has no unit that can answer for it.

Six changesWhen entries for one piece of state is the diagnosis. The point is not that six modules are involved in fulfilment; it is that six modules each decided independently what the state should become.

responsibilitiesorders.state (the field, not a module — that is the problem)Who writes `orders.state` today
Knows
  • Where an order is in its fulfilment lifecycle
  • Implicitly, six different interpretations of what each value means
Does
  • Gates which operations the UI offers
  • Drives the warehouse pick queue
  • Determines refund eligibility
  • Feeds the partner's SLA reporting
Depends on
  • Checkout service
  • Warehouse integration (other team)
  • Admin tool
  • Nightly reconciliation job
  • Partner webhook handler
  • Support macro runner
Changes when — 6 distinct reasons
  • Checkout captures a payment
  • The warehouse system pushes a status
  • An admin clicks a button
  • The reconciliation job decides two systems disagree
  • A partner webhook arrives out of order
  • A support macro runs a bulk correction

Six independent authors, none of whom can guarantee the field is right, and one of whom — the reconciliation job — exists solely to repair the others. The fix is not fewer participants; all six have legitimate information. It is that they should report events and one module should decide what those events mean (State Machines).

Ownership as a transition table

Making ownership real means writing down not only which transitions exist but who is permitted to trigger each one. That second column is usually absent, and its absence is exactly why every writer feels entitled to every transition.

The forbidden list here is about authority rather than about lifecycle: these are transitions that exist and are legal, triggered by a party that has no business triggering them.

Order fulfilment, annotated with who may trigger what
createdpaidpickingshippeddelivered ·cancelled ·
FromOnToGuardEffect
createdPayments reports CaptureSucceededpaidamount matches; capture id unseenreserve stock
paidWarehouse reports Acceptedpickingreservation liveprint pick list
pickingCarrier reports Acceptedshippedall active lines pickedconsume reservation; notify
shippedCarrier reports Delivereddeliveredopen returns window
createdCustomer or support cancelscancelledvoid authorization
paidCustomer or support cancelscancelledwarehouse has not acceptedrefund (keyed); release reservation
must be impossible
  • picking → shippedTriggered by the warehouse integration writing the state directly. It knows the parcel was picked, not that the carrier accepted it, so orders appear shipped with no tracking number and the customer gets a dispatch email for a parcel still on a bench.
  • paid → shippedTriggered by the partner webhook arriving out of order. Skipping picking skips the guard that every line was picked, so short-shipped orders are marked complete and the missing lines are never reconciled.
  • created → deliveredTriggered by the reconciliation job when two systems disagree. It "resolves" the disagreement by asserting the further-along state, which converts an inconsistency into a confident falsehood — and destroys the evidence that would have identified the real bug.
  • cancelled → pickingTriggered by the support macro runner doing a bulk correction against a stale list. Every mass update against a snapshot re-applies decisions made minutes ago to rows that have since moved on.
  • delivered → paidTriggered by a retried payment webhook. Any transition that goes backwards replays the effects of everything forwards of it — here, a second stock reservation for goods already delivered.

Every one of these five is a real transition performed by a real writer with good intentions and partial information. None of them is a bug in the writer; each is a consequence of the writer being allowed to decide what a fact implies, when only the owner has the context to decide that.

What single ownership actually buys

The argument for ownership is usually made on correctness, which understates it. The bigger effect is on the cost of every subsequent rule, because a rule added inside the owner applies to every participant automatically.

The cost line is the honest part, and it is organisational rather than technical: the owner becomes a queue. That is a real price, and it is the reason this is a design decision rather than an obvious improvement.

New rule: an order may not ship until the payment capture has settled, not merely authorized
The change

Finance requires that dispatch waits for settlement rather than authorization, because of a rise in chargebacks on authorized-but-unsettled captures.

Six writers, each updating the state directly
CheckoutServiceWarehouseIntegration (other team)AdminToolReconciliationJobPartnerWebhookHandlerSupportMacroRunner
testssix test suites, in three repositories, on two release cadences
6 modules · 1 test file

Three of the six are outside this team's release cycle, so the rule is live in the codebase and not live in the system for several weeks. During that window the rule is enforced for some orders and not others, which is worse for finance than not having it, because the numbers cannot be reasoned about.

Orders owns the state; everyone else reports events
Orders
testsorders_transition_test
1 module · 1 test file

One guard on one transition. Every participant inherits it on the next deploy of Orders, including the two integrations this team does not own, because they only ever reported "the warehouse accepted this" and never decided what it meant.

what it cost Orders is now a coordination point. The warehouse team can no longer add a status of their own without a change inside a module they do not own and a review from a team with its own priorities, so a change that used to take them a day now takes a sprint and a conversation. That queue is a genuine loss of autonomy, and it is what single ownership costs — the reason to accept it is that the alternative is six teams each able to make the state wrong, which is a different and worse kind of expensive.

How to build it

Most important first.

  • Name the owner explicitly, in code and in the repository's ownership file, so the question "who owns this?" has an answer that does not require asking a person (Code Ownership).
  • Invert the writers into event reporters: the warehouse integration calls orders.warehouseAccepted(id) rather than UPDATE orders SET state = .... Same information, and the interpretation moves inside the boundary (State Machines).
  • Make foreign writes structurally hard: separate database credentials per service, a schema the other team cannot write to, or an import lint rule inside a monolith.
  • Where a foreign writer cannot be changed, wrap it. An adapter that reads their updates and translates them into transitions turns an uncontrolled writer into a controlled one (Anti-Corruption Layer).
  • Treat the reconciliation job as a symptom with a deadline, not a feature. Record what it repairs; each category it fixes is a missing transition or a missing guard (Debuggability by Design).
  • Do this for state with invariants. Shared write access to a cache, a counter or a denormalized view may be entirely fine, and locking those down is cost with no benefit (Over-Design and Under-Design).

What the next change costs

The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.

Cost of the next change
  • Before: adding a rule about state transitions costs six edits across three repositories and two teams' release cycles, and the rule is only as strong as its weakest writer.
  • After: one edit inside the owner. The other five participants report events and inherit the rule without changing.
  • The next change that becomes dramatically cheaper: answering "why is this order in this state?". With one writer, the audit trail is complete by construction; with six, it is a correlation exercise across systems (Stable Identifiers).
  • The cost that remains: every new kind of event needs an operation on the owner's interface, so the owner becomes a coordination point between teams. That queue is real, and it is the price of the guarantee.
What the recommended approach costs
  • A single writer is a coordination point. Two teams needing changes in the same sprint now queue behind one module and one review, where before they edited separate code.
  • Turning direct writes into event reports adds a hop and an interface, which is real latency and real code for a small system that had no problem.
  • Enforcement mechanisms — separate credentials, schema permissions, lint rules — have operational cost and will occasionally block something legitimate at a bad moment.

What can go wrong

Failure modes
  • Ownership is declared and not enforced, so within a quarter there are seven writers and a document saying there is one.
  • The owner exposes a generic updateOrder(fields) operation, which is the shared write access again with a function signature around it.
  • Ownership is asserted over state that genuinely has several legitimate authors — a collaborative field, a cache — and the funnel becomes a bottleneck for no invariant (Fan-in and Fan-out).
  • The mitigation fails too: separate database credentials are introduced, an incident occurs, someone is given the owner's credentials to fix it, and the credentials stay shared afterwards (Least Privilege as a Design Decision).
Dependencies, and their direction
  • Every participant depends on the owner's interface rather than on the schema, which is a much smaller and much more stable dependency (Do We Need a Package for This?).
  • The owner depends on nothing but its own domain types, which is what lets it enforce rules without asking anyone.
  • Where the owner emits events after a transition, downstream consumers depend on those events, and that is the right direction: outward, from the owner, after the fact (Naming Events).
Misreads
  • "One owner means one team can do everything." It means one *module* is the writer. Several teams can contribute to it; what they cannot do is write the state from outside it.
  • "This is the same as UI state ownership." It is the same principle applied to different state. The frontend version is about which component holds a piece of interface state and how it flows to children; this is about which module in a system may mutate a piece of domain state that outlives every session (The Seven Kinds of State and State Ownership in Frontend Engineering are the counterpart, and the lifetimes are what differ: a UI state question is resolved by a re-render, a domain state question is resolved by a migration).
  • "Lock down every field." Ownership matters for state with invariants. Applying it to everything creates bottlenecks and teaches people to route around the rule, which costs you the cases that mattered (YAGNI, With Its Bill Attached).
  • "The database enforces it." The database enforces what you can express as a constraint against a single row. Most transition rules are not expressible that way, which is why the owning module exists (Enforcing Invariants).
Smells this explains
  • shotgun-surgery
  • god-object

Testing it, and how it ages

What to test, and at which boundary
  • An architecture test that only the owning module imports the writer or the table — this is the version of ownership that survives staff turnover (Internal Module Contracts).
  • A test per foreign integration that its adapter translates every event it can send into a defined transition or a defined refusal.
  • A production check that counts state changes by origin. If a write appears from an unexpected source, that is an ownership breach and it should page someone (Logging at Boundaries).
  • Instrument the reconciliation job to report what it repaired by category, so its output is a backlog of missing rules rather than a silent fix (Debuggability by Design).
How this design ages
  • Ownership erodes by exception. Each individual exception is justified and the accumulation is not, which is why exceptions need an expiry and a review (Revisit Triggers).
  • As a system grows, the owner tends to split — payment state and fulfilment state acquire different owners — and that split is healthy provided each piece still has exactly one.
  • The forcing function is usually organisational rather than technical: when two teams both need to change the state weekly, the single owner becomes a queue, and the honest answer is to split the state rather than to share the write access again.

Where this applies

This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.

  • GENERALThat state with several writers has no one who can guarantee it is a structural fact, independent of language and storage technology.
  • SCALE-SPECIFICAt one team with six engineers, shared write access works because everyone knows the rules and reviews each other. Past two teams it fails reliably, because the second team was not in the conversation where the rules were agreed and has no way to discover them.
  • CONTESTEDThe strongest opposing view: funnelling every write through one module creates a availability and coordination bottleneck, and mature data platforms handle multiple writers with constraints, transactions and conflict resolution rather than with an application-level owner. Teams running high-write systems point out that the "one writer" rule is often achieved by adding a queue in front of the same table, which changes the failure mode without removing it. That critique is strongest where the state has few invariants and high write volume, and weakest where the rules are conditional and cross-row.

Where the depth lives

This domain teaches the codebase-level structure and hands the rest off.

Architecturemodular-monolith
Domains that do not exist yet
  • System Design — once the writers are separate services, single ownership becomes a question of which service holds the record of truth and how the others learn about changes, which is an integration-topology decision.