Kinds of Coupling
Data, control, temporal, shared-state and implementation coupling are not degrees of one thing. They differ by an order of magnitude in cost, which is why the taxonomy is worth having.
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 are connected. Which kind of connection is it, and what will it cost me when a requirement changes?
A review comment says "this is too coupled". The author asks which part, and the reviewer cannot say. Both are right about something and the conversation has nowhere to go.
Coupling is bad, so measure it and reduce it. Count the dependencies between modules, put the number in the build, and drive it down.
The count treats a passed integer and a shared mutable cache as the same edge. One costs a parameter change; the other costs a class of bugs that only appear under load and only in production (Shared-State Coupling).
- The count treats a passed integer and a shared mutable cache as the same edge. One costs a parameter change; the other costs a class of bugs that only appear under load and only in production (Shared-State Coupling).
- Driving the count down rewards indirection: introduce an event bus and every direct edge disappears while the real dependency stays, now invisible to both the tool and the reader (Hidden Global State).
- It also rewards deleting useful connections. A module calling a domain rule instead of copying it adds an edge and removes duplicated knowledge, and the metric penalises it (Duplicate Knowledge).
- The review conversation never improves, because "too coupled" without a kind is a statement about taste and the author has no way to agree or disagree.
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.
- Every module in a working system is connected to others; the target is not zero and never was.
- Review is where this gets decided, so the taxonomy has to be usable in a comment rather than requiring a diagram (A Review Checklist Worth Reading).
- Existing code is full of all five kinds, so the useful version of this ranks them for a limited refactoring budget rather than condemning them all (What Technical Debt Actually Is).
- Whatever the connection, a change on one side must be detectable on the other — by the compiler, by a test, or by a contract. Coupling you cannot see is the only kind that is unconditionally bad.
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- The caller owns the intent it expresses; the callee owns how it is carried out. Every kind of coupling below is a way that split has been violated.
- Whoever introduces a connection owns naming which kind it is — that is a one-line justification, not a document.
- The reviewer owns asking which kind, rather than asking for less.
- The boundary is what the two sides have agreed to know about each other. Data coupling agrees on a value; implementation coupling agrees on a representation; shared-state coupling agrees on nothing and hopes.
- The seam is safest where the agreement is narrow and checkable: a typed value, a contract test, a compiler error.
- The kinds that cost most are exactly the ones where the agreement is not expressible in the type system, which is why they are found at runtime (Making Illegal States Unrepresentable).
Five kinds, and what each one charges
The reason to learn a taxonomy is that it makes an unresolvable review argument into a specific one. "Too coupled" cannot be acted on; "this is control coupling and the flag will grow a third value in a month" can.
Read the last column first. The cost differences here are not a matter of degree — the gap between a passed value and a shared mutable structure is the difference between a compiler error and a week of investigation.
- The three cheap kinds are checkable by a compiler or a test. The three expensive kinds are exactly the ones that are not (Testing as Design Feedback).
- If a limited budget forces a choice, spend it on shared state first — it is the only kind whose failures are non-deterministic.
- None of these is forbidden. Implementation coupling between two files owned by the same person that always change together is fine, and saying so out loud is better than pretending otherwise (Deliberate Debt).
| Kind | What one module knows about the other | What it looks like | How a violation is found | Cost of the next change |
|---|---|---|---|---|
| Data | The value it needs, and nothing else | total(lines: CartLine[]): Money | The compiler, at build time | Lowest. A signature change, and the compiler lists every site. This is the target shape. |
| Stamp | A whole structure when it needed two fields | priceFor(order: Order) when it only reads order.country and order.items | A test breaks for an unrelated reason when Order changes | Low but sticky: the callee is now coupled to a type that changes for reasons it does not care about (Introduce Parameter Object). |
| Control | How the other one should behave internally | save(user, true); render(data, { legacy: true }) | Review, if anyone is looking; otherwise a wrong branch in production | Medium. Every new behaviour adds a flag value and a branch, and callers pass flags through without understanding them (Boolean Parameters). |
| Temporal | The order in which the other must be called | init() then configure() then run(), documented in a comment | Runtime, on the path nobody exercised — often only after a refactor moves a call | High. The knowledge lives in the caller's head, so it is lost at handover (Temporal Coupling). |
| Shared state | A mutable structure neither owns | Two modules reading and writing the same cache, context object or table | Intermittently, in production, under load | Highest. No list of writers exists, so every change starts with an investigation and the failures are non-deterministic (Shared-State Coupling). |
| Implementation | The other's internal representation or private behaviour | A test asserting call order; reaching into a field; depending on an undocumented ordering guarantee | When the other team refactors and your build goes red for no visible reason | High and asymmetric: the cost lands on whoever tries to change the internals, who did not create the coupling (Exposing Too Much). |
Control coupling, which is the one that hides in plain sight
Control coupling is worth its own example because it is the kind that most often survives review. A boolean parameter looks like data, and it is not: the caller is not supplying a value the callee needs, it is choosing which code path runs inside.
The tell is that the caller has to know what the flag does *internally* to pass it correctly, which is the definition of knowing too much. The second tell is growth: flags never stay at one, and each addition multiplies the paths through the callee.
function publish(post: Post, draft: boolean, notify: boolean, reindex: boolean) {
if (!draft) { validate(post) }
save(post, draft)
if (notify && !draft) { sendSubscriberEmails(post) }
if (reindex) { search.index(post) }
}
publish(post, false, true, true)
publish(post, true, false, false)
// eight combinations exist, three are meaningful,
// and the caller must know the body to pass them correctlyfunction saveDraft(post: Post): Draft {
return store.saveDraft(post) // no validation, no email, no index
}
function publish(post: Post): Result<Published, ValidationError> {
const valid = validate(post) // always — publishing implies valid
if (!valid.ok) return valid
const published = store.publish(valid.value)
events.emit({ kind: 'post-published', id: published.id })
return ok(published)
}
// notification and indexing subscribe to the event;
// neither is a decision the caller makesThe flags encoded rules that belong to publishing, not to the caller — "drafts are not validated", "drafts do not notify". Moving them inside makes them true everywhere, including at call sites written later, and collapses eight combinations to two operations. The trade is real and worth naming: emitting an event replaces a direct call the compiler could check with an edge no type system sees, so the coupling to indexing did not disappear, it changed kind (Observer). That is an improvement here only because indexing is genuinely optional and independently deployable — if the caller needed the search index updated before returning, the event would be a lie.
Implementation coupling, and why the bill arrives elsewhere
The last kind is peculiar because the person who creates it never pays for it. A test that asserts how a module works internally, or a caller that depends on an ordering nobody promised, costs nothing until someone else tries to change the internals — and then it costs them, in a file they have never opened.
This is also the kind most often created by testing practice rather than by production code, which is why it belongs in a coupling lesson rather than a testing one (Mocking).
1// Coupled to the implementation: this test passes only for one algorithm2it('charges the customer', () => {3 const gateway = mock<Gateway>()4 const audit = mock<Audit>()5 new Checkout(gateway, audit).pay(order)6 7 expect(audit.record).toHaveBeenCalledBefore(gateway.charge) // an ordering8 expect(gateway.charge).toHaveBeenCalledWith(order.id, 4200, 'EUR')9})10// Moving the audit write into a transaction wrapper breaks this test11// while the observable behaviour is identical.12 13// Coupled to the contract: passes for any implementation that is correct14it('charges the customer and leaves an audit trail', async () => {15 const gateway = new InMemoryGateway()16 const audit = new InMemoryAudit()17 await new Checkout(gateway, audit).pay(order)18 19 expect(gateway.chargesFor(order.id)).toEqual([money(4200, 'EUR')])20 expect(audit.entriesFor(order.id)).toHaveLength(1)21})The first test encodes a call order that nothing in the requirement demanded. Six months later a refactor that preserves every observable behaviour turns the build red, and the engineer doing the refactor has to decide whether the ordering was a rule or an accident — with no way to tell. That decision cost, paid by someone who was not in the room, is what implementation coupling actually charges (Test Doubles, Precisely).
How to build it
Most important first.
- Name the kind before you argue about it. "This is control coupling — the boolean decides which branch runs inside" is actionable; "too coupled" is not (Tone, Disagreement and Receiving Review).
- Prefer data coupling: pass the value the callee needs, nothing more, and let it decide what to do (Function Design).
- Replace control coupling with separate operations or an explicit strategy, so the caller expresses intent rather than steering the implementation (Boolean Parameters).
- Convert temporal coupling into a type: make the invalid order unconstructable rather than documented (Temporal Coupling).
- Eliminate shared-state coupling first when budget is limited — it is the most expensive kind, and it is the one whose failures are non-deterministic (Shared-State Coupling).
- Accept implementation coupling deliberately when both sides are yours and change together, and write down that you accepted it (Deliberate Debt).
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.
- Data coupling: the next change costs a signature and its callers, caught by the compiler. Minutes, and the compiler enumerates the work.
- Control coupling: the next change means a new branch inside the callee plus a new flag value at every caller, and the callers that pass the flag through without understanding it are found by a bug.
- Temporal coupling: the next change costs an ordering nobody documented; the cheap version is a runtime error in staging, the expensive version is a partially initialised object in production (Temporal Coupling).
- Shared-state coupling: the next change costs an investigation. There is no list of who writes to the shared structure, so the work begins with finding out, and the failures are intermittent (Shared-State Coupling).
- Implementation coupling: the next change costs whatever the other module's internals were, and it arrives as someone else's refactor breaking your tests for reasons neither of you can see from your own file.
- Preferring data coupling means passing more arguments, and past about five parameters the signature becomes its own problem (Introduce Parameter Object).
- Removing control coupling multiplies the number of operations, and a module with twenty near-identical methods has traded one problem for another (Designing a Module Interface).
- Ranking coupling by cost is a heuristic with real exceptions: a well-tested shared in-memory index inside a single-threaded process is cheap, and a data-coupled call across a network is not (Cost-Aware Interfaces).
What can go wrong
- The taxonomy becomes a checklist and reviewers reject data coupling because it is on the list, which is the cheapest kind and usually correct.
- A shared-state dependency is refactored into an event bus, so it now looks like no coupling at all while the two modules still depend on each other's state transitions — with the dependency now unsearchable (Observer).
- Control coupling is "fixed" by replacing a boolean with an enum of eleven cases, which is the same steering with a longer signature (Long Parameter List).
- The mitigation fails on its own: converting every temporal dependency into types can produce a builder API with six phantom type parameters that nobody on the team can extend (The Complexity Budget).
- The kinds form a rough hierarchy of how much one module must know about the other, from a single value up to its internal representation and its execution order.
- Direction matters independently of kind: data coupling pointing from stable policy to a volatile detail is still a problem (Dependency Direction).
- Each kind fails differently under a network boundary, which is why moving a module out of process changes the cost of connections that were fine in memory (What Changes at the Network Boundary).
- "Minimise coupling." Coupling is how a system works. The goal is to choose the cheapest kind for each connection and to keep the expensive kinds visible and few (Cohesion).
- "Events decouple things." Events change *who knows whom at compile time*. If the consumer breaks when the producer changes its event shape, that is the same dependency with worse tooling and no compiler (Observer).
- "Dependency injection removes coupling." It moves the choice of implementation to a wiring point. The caller still depends on the interface, which is exactly what you want and is still coupling (Dependency Injection).
- "This is just SOLID." The taxonomy predates it and is more useful in review, because it names *what kind of connection* rather than prescribing a structure (SOLID, Read Honestly).
- long-parameter-list
- feature-envy
- shotgun-surgery
Testing it, and how it ages
- Data coupling needs no special test; the type checker is the test.
- Control coupling needs a test per flag combination, and the number of tests is the argument against it (Boolean Parameters).
- Temporal coupling needs a test that the wrong order fails loudly — and if you can write that test, you can usually make the wrong order unrepresentable instead (State Machines).
- Shared-state coupling needs concurrency tests, which are the least reliable tests you will write. That unreliability is itself the argument (The Thread-Safety Contract).
- Implementation coupling needs a contract test at the seam so the other side's refactor breaks with a useful message rather than a confusing one (Contract Tests).
- Coupling migrates downward in the table over time: a shared object introduced for convenience becomes a shared cache, then a shared lifecycle, then an ordering requirement — each step reasonable, the sum unmaintainable.
- The kinds also change when code moves out of process. In-memory shared state becomes impossible and is replaced by a database row that four services write to, which is the same problem with worse tooling (Where the Boundary Goes).
- The taxonomy itself ages well because it is about what one module must know about another, which does not depend on language or era.
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.
- GENERALThe ordering by cost follows from how much one module must know about another and how detectable a violation is, so it holds across languages; what changes is which kinds the compiler can catch for you.
- LANGUAGE-SPECIFICIn a language with sum types and ownership, temporal and shared-state coupling can be made unrepresentable at compile time, so the ranking flattens — those kinds stop being expensive because they stop compiling. In Python or JavaScript the same connections are checkable only at runtime, which is where most of their cost comes from.
- SIMPLIFIEDFive kinds is a working subset. The classical structured-design literature enumerates more — content, common, external, stamp — and the boundaries between them are debatable; these five are chosen because they differ in what a change actually costs and because a reviewer can identify them from a diff without a taxonomy chart.
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — Testing & Reliability Engineering — three of the five kinds are found only at runtime, which is why the taxonomy is also a prediction about which failures your test suite will and will not catch.