Coupling & Cohesion
The ticket is one line: billing should tidy up how it stores charges — rename
ledgerRows to entries, and write statuses in lowercase like every other
module already does. One module, one afternoon. Three days later you are still
going, because orders decides whether an order is paid by reading billing’s
rows, the email service builds receipts out of those same rows, and inventory
asks billing for a status and compares the answer against the string "PAID".
None of them contains a bug. Together they mean that billing cannot change how
billing stores its own data without a negotiation with three other teams.
Two properties decide how a week like that goes. Cohesion is about what is inside a module: does everything in it exist for the same reason? Coupling is about what crosses the line between modules: how much does one have to know about another’s insides to do its job? Keep a wall socket in mind. A kettle knows two holes and a voltage, which is why an electrician can rewire an entire house behind that faceplate without asking the kettle’s permission — and it only works because nobody spliced an appliance straight into the wall.
One reason to exist
A module is cohesive when you can say what it is for in a sentence that does
not need the word “and”. Billing charges orders, records what was charged, and
answers questions about those charges: several verbs, one subject. A file named
utils is the other thing — a date formatter, a retry helper, and a currency
parser sharing an address because none of them had anywhere else to go.
Low cohesion costs you in a specific way. When one responsibility is spread across four modules, every change to it is a change in four places, and the fourth is the one nobody remembers. That pattern has a name, shotgun surgery, and its tell is a pull request that touches many files without doing many things.
Cohesion is not the same as size. A two-hundred-line module that does one job
is cohesive; a thirty-line file holding three unrelated helpers is not. The
quick test is to read a module’s name and try to predict its exports. Told only
the name billing, most people guess something like charge, refund, and a
way to ask whether an order is paid; told only utils, nobody guesses
parseCurrency. When you guess right, the boundary matches a real idea. When
the list surprises you, the module is a drawer.
How far a change travels
Coupling gets discussed as though the goal were to have less of it, but a module coupled to nothing is a module nobody calls. What varies is not the amount, it is what you depend on, and there is a ladder.
Calling a published function is the top rung: you depend on a name, its arguments, and what it promises to return. Depending on the shape of the data that function hands back is the next one down. Reading another module’s storage directly — its rows, its field names, its status vocabulary — is the bottom rung, and it is where the opening ticket got stuck. Everything on that ladder is a fact about billing that some other module has memorized, and every memorized fact is a thing that can quietly become untrue.
The measure that matters is blast radius: when this module changes, how many
others change with it, and how do you find out? The scene below runs the same
change twice — billing rewrites how it stores charges — first against the three
modules from the opening, then against the same three after billing’s rows went
private behind charge().
Predict first
Billing renames ledgerRows to entries and switches its statuses to lowercase. In the top design, orders and email read billing's rows directly, and inventory compares a status it gets from billing with PAID. In the bottom design the rows are private: all three call charge() and read the Charge it returns. How many of the three modules break in each design?
Show the answer
The answer is C. The reasoning for each option is now shown under it.
one change leaving billing · rust = a module it broke · badges count breakages
One change in billing, and how far it travels in two designs — use Next step to go one at a time.
The badge on each module counts breakages as they arrive, so the numbers only
climb; they tally how often that module has been hit by this change rather than
how broken it is right now. The rewrite has two halves and they fail
differently. Renaming ledgerRows breaks orders and email at compile time,
which is the good kind of breakage — loud, immediate, impossible to ship.
Lowercasing the statuses breaks inventory, which never touched the rows and
only ever compared billing’s answer to "PAID": nothing to recompile, no
error, and a module that now believes nothing has been paid for. Orders
compares that string too, so the lowercasing breaks it a second time, but the
scene counts orders once: that second break hides behind the compile error,
which is luck rather than design.
Depending on another module’s data does not only create work, it creates work
you can miss.
In the second pass the tokens run the other way. The three modules call
charge(), read the Charge they get back, and none of their badges moves,
because the change never reached them. That is not the API being clever. It is
the change landing entirely inside the one module that owns the subject, which
is what cohesion buys.
Both sides of that fit in one block:
interface LedgerRow {
orderRef: string;
status: string;
}
// Before: orders reads billing's rows and re-derives billing's own rule.
function isPaid(orderId: string, billing: { ledgerRows: LedgerRow[] }): boolean {
return billing.ledgerRows.some(
(row) => row.orderRef === orderId && row.status === 'PAID',
);
}
interface Charge {
amountCents: number;
paidAt: string | null;
}
// After: billing owns the rule, and orders knows one function and one shape.
interface Billing {
charge(orderId: string, amountCents: number): Promise<Charge>;
}
async function confirmOrder(
orderId: string,
amountCents: number,
billing: Billing,
): Promise<boolean> {
const charge = await billing.charge(orderId, amountCents);
return charge.paidAt !== null;
}
The second version is not shorter, and that is the point: what shrank is the
number of facts orders has memorized about billing. Before, it knew a field
name, a row shape, a status vocabulary, and the rule for reading them — four
things, any of which billing might change on a Tuesday. After, it knows a
function and one field on the value that function returns. Rename the field and
every call to isPaid stops compiling, which is the outcome you want. Lowercase
the statuses without touching the field name and it returns false for every
paid order in silence — one function, coupled to billing on two rungs at once,
while confirmOrder cannot notice either change because it never knew a row or
a status existed.
Try it
Make the silent break loud
isPaid is coupled to billing on two rungs, and only one of them fails loudly.
Keep the design as it is, with orders still reading billing’s rows, but change
the types so that when billing switches its statuses to lowercase, isPaid
stops compiling instead of returning false for every paid order.
Hint
row.status === 'PAID' compiles against any string, so the compiler has
nothing to check. Give the status a type that can only hold the values billing
actually writes, and ask which module should own that type.
Solution
// Declared and exported by billing: the statuses it actually writes.
type LedgerStatus = 'PAID' | 'PENDING' | 'FAILED';
interface LedgerRow {
orderRef: string;
status: LedgerStatus;
}isPaid itself does not change. Its comparison with 'PAID' is now checked
against LedgerStatus, and it stays legal only while 'PAID' is one of the
values in that type. Billing owns the type, so the day it lowercases its
statuses it edits this one line, and the break shows up in orders at compile
time instead of in production.
To see it work, lowercase the statuses where billing declares them and run
tsc:
type LedgerStatus = 'paid' | 'pending' | 'failed';
// error TS2367: This comparison appears to be unintentional because
// the types 'LedgerStatus' and '"PAID"' have no overlap.Notice what this did not do. Orders still knows the field name, the row shape,
the status vocabulary and the rule, so the coupling is exactly as tight as
before. The failure moved from silent to loud, which is worth having while you
work toward the charge() design that removes the coupling instead.
Coupling on purpose
The honest version of the advice is not “decouple everything”. Every interface is indirection somebody has to read through, and a boundary drawn in the wrong place is worse than no boundary at all, because it hardens a split that the domain will keep contradicting. Teams that abstract too early end up maintaining a plugin system for the one plugin they will ever have.
What good design actually does is choose where the coupling goes: few places,
named, and stable enough to be worth memorizing. Put the boundaries where
change arrives — payment providers change, tax rules change, the shape of the
orders table does not change on its own — and leave the rest direct until you
have seen it move twice.
The two forces are also the same line seen from two sides. Move a responsibility to the module that owns the data it needs, and cohesion goes up there while coupling goes down everywhere else; leave it where it does not belong, and you have paid twice. That symmetry holds at every scale — functions in a file, modules in a service, services in a system. Splitting a codebase into microservices does not remove coupling between two modules that reached into each other; it converts a compile error into a version negotiation over the network, which is the same problem with a worse feedback loop.
Check yourself
Pick an answer to lock it in. You then get the reasoning behind every option, including the ones you didn't pick. Any you miss come back on the review page a day later.
A pull request changes how a loyalty discount is calculated. It edits cart.ts, checkout.ts, invoice.ts and the admin sales report, a few lines in each, and none of those files imports another. What does the diff tell you?
Show the answer
The answer is A. The reasoning for each option is now shown under it.
Shipping exposes quote(order), which returns a Quote. A warehouse dashboard skips it and reads the shipping_rates table directly. Shipping now splits that table in two and changes nothing about quote or Quote. What has to change outside shipping?
Show the answer
The answer is D. The reasoning for each option is now shown under it.
Inventory decides whether to ship by checking statusOf(order) === 'PAID', where statusOf belongs to billing and returns a string. Which billing change reaches production without anyone noticing?
Show the answer
The answer is B. The reasoning for each option is now shown under it.
Orders and billing live in one codebase and read each other's tables. The team splits them into two services, and each now fetches the other's rows as JSON over HTTP. What happened to the coupling between them?
Show the answer
The answer is C. The reasoning for each option is now shown under it.
Key takeaways
Cohesion is whether a module has one reason to exist; you can test it by predicting a module’s exports from its name.
What matters is not how much you depend on another module but what you depend on — a published function, its return shape, or its storage.
Breakage that the compiler catches is the cheap kind; coupling to values like status strings fails silently and ships.
Zero coupling is unreachable, so the goal is coupling on purpose: few boundaries, placed where change actually arrives.