الدرس 1 من 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.
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 coupling | What it looks like | Cost when the other side changes |
|---|---|---|
| Data coupling (mildest) | A caller passes exactly the values the callee needs | Low: only a signature change affects you |
| Stamp coupling | A caller passes a whole object where three fields are used | Moderate: unrelated field changes ripple into your callers |
| Control coupling | A caller passes a flag that selects the callee's behaviour | Moderate to high: the caller must understand the callee's internals |
| Temporal coupling | Operations must happen in a particular order, unenforced by types | High: violations are runtime bugs, often intermittent |
| Common coupling (shared mutable state) | Two modules read and write the same table or global | Very high: either side can break the other with no visible call |
| Content coupling (worst) | One module reaches into another's internals or private data | Severe: the other side cannot refactor at all without breaking you |
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**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**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**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
// 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?RevealHide
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.