Coupling and Cohesion
The two dials behind every design judgement — how much modules depend on each other, and how well each one sticks to one job.
Big idea
Good design is low coupling, high cohesion: each module does one focused job (cohesion), and knows as little as possible about the others (coupling). Nearly every principle and pattern you'll meet is a technique for turning one of these two dials.
The problem it solves
You change the format of a date in the invoice PDF and the nightly email report breaks, a test for user sign-up fails, and an export job starts crashing. That's high coupling: a change in one place ripples into places that shouldn't care.
Meanwhile utils.py holds date formatting, password hashing, a CSV parser, and the retry logic for the payment API. That's low cohesion: unrelated things living together, so every change to the file risks all of them and nobody knows where anything belongs.
Think of it like
A kitchen. A good one has stations — the grill cook grills, the pastry cook bakes (high cohesion) — and they talk through a pass where finished plates are handed over (low coupling). A bad one has every cook reaching into every other cook's mise en place: move the salt and three dishes fail.
Coupling, from loose to tight
- Message — modules talk only by calling well-defined methods with simple arguments.
- Data — they pass the data needed and nothing more.
- Stamp — they pass a whole record when one field would do (now they depend on its shape).
- Control — one passes a flag that tells the other how to work (
render(data, mode=3)). - Common/global — they share a global variable.
- Content — one reaches into the other's private internals.
Cohesion, from weak to strong
- Coincidental — things together by accident (
utils.py). - Logical — grouped by category, not purpose ("all the validators").
- Temporal — things that happen at the same time ("startup stuff").
- Sequential / communicational — steps that share data or feed each other.
- Functional — everything in the module serves a single, nameable job. The goal.
The first OrderService knows SQL and SMTP details; the second knows two small roles and nothing about how they're done.
In code
class PriceCalculator: # one job: money
def __init__(self, tax_rate):
self.tax_rate = tax_rate
def total(self, lines):
subtotal = sum(qty * price for qty, price in lines)
return round(subtotal * (1 + self.tax_rate), 2)
class ReceiptFormatter: # one job: text layout
def format(self, lines, total):
rows = [f"{qty} x {price:.2f}" for qty, price in lines]
return "\n".join(rows + [f"TOTAL {total:.2f}"])
class Checkout: # coordinates, knows only the two methods above
def __init__(self, calculator, formatter):
self.calculator = calculator
self.formatter = formatter
def receipt(self, lines):
return self.formatter.format(lines, self.calculator.total(lines))
lines = [(2, 3.50), (1, 10.00)]
print(Checkout(PriceCalculator(0.10), ReceiptFormatter()).receipt(lines))
class ShoutingFormatter(ReceiptFormatter): # change the layout: no money code touched
def format(self, lines, total):
return super().format(lines, total).upper().replace("X", "×")
print(Checkout(PriceCalculator(0.0), ShoutingFormatter()).receipt(lines))output
2 x 3.50
1 x 10.00
TOTAL 18.70
2 × 3.50
1 × 10.00
TOTAL 17.00Run and edit it in the app.
report(data, mode=2) — where mode switches between three totally different layouts inside report. What's the smell?
Watch it
Rules of thumb
- Law of Demeter — talk to your friends, not your friends' friends:
order.customer.address.citycouples you to three classes. Askorder.ship_to_city(). - Depend on interfaces at boundaries — a database, an email service, a clock.
- Name test: if you can't name a class without "and" or "Manager", its cohesion is probably low.
- Change test: list the reasons a module might change. More than one → split it (Single Responsibility).
When to push — and when to stop
Low coupling has a price: indirection. Five interfaces between two classes that always change together is worse — they were cohesive, and you split them. Aim for modules that change together living together, and modules that change for different reasons living apart.
Pros and cons of aiming for it
| Pros | Cons (when overdone) |
|---|---|
| Changes stay local; fewer surprise breakages | Indirection: more files to follow one request |
| Modules are testable in isolation | Interfaces for things that never vary |
| Parallel work without merge pain | Splitting cohesive code scatters one idea |
How it relates
- Encapsulation is the main tool for loose coupling.
- SOLID is five specific ways to manage both dials.
- The smells in Couplers and Change Preventers are what high coupling and low cohesion feel like day to day.
- Mediator, Observer, and Facade exist mainly to cut coupling.
A module named helpers.py holds date formatting, password hashing, and an HTTP retry loop. How would you describe its cohesion?
Key terms
- Coupling — How much one module depends on the details of another.
- Cohesion — How closely the responsibilities inside one module belong together.
- Law of Demeter — Only call methods on your direct collaborators, not on objects they return.
- Control coupling — Passing a flag that tells another module which internal path to take.