50%

Lesson 1 of 2

Not All Coupling Is Equal

Every useful system is coupled somewhere; the question is where, how strongly, and whether the coupling crosses a boundary you cannot cheaply cross back.

12 min read

Coupling is the degree to which one part of a system must know about, or change with, another. "Reduce coupling" is not actionable advice, because a system with zero coupling does nothing — the parts have to reach each other somehow. What is actionable is naming which kind of coupling you have, since the kinds differ enormously in what they cost when something changes.

Kind of couplingWhat it looks likeCost when the other side changes
Data coupling (mildest)A caller passes exactly the values the callee needsLow: only a signature change affects you
Stamp couplingA caller passes a whole object where three fields are usedModerate: unrelated field changes ripple into your callers
Control couplingA caller passes a flag that selects the callee's behaviourModerate to high: the caller must understand the callee's internals
Temporal couplingOperations must happen in a particular order, unenforced by typesHigh: violations are runtime bugs, often intermittent
Common coupling (shared mutable state)Two modules read and write the same table or globalVery high: either side can break the other with no visible call
Content coupling (worst)One module reaches into another's internals or private dataSevere: the other side cannot refactor at all without breaking you
The classic ranking, adapted to modern systems. Everything below the middle row is worth a design conversation.

The three that cause the most damage in practice

Textbook rankings are useful, but three specific forms are responsible for most of the pain in production systems, and none of them is visible in a class diagram.

  1. 1**Shared-database coupling.** Two components read and write the same tables. Neither can change a column, add a constraint or fix a data model mistake without checking with the other, and the dependency is invisible in code — it lives in a schema, and nothing fails at compile time.
  2. 2**Temporal coupling.** B only works if A ran first: a cache must be warmed, a row must exist, a session must be initialised. The dependency is real and enforced by nothing, so it is discovered by an intermittent production failure at the worst moment.
  3. 3**Deployment coupling.** Two units must be released together, or in a fixed order. This is the defining property of the distributed monolith from Chapter 1: whatever the diagram shows, if a runbook says "deploy inventory before orders", the two are one unit with extra network calls.

The same dependency, three ways to carry it

Two components needing the same customer data. Only the third arrangement lets either side change its storage independently.

First arrangement: both components read the same customers table directly, so neither can change the schema. Second: one component calls the other synchronously and must be up whenever it runs. Third: one component owns the data and publishes changes as events, and the other keeps a local read model shaped for its own needs.

Shared table

Billing

SELECT from customers

Support tools

SELECT from customers

customers table

owned by nobody

Synchronous call

Support tools

calls billing at request time

Billing owns the data

availability now shared

Owned data plus events

Billing owns customers

publishes changes

Support read model

local, eventually consistent

Temporal coupling, and the same logic with the ordering made impossible to get wrong.typescript
// Temporally coupled: calling charge() before reserve() compiles, passes
// review, and fails intermittently in production when stock runs out.
class Checkout {
  reserve(order: Order): void { /* sets this.reservation */ }
  charge(order: Order): void {
    if (!this.reservation) throw new Error('reserve() must be called first');
    /* ... */
  }
}

// The ordering is now carried by the type system: you cannot obtain a
// Reservation without reserving, and charge() cannot be called without one.
function reserve(order: Order): Reservation { /* ... */ }
function charge(reservation: Reservation): Receipt { /* ... */ }

// The general move: turn "you must call these in order" into "the output of
// the first step is the input the second step requires".

In practice

Removing a shared table without stopping the world

An insurance platform had a `policies` table written by the quoting service and read directly by four others — billing, claims, reporting and a broker portal. A regulatory change required adding a mandatory field and reshaping how coverage periods were stored.

Constraints

  • Four unrelated teams read the table directly
  • Regulatory deadline in six months, immovable
  • Reporting queries the table with hand-written SQL
  • No downtime permitted on the broker portal

Decision

Quoting published a versioned read API and a change-event stream. The three application consumers migrated to the API over four months; reporting was given a nightly replicated copy in the warehouse rather than the live table. Only then was the schema reshaped.

Why

The schema could not change while four teams depended on its exact shape, and asking all four to migrate simultaneously would have needed a coordinated release — the very thing the platform could not do. Introducing the interface first converted one large coordinated change into five small independent ones, each of which could be rolled back on its own.

What it cost

The migration cost about four months of one team's capacity and left the consumers reading data up to a few seconds stale, which forced two of them to redesign screens that had silently relied on read-after-write. It also added an API to operate and monitor forever. The alternative — a single coordinated schema change across five systems in one release window — was judged to have an unacceptable chance of a multi-hour outage on a regulated deadline.

A ticket says "join to the users table to get the email"

As a developer

Writes the join. It is one line, it is fast, the table is right there in the same database, and adding an API call would be slower and more code for the same result.

As an architect

Asks who owns that table and whether they know a second system now depends on its shape. The join is genuinely cheaper today and creates a dependency that is invisible to its owner, has no deprecation path, and will surface the day they try to change it. If both components will always ship together, the join is fine and should be recorded as a deliberate choice; if either might be extracted later, the join is a mortgage.

Two modules in the same deployable unit call each other frequently and share several types. A reviewer flags this as "high coupling". Is it a problem?Reveal

Not necessarily, and the deciding factor is the boundary rather than the frequency. Within one deployable unit owned by one team, both sides change in the same commit, the compiler catches mismatches, and there is no release coordination — so the coupling costs almost nothing. It becomes a problem in two situations: if the two modules are owned by different teams, in which case every change becomes a negotiation, or if you intend to extract one of them later, in which case each shared type is work you have deferred. The right question is not "how much coupling is there" but "what boundary does this coupling cross, and what would it cost to cross it back".

Key takeaways

  • Coupling is not one quantity: name the kind, because data, control, temporal, common and content coupling cost very different amounts.
  • Judge coupling by its strength multiplied by the cost of crossing the boundary it spans.
  • Shared-database coupling turns a schema into an undocumented, unversioned public interface.
  • Temporal coupling is best removed by making the first step produce what the second step requires.
  • Deployment coupling — a required release order — is the defining symptom of a distributed monolith.