PYTH-270 · Lesson 2 of 12 · 25 min

Cohesion, coupling and the SOLID principles

▶ Video coming soon A video version of this lesson is on the way.

Lesson 1 named cohesion and coupling as the two forces behind code that is easy or hard to change. This lesson looks at them closely and then at SOLID, five principles that Robert C. Martin gathered in the early 2000s to help object-oriented code stay cohesive and loosely coupled. The principles were written with languages such as Java and C++ in mind, so this lesson also shows what each one looks like in Python, where functions are objects, duck typing replaces many interfaces, and a module is often the right unit of design.

Lena's review of the ridgeline package becomes the running example. She found a ReportPrinter class that fetched data, calculated totals and formatted output; a shipping function with an if-chain for every carrier; a GiftCard class that pretended to be a physical product; and an order service that created its own database connection. Each problem maps onto one of the principles.

Cohesion: things that change together belong together

Cohesion describes how strongly the parts of a module, class or function are related. The classic scale runs from coincidental cohesion, where parts are grouped for no reason (a utils.py full of unrelated helpers), through logical cohesion, where parts do similar kinds of things (all the input readers in one place), to functional cohesion, where every part contributes to one well-defined task (a money module that parses, rounds and formats amounts).

A practical test is to describe what the piece does in one sentence without using the word and. The money module rounds and formats amounts of money for display, which is one job described from two angles. ReportPrinter loads sales from the database and computes totals and formats a report, which is three jobs. Another test is to ask who would ask for a change: if the accountant, the database administrator and the web designer could each ask for a change to the same class, it has low cohesion.

Coupling: how much pieces know about each other

Coupling describes how much one piece depends on another. Some coupling is necessary, because pieces must work together, but some kinds are far more expensive than others. From tightest to loosest, the traditional list runs: content coupling, where one module reaches into another's private data; common coupling, where modules share global variables; control coupling, where one passes a flag that tells the other what to do; stamp coupling, where a whole record is passed when only one field is needed; and data coupling, where only the simple values that are needed are passed.

Two diagrams side by side. On the left, five modules are each connected to every other module by lines, forming a dense web. On the right, the same five modules connect only through two small interfaces, with far fewer lines.
Tight coupling (left) means every change can ripple everywhere; loose coupling (right) limits each change to a few places

In Python, the most common sources of tight coupling are module-level global state, reaching into another object's underscore attributes, long chains such as order.customer.address.city.lower() that depend on the structure of four classes, and boolean flag parameters such as print_report(data, as_html=True), which make the caller control the callee's internal branching.

Reaching through nested structure couples the caller to it; asking the object for what you need does not
# Tight: the caller must know the internal layout of Order and Customer.
def label_tight(order):
    return f"{order['customer']['address']['city'].upper()} #{order['id']}"

# Looser: the order offers what callers need; callers stop depending on its structure.
class Order:
    def __init__(self, order_id, city):
        self.id = order_id
        self._city = city

    def ship_to_city(self):
        return self._city.upper()

def label_loose(order):
    return f"{order.ship_to_city()} #{order.id}"

print(label_tight({"id": 1041, "customer": {"address": {"city": "Kelowna"}}}))
print(label_loose(Order(1041, "Kelowna")))
Output
KELOWNA #1041
KELOWNA #1041

The second version follows the Law of Demeter, sometimes stated as talk to friends, not strangers: a function should call methods on its own objects and parameters, not on objects it reached through them. If the way addresses are stored changes, only Order changes, not every label, invoice and report that needs a city.

S: the single responsibility principle

The single responsibility principle says a module or class should have one reason to change, or in Martin's later wording, should be responsible to one actor: one person or group whose requests drive changes to it. It is cohesion stated as a rule. ReportPrinter breaks it, because the accountant (totals), the database administrator (queries) and the web designer (formatting) all drive changes to it.

The fix is to split along the lines of those reasons: a SalesSource that loads records, a function that computes the summary, and a formatter that turns a summary into text. Each piece is now small enough to test on its own, and the formatter can be swapped for an HTML or CSV formatter without touching the arithmetic.

Try it yourself

Split the report

The starter report(sales) both computes totals and formats text. Split it: write summarize(sales) returning a dictionary {"bags": total_bags, "revenue": total_revenue_rounded_to_2}, and format_summary(summary) returning the text Bags: N | Revenue: X.XX.

Keep report(sales) working by returning format_summary(summarize(sales)). Each sale is a tuple (sku, bags, price_per_bag).

def report(sales):
    bags = 0
    revenue = 0
    for sku, qty, price in sales:
        bags += qty
        revenue += qty * price
    return f"Bags: {bags} | Revenue: {revenue:.2f}"

print(report([("RC-101", 2, 18.50), ("RC-104", 1, 20.25)]))

O: the open-closed principle

The open-closed principle, from Bertrand Meyer, says software should be open for extension but closed for modification: you should be able to add new behavior by adding new code, without editing code that already works. Ridgeline's shipping function was the opposite. Every new carrier meant opening the function and adding another elif, retesting all the existing branches, and hoping nothing broke.

In Python, the usual way to make code open for extension is a registry: a dictionary that maps a name to a function or class, plus a small decorator that adds entries to it. The core code looks things up in the registry and never needs to change; a new carrier is a new function with one decorator line. Lesson 6 calls this the strategy pattern, and lesson 7 returns to registries as a plugin system.

A registry keeps shipping_cost closed for modification and open for new carriers
SHIPPERS = {}

def shipper(name):
    def register(func):
        SHIPPERS[name] = func
        return func
    return register

@shipper("pickup")
def pickup(weight_kg):
    return 0.0

@shipper("canada_post")
def canada_post(weight_kg):
    return round(9.50 + 2.25 * weight_kg, 2)

def shipping_cost(method, weight_kg):
    try:
        return SHIPPERS[method](weight_kg)
    except KeyError:
        raise ValueError(f"unknown shipping method: {method}") from None

# Adding a carrier later needs no change to shipping_cost:
@shipper("courier")
def courier(weight_kg):
    return 15.0 if weight_kg <= 5 else 22.0

for m in ["pickup", "canada_post", "courier"]:
    print(m, shipping_cost(m, 2.0))
Output
pickup 0.0
canada_post 14.0
courier 15.0

Try it yourself

Add a carrier without editing

The setup has defined a registry SHIPPERS, a shipper(name) decorator and a shipping_cost(method, weight_kg) function, all hidden from you. Theo has signed with a new local courier.

Register a new method called "bike" using the @shipper("bike") decorator: it costs 6.0 for parcels up to 3 kg and is not available above that, so raise ValueError for heavier parcels. Do not redefine shipping_cost.

def bike(weight_kg):
    return 6.0

print(shipping_cost("pickup", 2))

L: the Liskov substitution principle

Barbara Liskov's substitution principle says that objects of a subclass must be usable anywhere objects of the parent class are expected, without the caller noticing. A subclass may add behavior, but it must keep the parent's promises: accept at least the same inputs, return the same kinds of results, and not raise new exceptions for operations the parent supports.

In the ridgeline package, Product had a shipping_weight() method, and GiftCard inherited from Product. Gift cards are emailed, so the developer made GiftCard.shipping_weight() raise NotImplementedError. Every function that loops over products and adds up weights now crashes when a basket contains a gift card. The subclass broke the parent's promise. The fix is to keep the promise (a gift card weighs 0 kg and ships digitally) or, if the promise genuinely does not apply, to stop pretending: model gift cards as a separate kind of item that does not inherit shipping behavior at all.

Duck typing does not remove Liskov's principle; it widens it. Any object passed where code expects something with a shipping_weight() method should honor the same contract, whether or not it inherits from Product.

I: the interface segregation principle

The interface segregation principle says that clients should not be forced to depend on methods they do not use. In languages with formal interfaces, a fat interface with fifteen methods forces every implementer to provide all fifteen, even when a client needs only two. In Python, the equivalent problem is a function that demands a rich object when it only needs one capability, or an abstract base class that requires many methods from every subclass.

The Pythonic answer is small protocols. A function that prints shelf labels needs only something with a name and a price; it should accept any such object, not demand an entire Coffee with stock levels and roast dates. Lesson 7 shows how typing.Protocol expresses these small interfaces explicitly, so type checkers can verify them without forcing inheritance.

D: the dependency inversion principle

The dependency inversion principle says that high-level policy should not depend on low-level details; both should depend on abstractions. Ridgeline's order service created its own SQLite connection inside its constructor and called it directly. That made the business rules (high level) depend on the database (low level): the service could not be tested without a real database, and moving to PostgreSQL meant editing business code.

Inverting the dependency means the service asks for what it needs, something that can save and load orders, and the caller supplies it. In production, the caller passes a SQLite repository; in tests, an in-memory one; later, a PostgreSQL one. This is dependency injection, and in Python it is usually nothing more than passing an object to the constructor.

Dependency injection: OrderService works with any object that has add() and all()
class InMemoryOrders:
    """Satisfies the same contract as the SQLite repository: add() and all()."""
    def __init__(self):
        self._orders = []

    def add(self, order):
        self._orders.append(order)

    def all(self):
        return list(self._orders)

class OrderService:
    def __init__(self, orders):          # the dependency is passed in, not created here
        self.orders = orders

    def place(self, customer, total):
        order = {"customer": customer, "total": total}
        self.orders.add(order)
        return order

service = OrderService(InMemoryOrders())
service.place("Alder Hotel", 204.0)
service.place("Bayside Bakery", 121.5)
print(len(service.orders.all()), "orders stored")
Output
2 orders stored
Five cards in a row labeled S, O, L, I and D. Each card names the principle and gives a one-line Ridgeline example: split ReportPrinter, register new carriers, gift cards keep Product's promises, ask only for what you use, and pass the repository in.
The five SOLID principles, each with the Ridgeline problem it fixed

Try it yourself

Inject the clock

is_stale(roast_date) decides whether a bag is past its 30-day sell-by window, but it calls date.today() inside, so its result depends on the day you run it and is hard to test.

Change it to is_stale(roast_date, today=None): when today is given, use it; when it is None, use date.today(). A bag is stale when more than 30 days have passed since roasting.

from datetime import date

def is_stale(roast_date):
    return (date.today() - roast_date).days > 30

print(is_stale(date(2020, 1, 1)))

Worked problem

Mapping review findings to principles

Lena hands Jordan four findings from her review and asks which principle each one breaks.

Show solution
  1. ReportPrinter loads data, computes totals and formats text: three reasons to change, so it breaks the single responsibility principle.
  2. shipping_cost needs a new elif for every carrier: existing code must be edited to extend it, so it breaks the open-closed principle.
  3. GiftCard.shipping_weight() raises NotImplementedError where Product returns a weight: it cannot stand in for a Product, so it breaks the Liskov substitution principle.
  4. The label printer requires a full Coffee object when it reads only the name and price: it depends on more than it uses, so it breaks interface segregation.
  5. OrderService builds its own SQLite connection: business rules depend on a storage detail, so it breaks dependency inversion.

Answer: S, O, L, I and D: one finding per principle, which is why Lena used them as her review checklist.

Common mistakes

  • MistakeSplitting code into tiny classes in the name of single responsibility until nobody can follow it.

    InsteadSplit along real reasons for change, judged by who asks for changes, not by counting methods.

  • MistakeApplying the open-closed principle everywhere in advance.

    InsteadAdd extension points where change has actually happened or is clearly coming.

  • MistakeMaking a subclass raise NotImplementedError for a method the parent supports.

    InsteadKeep the parent's promise, or do not inherit from it.

  • MistakePassing a whole object when the function needs one value.

    InsteadPass the value, or depend on a small protocol, to reduce coupling.

  • MistakeCreating databases, clocks or network clients inside business classes.

    InsteadPass them in, so they can be replaced in tests and changed later.

  • MistakeReaching through chains like a.b.c.d.method().

    InsteadAsk the nearest object for what you need (the Law of Demeter).

Summary and key terms

  • Cohesion is how closely the parts of a piece belong together; aim for one job per piece.
  • Coupling is how much pieces depend on each other's details; data coupling is loosest, content coupling tightest.
  • Single responsibility: a module or class should answer to one actor, one reason to change.
  • Open-closed: extend behavior by adding code, for example through a registry, not by editing working code.
  • Liskov substitution: subclasses and duck-typed stand-ins must keep the promises of what they replace.
  • Interface segregation: depend only on the methods you use; in Python, prefer small protocols.
  • Dependency inversion: business rules depend on abstractions, and concrete details are passed in.
functional cohesion
Every part of a module contributes to one well-defined task.
data coupling
Modules share only the simple values that are needed.
control coupling
One module passes a flag that steers the internal logic of another.
Law of Demeter
Call methods on your own objects and parameters, not on objects reached through them.
single responsibility principle
A module should have one reason to change.
open-closed principle
Open for extension, closed for modification.
Liskov substitution principle
A subtype must be usable wherever its parent type is expected.
interface segregation principle
Clients should not depend on methods they do not use.
dependency inversion principle
High-level policy and low-level detail both depend on abstractions.
dependency injection
Supplying an object's dependencies from outside rather than creating them inside.
registry
A mapping from names to functions or classes that new code can add to.

Practice problems

Work each problem on paper first, then reveal the answer to check.

  1. How can you tell, in one sentence, whether a class is cohesive?

    Show answer

    Describe its job without the word and; if you cannot, it probably does more than one thing.

  2. Which is looser: passing a customer dictionary or passing the customer's postal code?

    Show answer

    Passing only the postal code (data coupling) is looser than passing the whole record (stamp coupling).

  3. What Python feature most often makes code open for extension?

    Show answer

    A registry of functions or classes, usually filled by a decorator.

  4. A Penguin subclass of Bird raises an error in fly(). Which principle is at risk?

    Show answer

    Liskov substitution: code expecting any Bird to fly will break.

  5. Why is dependency injection helpful for testing?

    Show answer

    Tests can pass a fake or in-memory dependency instead of a real database, clock or network service.

  6. Does dependency inversion require abstract base classes in Python?

    Show answer

    No: any object with the needed methods will do, though a Protocol can document the contract.

Lesson quiz · 5 questions

Score 60% or more to complete this lesson.

Take the quiz