01 · Foundations

Coupling & Cohesion

8 min read · 5 questions · 1 exercise

Cohesion is whether a module has one reason to exist; coupling is how many other modules have to change when it does.

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?

  1. Orders and email do break, loudly, because the field they read is gone. Inventory breaks too, only silently: it never touched the rows, but it compares billing's status with PAID, and the answer is now paid. It still compiles and now matches nothing.

  2. Calling charge() is still a dependency on billing, but on a function and its return shape, and neither of those changed. The rename and the lowercasing happen in rows that nothing outside billing can see.

  3. On top, all three had memorized a fact about billing's storage: orders and email the field name, inventory the status vocabulary. Below, the change lands entirely inside billing, behind charge(), and never reaches them. The blast radius goes from three modules to none.

  4. Inventory still needs to know whether an order is paid, but below the line it reads charge.paidAt, which billing fills in from its own rows. The status strings never leave billing, so lowercasing them changes nothing inventory can see.

Show the answer

The answer is C. The reasoning for each option is now shown under it.

One change, two blast radii

one change leaving billing · rust = a module it broke · badges count breakages

INTO BILLING’S ROWSTHROUGH CHARGE()
billing
orders
email
inventory
charge()
orders
email
inventory

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?

  1. Four files each hold their own copy of one responsibility, which is low cohesion, and its tell is a diff that touches many files without doing many things. Give the rule one module and the next change is one file.

  2. None of them imports another, so there is no dependency between them to break. Each holds a separate copy of the same rule, which is a cohesion problem: one responsibility spread across four places.

  3. Smaller pull requests are easier to review, but the code underneath is unchanged. The rule still lives in four places, and the next change still has to find all four, including the one nobody remembers.

  4. How often a rule changes is up to the business. The cost here comes from where the rule lives: a frequent change to one module is cheap, while even a rare change to four files is how the fourth one gets missed.

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?

  1. Callers of quote depend on its name, its arguments and the Quote it returns, and none of those moved. The table sits behind the function, which is the reason for having the function.

  2. It does still work, for the modules that use it. The dashboard never used it: it memorized the table's shape, and that shape just changed underneath it.

  3. Depending on shipping is not one thing. The callers are on the top rung, a published function, and are untouched; the change lands only on the bottom rung, where the dashboard reached into storage.

  4. The dashboard depends on shipping's storage, the bottom rung, so a storage change reaches it. Callers of quote depend on a function and its return shape, which did not change, so the blast radius is exactly one module.

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?

  1. Inventory's call no longer resolves, so the build fails before anything ships. It is a nuisance, but it is the cheap kind of breakage: loud and immediate.

  2. Inventory still compiles and still calls statusOf, but SETTLED never equals PAID, so no order counts as paid and nothing ships. There is no compiler error and no exception, because the dependency is on a value, which the compiler cannot check when the type is string.

  3. Every call that does not pass the new argument stops compiling. It is work for inventory, but it cannot reach production by accident.

  4. It looks like the sneaky one, but TypeScript rejects comparing a Promise<string> with PAID because the types have no overlap. The build fails, which makes this breakage loud.

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?

  1. Separate deploys change when the two ship, not what they know about each other. Orders still depends on the shape of billing's rows; it just finds out later when that shape changes.

  2. A boundary helps when what crosses it is small and stable. What crosses this one is still the raw rows, so every fact orders memorized about billing's storage is still memorized.

  3. Each service still depends on the other's storage, field names and all. What changed is the feedback: a rename that used to fail the build now fails a request in production. Same coupling, worse feedback loop.

  4. The number of facts each side knows about the other did not change, so neither did the coupling. What got worse is when you find out that something broke.

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.