2.2.9 · D3Design Principles

Worked examples — Separation of concerns

3,570 words16 min readBack to topic

Two measures we lean on the whole way

Everything below is scored with two numbers. The parent note names them; here we make each one concrete and give it a picture, because a standalone reader needs to see them before we count with them.

Figure — Separation of concerns

The scenario matrix

Here is every case class this topic can hand you:

# Case class What's wrong (or the twist) The seam you cut along
A Tangling — one unit, many jobs Storage + logic + display in one function Split by reason to change
B Scattering — one job, many units Tax rule copy-pasted in 3 places Pull the duplicate into one home (Don't Repeat Yourself (DRY))
C Cross-cutting concern Logging touches every function Wrap with a decorator (Cross-cutting concerns)
D Layering at scale Big app, wrong-way dependencies One-directional layers (Layered Architecture)
E Degenerate: nothing to separate A 5-line script Don't add a boundary
F Over-separation (fragmentation) 6 one-line classes calling each other Merge — cohesion was faked (fragmentation)
G Real-world word problem Notification system for a shop Identify concerns from the story
H Exam twist "Which refactor lowers coupling most?" Reason numerically about coupling
I Limiting behaviour Push SoC to its extreme both ways See where each extreme breaks

The nine examples below hit every cell A–I. Each is labelled with its cell.


Figure — Separation of concerns

Example A — Tangling (mixed jobs in one unit)

Step 1 — List the reasons to change. Why this step? The parent's recipe step 1: a concern = one reason to edit.

  • Storage: "we moved to a new database" → the db.query line.
  • Business: "tax went from 20% to 25%" → the gross, tax, net maths.
  • Presentation: "show it in euros / as JSON" → the print.

Three distinct reasons mean three concerns, so three homes.

Step 2 — Give each concern its own function. Why this step? So a change for one reason touches exactly one place (this is the Single Responsibility Principle).

def fetch_emp(emp_id):                 # storage
    return db.query(f"SELECT hours, rate FROM emp WHERE id={emp_id}")
 
def net_pay(hours, rate, tax=0.20):    # business — pure maths, no db, no screen
    gross = hours * rate
    return gross - gross * tax
 
def render_pay(net):                   # presentation
    return f"Net pay: ${net:.2f}"

Step 3 — Compose them. Why this step? The parts must still cooperate, but only through narrow interfaces (numbers in, numbers out).

emp = fetch_emp(7)
print(render_pay(net_pay(emp.hours, emp.rate)))

Verify: take hours = 40, rate = 25. Gross . Tax . Net . render_pay(800)"Net pay: $800.00". The business function net_pay now needs no database to test — proof the concern is truly separated.


Example B — Scattering (one job smeared across many units)

Step 1 — Spot the scattered concern. Why this step? The parent warns: a concern smeared across modules is scattering, the twin evil of tangling. "The tax rate" is one concern living in three places.

Step 2 — Give tax one home. Why this step? This is Don't Repeat Yourself (DRY) serving SoC: one rule, one source of truth.

TAX = 0.18
def with_tax(amount): return amount * (1 + TAX)
 
def cart_total(rows):  return with_tax(sum(p*q for p,q in rows))
def quote(price):      return with_tax(price)
def invoice(sub):      return with_tax(sub)

Step 3 — Make the change. Why this step? A single source of truth means a rule change lands in exactly one place. Set TAX = 0.20. Done. One edit instead of three.

Verify: with TAX = 0.20, quote(50) . cart_total([(10, 2), (5, 4)]): subtotal , with tax . invoice(200) . Before the fix: 3 edits. After: 1 edit — the metric the parent's first mistake demands ("how many places do I edit?").


Example C — Cross-cutting concern

Step 1 — Classify the concern. Why this step? Logging isn't a layer — it touches storage, business, and presentation alike. That's a cross-cutting concern, so a decorator/wrapper, not a layer, is the right tool (Cross-cutting concerns).

Step 2 — Isolate it in a wrapper. Why this step? A decorator lets us keep the log-writing code physically in one place while it still runs around every function we choose to wrap.

import logging                          # Python's built-in logging module
logger = logging.getLogger("app")       # one place that knows how to log
 
def with_logging(fn):                   # the cross-cutting concern, isolated
    def wrapped(*a, **k):
        logger.info("calling %s", fn.__name__)
        return fn(*a, **k)
    return wrapped
 
@with_logging
def net_pay(hours, rate, tax=0.20):     # pure business maths, no logging inside
    return hours*rate - hours*rate*tax

Verify: call net_pay(10, 5). Business result unchanged: . The log line is produced around it, not inside it — remove the @with_logging line and the maths still returns 40. Concern separated.


Example D — Layering at scale (dependency direction)

Step 1 — Recall the legal direction. Why this step? In Layered Architecture, arrows point one way: Presentation → Application → Domain → Data. So a database swap never forces a UI rewrite.

Figure — Separation of concerns

Step 2 — Count the illegal coupling. Why this step? Coupling = number of other modules a module depends on (our first definition above). Counting tells us objectively how far the current design is from the ideal, and by how much a fix helps. The Domain currently depends on: Data (1) and Presentation (1) = coupling 2. The upward arrow (orange, in the figure) violates the rule: business rules should not know how things look.

Step 3 — Cut the upward arrow. Why this step? Domain raises a plain error object; Presentation (which already depends downward on Domain) decides how to display it. No new dependency needed — Presentation already points at Domain. After the fix, Domain depends only on Data: coupling 1.

Verify: Domain coupling before , after . Lower coupling ✓. Crucially, no layer's cohesion dropped and no new dependency was added — the formatting concern moved to where it belongs (Presentation), which is the corner our matrix (fig. 1) aims for.


Example E — Degenerate case: nothing to separate

Step 1 — Count the concerns. Why this step? SoC separates concerns that change for different reasons. Here there is essentially one concern (show a date), and no plausible independent reasons to change.

Step 2 — Recognise the anti-pattern. Why this step? Naming the anti-pattern stops us adding ceremony out of habit. Adding layers here is the parent's third mistake: "separation always means more layers." Boundaries you don't need only add coupling and indirection.

Verify: count the boundaries that change independently: 0. Rule from the parent: "add a boundary only when two concerns genuinely change independently." Zero independent seams means you add zero boundaries. Leave it as two lines.


Example F — Over-separation (fragmentation)

Step 1 — Test with the "reason to change" question. Why this step? The parent: "one concern" means one reason to change, not one statement. All four classes change for the same reason ("how we compute pay"). So they are one concern split into four rooms.

Step 2 — Measure the damage. Why this step? Counting the dependencies exposes that "more classes" quietly raised coupling instead of lowering it. net_pay now depends on four classes → coupling 4 (was 0). Each class does one line, not one concern → this is fragmentation (parent's second mistake). Cohesion looks high per-class but the real concern is scattered.

Step 3 — Merge back. Why this step? Since all four rooms share one reason to change, they belong in one room.

def net_pay(emp, tax=0.20):
    return emp.hours * emp.rate * (1 - tax)

Coupling for the caller of pay-logic drops from 4 to 1.

Verify: with hours = 40, rate = 25: gross , net . Same answer as the four-class maze, with caller coupling reduced from 4 to 1. Fewer rooms, one concern — the matrix's bottom-left.


Example G — Real-world word problem

Step 1 — Extract concerns from the story ("who asks me to edit this, and why?"). Why this step? The parent's test turns a vague sentence into a concrete list of "reasons to change".

  • Persistence — "save the order" → changes if we switch database.
  • Payments — "charge the card" → changes if we switch payment provider.
  • Notification — "email... later SMS" → changes when a channel is added.
  • Wording/templates — "marketing changes wording" → changes independently of everything else.

Four reasons mean four concerns.

Step 2 — Notice the sub-seam inside notifications. Why this step? "Add SMS" (a channel) and "change wording" (a template) change for different reasons — so notification splits again: a Channel interface + a Template. This is Information Hiding: place_order doesn't know or care how a message is delivered.

Step 3 — Structure and wire it together. Why this step? Giving each concern its own function/interface means each future request lands in exactly one home — but the parts must still cooperate, so the composition step wires them in order.

def build_msg(order):                 # wording/template concern (its own home)
    return f"Hi {order.customer.name}, your order {order.id} is confirmed."
 
def notify(user, msg):                # notification concern; hides the "how"
    for channel in user.channels:     # email today, SMS tomorrow
        channel.send(msg)             # each channel implements send(msg)
 
def place_order(order):
    save(order)                                   # persistence concern
    charge(order.card, order.total)               # payments concern
    notify(order.customer, build_msg(order))      # notify AFTER charge succeeds

The wiring order matters: save first, then charge, then notify — each concern is a separate call, so place_order reads like the story and edits stay local.

Verify — count edits for each future request:

  • Add SMS → add one Channel (implements send), edit 1 place (user.channels config). ✓
  • Change email wording → edit build_msg only, 1 place. ✓
  • Switch payment provider → edit charge only, 1 place. ✓ Every future change touches exactly 1 home — the gold standard from the parent's first mistake.

Example H — Exam twist (reason numerically)

Step 1 — Score move (i): the logging decorator. Why this step? Coupling = number of other modules Report depends on (our definition above), so we count what each move removes from Report's dependency list. With a decorator, Report no longer calls Logger directly — the wrapper does. That removes 1 dependency: , so coupling 3.

Step 2 — Score move (ii): delete the unused Mailer. Why this step? Same rule — count the removed dependency. Mailer is unused, so deleting it removes exactly 1: , so coupling 3.

Step 3 — Score move (iii): split Report into two modules. Why this step? Splitting redistributes the same 4 dependencies but forces the two halves to talk to each other, adding a new inter-module edge. So we must count both the removal and the new edge. Report's own list may lose at most 1 dependency, but a new dependency on its sibling appears: worst case , so coupling 4. It does not beat (i) or (ii).

Compare: (i) → 3, (ii) → 3, (iii) → 4. Answer: either (i) or (ii), reaching coupling 3.

Verify: starting coupling . (i): . (ii): . (iii): . Minimum reachable , achieved by (i) and (ii); (iii) does not improve. ✓


Example I — Limiting behaviour (both extremes)

Step 1 — The zero-separation limit. Why this step? Testing an extreme reveals whether a metric can be gamed — the parent warns coupling alone is cheatable. One giant function means there is exactly one module, so inter-module coupling . But it juggles every concern, so cohesion is terrible. Coupling looked perfect yet the design is awful.

Step 2 — The maximum-separation limit. Why this step? The opposite extreme checks the other metric's cheatability. One function per line means N tiny units all calling each other, so coupling explodes toward its max while "per-unit cohesion" looks perfect. This is fragmentation — the parent's second cheat.

Step 3 — Find the sweet spot. Why this step? Since neither extreme wins both metrics, the good design must sit between them, cut along real concern seams.

Figure — Separation of concerns

Verify: model a 6-statement program. Zero-separation: 1 module, coupling , but concerns-per-module (bad cohesion). Max-separation: 6 modules chained, so coupling edges . Group them by 3 concerns instead: 3 modules, edges . So concern-based grouping gives coupling and one concern per module. The middle beats both extremes numerically. ✓


Active recall

Recall In Example B, how many edits does a

good (DRY) design need for a tax change, versus the scattered version? Good design: 1 edit (change the single TAX constant). Scattered version: 3 edits.

Recall In Example D, what is the Domain layer's coupling before and after removing the upward arrow?

Before: 2 (Data + Presentation). After: 1 (Data only).

Recall In Example H, which single move lowers

Report's coupling most, and to what value? Move (i) or (ii), each reaching coupling 3. Splitting (iii) does not beat them because it adds a new inter-module edge.

Recall In Example E, how many boundaries should you add to a 5-line date script?

Zero — no two concerns change independently, so a boundary would only add coupling (parent's third mistake).

Recall Define coupling and cohesion in one line each.

Coupling = how many other modules a module depends on (want LOW). Cohesion = how tightly a module's insides serve one concern (want HIGH).


See also: Coupling and Cohesion · Modularity · Single Responsibility Principle · Hinglish version →