2.2.9 · D4Design Principles

Exercises — Separation of concerns

2,311 words11 min readBack to topic

Two numbers show up again and again, so let us pin them down in plain words before using them:

See Coupling and Cohesion for the full treatment; here we only use them.


Level 1 — Recognition

L1·Q1 — Name the concerns

Below, each commented line does something. List the distinct concerns (reasons to change), and how many there are.

def handle_order():
    row  = db.query("SELECT price FROM items WHERE id=5")  # (a)
    cost = row.price * 1.18                                 # (b)
    open("log.txt","a").write("order handled\n")            # (c)
    print(f"You owe ${cost:.2f}")                           # (d)
Recall Solution
  • (a) Storage / persistence — changes if the database changes.
  • (b) Business rule — changes if the tax rate or pricing law changes.
  • (c) Logging — a cross-cutting concern; changes if logging policy changes.
  • (d) Presentation — changes if the output format changes.

Distinct concerns = 4. Four different people, four different reasons, all tangled in one function. That is exactly the "one person does every kitchen job" anti-pattern.

L1·Q2 — Coupling by counting arrows

Module Report imports and calls Database, Formatter, and Mailer. Nothing else. What is Coupling(Report)?

Recall Solution

Coupling = number of other modules depended on = 3 (Database, Formatter, Mailer). That is the whole definition — count the arrows leaving the box. See the figure below.

Figure — Separation of concerns

Level 2 — Application

L2·Q1 — Split the tangle

Take the L1·Q1 function and rewrite it so each concern lives in its own function, composed at the end. Write the four functions.

Recall Solution
def fetch_price(item_id):                 # storage concern
    return db.query(f"SELECT price FROM items WHERE id={item_id}").price
 
def total_with_tax(price, tax=0.18):      # business concern
    return price * (1 + tax)
 
def log_event(msg):                       # logging (cross-cutting)
    open("log.txt","a").write(msg + "\n")
 
def render(amount):                       # presentation concern
    return f"You owe ${amount:.2f}"
 
# composition:
log_event("order handled")
print(render(total_with_tax(fetch_price(5))))

Why this split? A tax change now touches only total_with_tax. A database swap touches only fetch_price. Each concern has one home — the definition of SoC.

L2·Q2 — Tax edit blast radius

In the tangled version, a tax-law change forces you to edit how many functions? In the separated version, how many?

Recall Solution
  • Tangled: the whole handle_order is one function, so 1 function is edited — but that same edit sits next to storage and presentation, risking them.
  • Separated: exactly 1 function, total_with_tax, and it touches nothing else. The count is the same (1) but the isolation is the win: no storage/UI code shares the room.

The real metric is not "how many functions" but "how many concerns can this edit accidentally break": 3 vs 0.

L2·Q3 — Which concern is cross-cutting?

Of the four concerns in L2·Q1, one naturally appears in every function of a bigger app. Which, and what tool isolates it without pasting it everywhere?

Recall Solution

Logging is the cross-cutting concern. Isolate it with a decorator (or middleware), so business functions stay clean:

def logged(fn):
    def wrap(*a, **k):
        log_event(f"calling {fn.__name__}")
        return fn(*a, **k)
    return wrap
 
@logged
def total_with_tax(price, tax=0.18): ...

The math and the logging run together but live physically apart.


Level 3 — Analysis

L3·Q1 — Score two designs

For a small script, compare:

  • Design A: 1 module doing storage + business + UI.
  • Design B: 3 modules (storage, business, UI); business depends on storage, UI depends on business.

Give Coupling of each module and a one-line verdict on cohesion.

Recall Solution

Design A: one module, so no other module to depend on → Coupling = 0. But it mixes 3 concerns, so cohesion is low (elements serve different reasons). Design B:

  • storage: depends on nothing → Coupling = 0.
  • business: depends on storageCoupling = 1.
  • UI: depends on businessCoupling = 1. Each module serves one concern → cohesion high.

Verdict: A wins the naive "zero coupling" contest but loses cohesion; B has tiny coupling (0,1,1) and high cohesion. This is why you optimise both, not one — see Coupling and Cohesion.

L3·Q2 — The over-split trap

A developer splits business into 6 nano-modules that all call each other in a ring: each depends on 2 neighbours. Total pairwise dependency arrows?

Recall Solution

A ring of 6 nodes, each pointing to its 2 neighbours → directed dependencies. Compared with Design B's total of arrows for the same work. This explosion is fragmentation: "high cohesion per tiny box" bought with runaway coupling. Splitting past the natural seam makes things worse.

Figure — Separation of concerns

L3·Q3 — Direction of arrows

In Layered Architecture, presentation → domain → data (one-way). Why does making the arrow point both ways (data also imports presentation) destroy SoC?

Recall Solution

A cycle means the database code now knows about the screen. Swap the UI and the data layer breaks; swap the database and the UI breaks. Concerns that were meant to change independently are now chained together — coupling becomes bidirectional, the blast radius of any change grows, and you can no longer test one layer alone. One-way arrows are SoC made structural; a cycle undoes it.


Level 4 — Synthesis

L4·Q1 — Design a notifications feature

You must add "send an email and an SMS when an order ships", with logging on every send. Sketch the modules and the dependency arrows so that adding a third channel (push) later touches minimal code.

Recall Solution

Separate the what (an order shipped) from the how (each channel), and treat logging as cross-cutting.

OrderService  --emits-->  ShipEvent
Notifier (loops over channels)  --> [EmailChannel, SmsChannel]
logged  --wraps-->  every channel.send()
  • Notifier depends on an interface Channel.send(msg), not on concrete classes (Information Hiding).
  • Adding push = write one new PushChannel and register it. Notifier, OrderService, logging, email, SMS are all untouched.
  • Blast radius of a new channel = 1 new module, 0 edits to existing ones. That is the Single Responsibility Principle and SoC paying off together.

L4·Q2 — Where does tax belong?

Prices, currency formatting, and tax rate all appear. Assign each to a layer of Layered Architecture and justify by "reason to change".

Recall Solution
  • Tax rate & price math → domain layer (business rules; changes when the law/pricing changes).
  • Currency formatting ($, .2f, commas) → presentation layer (changes when locale/UI changes).
  • Reading the stored price → data layer (changes when the DB changes).

Each sits in the layer whose reason to change it shares. If tax formatting crept into the data layer, a UI locale change would force a database-layer edit — a scatter.


Level 5 — Mastery

L5·Q1 — Cohesion as a fraction

Recall . A module has 4 elements. The number of possible unordered pairs is . If all pairs serve the same concern, what is the cohesion value? If only 2 of those pairs do?

Recall Solution

Total possible pairs .

  • All 6 serve one concern → cohesion (perfectly cohesive).
  • Only 2 do → cohesion (low; the module mixes concerns).

The fraction ranges from (nothing shares a purpose) to (everything does).

L5·Q2 — Degenerate edge cases

Evaluate cohesion and coupling for the extreme inputs. (a) A module with 1 element. (b) A module depending on 0 others. (c) A single giant module containing the whole program.

Recall Solution
  • (a) 1 element: possible pairs , so cohesion is undefined; by convention a one-element module is treated as trivially cohesive (there is nothing to disagree). Coupling depends only on its imports.
  • (b) 0 dependencies: Coupling = 0 — the ideal for a leaf module like storage in L3·Q1. It can be tested in total isolation.
  • (c) whole program in one module: Coupling = 0 (no other module), yet cohesion collapses toward its minimum because unrelated concerns share the box. This is precisely why coupling alone is a gameable metric — the degenerate case exposes it.

L5·Q3 — Prove the both-metrics claim

Show, using the numbers from L3, that neither "minimise coupling alone" nor "maximise cohesion alone" recovers a good design, but "cut along concern seams" does.

Recall Solution
  • Minimise coupling alone → Design A: total coupling , but cohesion low (mixes 3 concerns). Optimum of the wrong objective.
  • Maximise cohesion alone → L3·Q2 ring: each nano-module is "about one thing" (looks maximally cohesive) but total coupling . Optimum of the other wrong objective.
  • Cut along seams (Design B): total coupling and each module fully cohesive. Strictly better than both extremes on the joint goal.

So SoC is not "minimise X"; it is find the partition that is simultaneously low-coupling and high-cohesion, which is the partition along real reasons to change.


Active recall

Recall The single fastest test for "is this well-separated?"

Ask: for a typical change, how many places must I edit? Good SoC → ideally one, and that edit cannot break unrelated concerns.

Recall Why is "coupling = 0" not automatically good?

Because one giant module also has coupling 0 (no other module exists) while mixing every concern. Coupling alone is gameable; pair it with high cohesion.