Shared-State Coupling
Two modules connected through a mutable structure neither of them owns. The most expensive kind, because there is no list of who writes to it.
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.
Two modules never call each other and still break each other. What connects them, and how do I make that connection visible?
Checkout runs a pipeline of steps — validate, price, apply promotions, calculate shipping, reserve stock — and each step reads and writes a shared OrderContext object. A promotions change has started producing wrong shipping costs, and nobody can explain the mechanism.
Pass a context object through the pipeline. Every step gets the same OrderContext, reads what it needs and writes what it produces. It is flexible — adding a step needs no signature changes anywhere — and it is how most pipelines are written.
The promotions step starts writing context.discountedLines and, for convenience, also mutating context.lines in place. Shipping reads context.lines and now weighs the discounted basket. Neither module imports the other; neither team can see the connection (Local Reasoning).
- The promotions step starts writing
context.discountedLinesand, for convenience, also mutatingcontext.linesin place. Shipping readscontext.linesand now weighs the discounted basket. Neither module imports the other; neither team can see the connection (Local Reasoning). - Adding a step is only cheap because nothing declares what it needs. The cost has moved to reordering: the YAML order is now load-bearing, and changing it is a production incident with no compiler involvement (Temporal Coupling).
- The two steps that run in parallel write overlapping fields. Under load, one checkout in ten thousand gets a shipping cost computed against a half-updated basket, and it is not reproducible (Data Race Is Not Race Condition).
- The context object is now the union of everything every step ever needed — forty optional fields, most undefined at any moment, none documented as to who fills them (Boolean Flag Explosion).
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.
- The pipeline has eleven steps, six of them written by different teams, and the order is configured in a YAML file that operations can edit (Build-Time and Runtime Configuration).
- It runs under concurrency: one process handles many checkouts, and two steps run in parallel for latency reasons.
- Checkout is the highest-revenue path in the system, so the fix must be incremental and reversible at every stage (Incremental Migration).
- The shipping cost charged is computed from the final basket, not from an intermediate version of it.
- A step's output depends only on its declared inputs. If it depends on what ran before it, that dependency must be visible in its signature.
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- Each step owns a transformation: it takes what it needs and produces what it computed. It owns nothing about what other steps do.
- The pipeline owns sequencing and owns passing each step's output to the next, which is exactly the ownership the shared bag gave away.
- No module owns the context object, and that is the problem stated precisely: an object with no owner has no rules, no invariant and no one to ask.
- The seam is each step's signature. Making the inputs and outputs explicit puts the dependency where the compiler and the reader can see it (Function Design).
- The boundary between steps should be a value, not a reference to something both can change (Immutability).
- Where genuinely shared mutable state is required — a cache, a connection pool — the boundary is a module that owns it, with operations rather than field access (Encapsulation).
The bag with no owner
The shape below is so common it reads as normal. A context object flows through a pipeline; every step takes it, reads a little, writes a little, and passes it on. It is flexible, it needs no signature changes when a step is added, and it hides every dependency in the system.
The specific defect is one line: promotions mutates lines in place instead of producing a new field. Nothing in either module's source shows that shipping depends on promotions, and no dependency graph will draw the edge.
1interface OrderContext {2 lines: CartLine[]3 subtotal?: Money4 discountedLines?: CartLine[]5 shippingCost?: Money6 // ...thirty-six more optional fields, filled in by eleven steps7}8 9// promotions.ts — team A10function applyPromotions(ctx: OrderContext): void {11 const discounted = discount(ctx.lines)12 ctx.discountedLines = discounted13 ctx.lines = discounted // "convenient" — and it is the whole bug14}15 16// shipping.ts — team B, does not import promotions.ts17function calculateShipping(ctx: OrderContext): void {18 ctx.shippingCost = rateFor(totalWeight(ctx.lines)) // now the discounted basket19}20 21// pipeline.yaml — edited by operations22// steps: [validate, price, promotions, shipping, reserve]Three things make this expensive and only one of them is the mutation. There is no list of who reads lines; the execution order that makes it break is in a YAML file outside the type system; and the two modules have no import relationship, so every tool that draws dependencies shows them as unrelated. The investigation that finds this begins with a support ticket about shipping costs and ends, days later, in a promotions file (Debuggability by Design).
The graph the tooling draws, and the graph that exists
The left half of this diagram is what a dependency analyser reports: eleven steps, one shared type, no edges between steps. The right half is the graph that actually governs change — every step that writes a field is connected to every step that reads it, through an ordering defined in configuration.
This is why shared-state coupling deserves its own name rather than being a severity of ordinary coupling. It is the one kind that is invisible to the tools people use to look for coupling (Afferent and Efferent Coupling).
- No step imports another, so the coupling is invisible to static analysis, to review and to a new engineer reading either file (Fan-in and Fan-out).
- The ordering that makes it correct lives in a config file that a non-engineer can edit, which means the invariant is protected by nothing (Enforcing Invariants).
- Two of these steps run in parallel, which turns a deterministic wrong answer into an intermittent one — strictly worse, because now it is unreproducible (Data Race Is Not Race Condition).
What making it explicit costs, honestly
The fix is to make each step declare what it consumes and what it produces, and to pass values rather than a shared reference. The compiler then knows the dependency graph, and the ordering becomes a type error rather than a config change.
The change below is priced for the promotions rule that started this. It is genuinely much cheaper afterwards — and the last line of the cost field is the one to read twice, because the bag was not chosen out of ignorance. It was chosen because it makes a certain kind of change free, and that kind of change now costs the most.
A promotion can now waive shipping, which means promotions and shipping genuinely need to interact — the connection that was accidental becomes a requirement.
The change itself is small; establishing what else reads the fields it touches is not. The step order has to be adjusted in YAML, which no test covers, and the parallel pair has to be re-examined by hand. The reservation step is affected and nothing in the diff suggests it.
The new dependency is declared in shipping's input type, so it is visible in review and enforced by the compiler. Placing shipping before promotions no longer type-checks. Every other step is provably unaffected because it does not consume the changed type.
How to build it
Most important first.
- Make each step a function from a declared input type to a declared output type. The signature becomes the dependency declaration, and a step that needs the priced basket says so in its parameters.
- Make the state passed between steps immutable, so "who wrote this field" has exactly one answer per version (Immutability).
- Build the next context from the previous one rather than mutating it, so every intermediate version still exists and a debugger can show which step produced which value (Debuggability by Design).
- Where a step genuinely needs something optional, model it as a distinct type rather than an undefined field —
PricedOrderversusOrder— so a step that requires pricing cannot be scheduled before it (Making Illegal States Unrepresentable). - If real shared mutable state is unavoidable, give it an owner with a narrow interface and make every access go through it, so at least there is a list of writers (State Ownership).
- Do this to the steps whose interactions have actually caused incidents first. Converting eleven steps at once is a rewrite of the revenue path (The Legacy Change Loop).
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.
- Before: adding a promotions rule costs an unbounded investigation. There is no list of readers of
context.lines, so the work starts by grepping for a field name and reasoning about eleven steps in an operator-editable order. The intermittent failures cost more than the change. - After: the same change costs the promotions step and its output type. Any step that consumed the affected field is a compiler error, so the affected set is enumerated rather than guessed.
- Reordering steps goes from a production risk to a compile error, because a step that requires a
PricedOrdercannot be placed before pricing. - What did not get cheaper: adding a field that genuinely every step needs — a tenant id, a correlation id — now touches every type in the chain. Under the bag it was one line. That is the real cost of explicitness and it is paid on exactly the changes that are legitimately cross-cutting (Change Amplification).
- Explicit input and output types are more code and more names for the same behaviour, and for a three-step pipeline owned by one person that is pure overhead (When Design Does Not Pay).
- Immutable intermediates allocate. For a checkout at human scale this is irrelevant; inside a hot loop over millions of rows it is not, and the honest answer there is an owned mutable buffer rather than a shared one (Allocation and Copies).
- Rigid types make the pipeline harder to reconfigure at runtime, which was a genuine feature for operations. Some of that flexibility is being taken away deliberately, and the people who used it should be told why.
What can go wrong
- The context is made immutable and steps start returning copies with one field changed, which is correct — but a step keeps a reference to an earlier version and reads a stale basket. Immutability moved the bug rather than removing it (State Ownership).
- Explicit types are introduced and one step takes
any"for now" to avoid a large refactor, which restores the original problem in the one step that had the most fields. - The shared object is replaced by a shared event bus and the coupling survives with worse tooling: steps now depend on each other's events and the ordering is still implicit (Observer).
- The mitigation fails on its own terms: eleven explicit types is real ceremony, and a team that finds the pipeline tedious will add a
metadata: Record<string, unknown>field to make it flexible again — which is the context bag, readmitted through the back door.
- Before: eleven modules depend on one mutable structure and, through it, on each other in an order nobody declared. The dependency graph the tooling draws is wrong — it shows a star, and the real graph is a mesh.
- After: each step depends on the type it consumes. The graph is a chain, and it is the graph the tooling shows.
- The pipeline depends on all steps; no step depends on the pipeline. That direction is what allows a step to be tested and reused alone (Dependency Direction).
- "So never share state." A cache, a connection pool and a database are all shared mutable state and are all fine — because each has an owner and a narrow interface. The problem is state shared *without* an owner (State Ownership).
- "Make it immutable and it is fixed." Immutability removes the write race and leaves the ordering dependency: a step still needs a version of the basket produced by another step, and if that is not in the signature it is still invisible (Immutability).
- "This is a concurrency bug." Concurrency made it non-deterministic; the design defect is present single-threaded, where it shows up as a step silently depending on what ran before it (Shared Mutable State).
- "Use a global store with actions and reducers." That gives the mutations names and a log, which genuinely helps debugging, and leaves the coupling exactly where it was: any step can still dispatch anything and any step can still read everything (Hidden Global State).
- god-object
- shotgun-surgery
- primitive-obsession
Testing it, and how it ages
- Test each step in isolation with a constructed input. If a step needs a context assembled by five predecessors to be testable, that setup cost is the coupling made visible (Testing as Design Feedback).
- Test the pipeline's composition separately from the steps' logic, so a reordering bug and a pricing bug fail different tests (What a Unit Is).
- For the parallel steps, a concurrency test that runs them repeatedly against shared input — accepting that it is the least reliable test in the suite, which is itself the argument for removing the sharing (What "Thread-Safe" Actually Means).
- Assert immutability directly: mutate what a step returned and assert the pipeline's view is unchanged.
- Context bags always grow, because adding a field is free and removing one requires knowing every reader. Field count over time is the cleanest signal that this is happening (Speculative Generality).
- The failure arrives when the pipeline first runs steps in parallel, or when the first team that did not write the original steps adds one. Both are usually two to three years in.
- The explicit-type version ages differently: it gets verbose, and the pressure is to introduce a generic envelope. Resisting that is the ongoing maintenance cost (Introduce Parameter Object).
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.
- GENERALA mutable structure with no owner has no enforceable rule about it in any language or paradigm; what varies is whether the runtime turns the defect into a data race as well, which changes how it is discovered rather than whether it exists.
- LANGUAGE-SPECIFICRust rejects the shared-mutable case outright at compile time, so this class of coupling is largely structural there. In Java, C# or TypeScript it compiles silently and is found under load. In a single-threaded runtime like Node the race disappears but the ordering dependency remains, which is why teams there often misdiagnose it as solved.
- CONTESTEDThe strongest opposing view is that context objects are a legitimate and widely successful pattern — middleware chains in every major web framework work this way — and that the discipline of "only add fields, never mutate what you did not add" is cheap and sufficient, while explicit per-step types produce an unusable amount of ceremony in an eleven-step pipeline. That is fair for a chain where each participant genuinely appends rather than edits, and it depends entirely on a convention no compiler enforces; the failure in this lesson is precisely a step that mutated in place, which the convention forbids and nothing prevented.
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — Testing & Reliability Engineering — the fact that the only test for this defect is a flaky concurrency test is itself the strongest argument for designing the sharing away rather than covering it.