Software Design Reference Core Designer · Seeing Structural Problems

Lesson 0001 · Core Designer · Module 1

Seeing Coupling & Cohesion

Before you can fix structure, you have to see it. This lesson trains your eye to measure the two forces every structure is made of.

Reading ≈ 11 minPractice ≈ 20 min

Mission tie-in: code that "works" but isn't maintainable is usually failing on exactly these two forces. Every later lesson — patterns, ports, domain protection — is a tool for steering them.

Knowledge: the two forces

Coupling measures how much one module depends on another. Low coupling means a module can change, be tested, or be replaced without its neighbors noticing. Cohesion measures how strongly the elements inside a module belong together — whether it has one purpose.

Look at this function. It works. It also does three different jobs:

def process_order(order_id: str, db: Database) -> None:
    order = db.fetch_order(order_id)
    total = 0.0
    for line in order["lines"]:
        total += line["price"] * line["qty"]
    order["total"] = round(total, 2)
    db.save_order(order)
    EmailClient().send_receipt(order_id, order["total"])
    logger.info("Order %s processed", order_id)

Ask two questions. Cohesion: how many jobs is this one function doing? It computes a business total, persists it, sends email, and logs — four. Coupling: what does the order flow depend on? It is now fused to the email client and the logging framework; change either and order processing breaks.

Notice what you didn't need: a diagram, a framework, or a tool. You saw the problem by asking how many reasons does this have to change, and who is it joined to. That is the skill of this module: not applying rules, but seeing.

† A function named with "and" ("process and notify", "validate and send") is a confession of low cohesion written into the name.
The principle behind the principle

Coupling is not expensive because a book says so. It is expensive because of an arithmetic you can run against your own repository. The cost a structural defect charges you over a year is roughly change propagation × change frequency × cost of understanding: how many modules one edit forces you to touch, how often that edit happens, and how long it takes to load each touched module into your head well enough to change it safely.

Put numbers on it. A checkout file on a six-engineer team gets edited around forty times a year. If each edit stays inside one module and costs twenty minutes of reading, that is roughly thirteen hours a year. Now fuse the same file to the mail client, the logging framework, and the reporting schema: the average edit touches four modules, so eighty minutes of reading — and three modules that had no reason to change are now inside the blast radius of a regression. Same feature work, four times the reading, and a test suite that has to cover neighbours you never intended to disturb.

Cohesion is the other half of the same arithmetic; it sets the denominator. It decides how much of what you read is relevant to the change you came to make. A module with one job means every line you read is on topic. A module with four jobs means three quarters of your reading is tax you pay to reach the quarter you needed.

Where it stops applying. Multiply by a change frequency of zero and the product is zero: a tightly coupled import script written for one migration and deleted afterwards costs nothing, because nothing propagates through code nobody edits. The same holds below a size threshold — in a two-hundred-line script the cost of understanding is small enough that structure buys less than it costs. And coupling between two things that genuinely always change together is not a defect at all. Separating those only means every change now needs two commits, two reviews, and a release order.

How a senior designer reads that function

Watch the order of operations, because it is not "read the code and dislike it".

They look at the history before the code. git log --oneline -- orders.py over six months returns eighteen commits: "add VAT for IE", "new receipt template", "switch to structured logging", "retry failed sends", "fix rounding on discounts". Five different parts of the world edit one file. That list is the cohesion measurement; the function body only confirms what the history already said.

Then they ask one question: if the mail vendor changes next month, what has to be re-tested? The honest answer is order pricing — because the send is inline, the pricing path and the send path share a failure surface. That sentence is what the coupling costs, stated in a form a product manager can act on.

Then they reject the obvious refactor. The tempting move is to lift the last two lines into a notification class that takes the order dictionary:

class NotificationService:
    def send_receipt(self, order: dict) -> None:
        EmailClient().send_receipt(order["id"], order["total"])
        logger.info("Order %s processed", order["id"])

This looks like progress and is not. process_order still builds the dependency at call time, still fails when SMTP fails, and the new class now depends on the order dictionary's key names as well — a second module that breaks when total is renamed. The edit moved code across a file boundary without turning a single arrow. The tell for this mistake: after the refactor, is the list of things you must re-test any shorter? Here it is not, so nothing was decoupled; something was relocated.

The move that works changes who depends on whom. Order processing computes and saves, then returns a result; something outside it decides whether a receipt is sent. The receipt becomes a consequence of a completed order rather than a step inside one, and the order flow stops importing anything about mail at all.

The consequence over a year. In the coupled version, a marketing tweak to a receipt template ships through the order-processing test suite and the order-processing deploy. That is the version where, on the busiest trading day of the year, a template that raises on a missing field takes checkout down with it — the classic incident shape, in which the blast radius is set by structure rather than by the size of the change. In the decoupled version the same edit touches one module, and a failing receipt leaves an order that is still saved and still correct.

Cohesion and coupling trade against each other across a boundary: to lower coupling between two modules you often raise the cohesion of each — you pull each job into its own module and leave each module with one job.

Field notes · what it looks like in real code
You see thisWhat it costsThe move
def process_and_save(...)The "and" in a name is two change sources admitted out loud.Split at the “and”; each half gets one job.
import smtplib in a pricing modulePricing redeploys when the mail vendor changes.Inject a Notifier port; keep the vendor at the edge.
4 mocks to test 1 assertionMock count tracks neighbour count — the unit has too many.Pass collaborators in; delete the ones the job never uses.
one file in 8 of your last 10 PRsIt is the junction where unrelated changes meet.List its jobs; give each one its own module.

When this is the wrong move

Do not split a function merely because it does more than one thing. Leave it alone when the code is a spike or a migration script whose lifetime is shorter than the next change to it; when the two jobs have always changed together and there is no plausible world in which one changes alone; or when the split would replace one readable function with three modules plus an orchestration layer you now have to hold in your head at once. Structure is bought with reading cost, and a boundary nothing ever crosses is a boundary you paid for and never used. The question to answer honestly is not "does this have one job?" but "will anything ever want to change one of these jobs without the other?"

What people get wrong after reading this
Less coupling is always better, so aim for zero.
Zero coupling means no system — modules that depend on nothing also do nothing together. The goal is few, explicit, and stable dependencies. Depending on Decimal is coupling too, and it is free, because Decimal does not change. What costs you is depending on something volatile.
Moving code into a new file reduces coupling.
Coupling lives in what one module must know about another, not in where the text sits. If the extracted class still reads the caller's dictionary keys, you have added an import and kept every dependency. The measurement is whether the set of modules a change forces you to touch got smaller.
Cohesive means small.
Cohesion is measured in reasons to change, not in lines. A three-hundred-line parser with one job is more cohesive than three twenty-line classes that must be edited together in every commit — and the second arrangement is strictly worse to work in.
A metrics tool can tell me where the problem is.
Import-graph metrics find candidates. They cannot see change frequency or volatility, which is where the cost actually comes from. A file with thirty imports that nobody has edited in two years is not your problem; the file with four imports that appears in every second pull request is.

Skill: point at the problem

Read each snippet below and name the force that is failing. Answer before checking — the act of deciding is the practice.

The function above computes totals, sends email, and logs. The primary problem is:

Which change best raises cohesion without breaking the order flow?

Two modules are tightly coupled when:

Practice on your own code

Take the messiest function in a file you own. Write down (in your head or a note): its jobs, and every neighbor it touches. If the list has more than one job or more than two neighbors, you have found this lesson's win. Then run the history check — git log --oneline -- <that file> — and see whether the commit subjects name more than one part of the world.

In the lab: the companion returns-resolution service carries this lesson's problem at working scale. Its phase-1 checkpoint asks you to find the coupling hot-spots in the resolution flow — the places where one edit forces several — and cut exactly one, then show that the list of modules a notification change touches actually got shorter.

Reveal: the refactored version of the example
class Order:
    def __init__(self, order_id: str, lines: list[Line]) -> None:
        self.order_id = order_id
        self.lines = lines

    def total(self, pricing: Pricing) -> Money:
        return pricing.total(self.lines)   # domain knows money, not email


# one job: order flow
def process_order(order_id: str, orders: OrderRepository, pricing: Pricing) -> Money:
    order = orders.get(order_id)
    return order.total(pricing)            # persistence, email, logging live elsewhere

Order flow now depends on a repository and a pricing strategy — not on email or logging. Those became separate modules with one job each.

Teach it back
  1. Explain coupling and cohesion to a junior developer in two sentences, without using either word.
  2. Find one function in your own code this week that fails on one of the two forces, and name its cost in a unit you could put in a ticket: modules touched per edit, edits per month, or minutes of reading per edit.
  3. Predict what breaks in a year if nobody fixes it — specifically, which unrelated change will be the one that takes something down.

Your win

You can now look at any function and name which force is failing: too many jobs inside (cohesion) or too many neighbors outside (coupling). That single judgment is the basis of every other lesson in this course.

Read and watch deeper

Anything unclear? Ask your agent-teacher — re-explain any part of this lesson, or bring your own code and have it pointed at together.