open atlas
↑ Back to track
Code patterns & craft CP · 04 · 02

Actors and axes of change

A component often mixes three independent change axes — presentation policy, a business rule, persistence — each requested by a different actor. Group code by axis so one change touches one place. SRP attacks coupling across axes; over-splitting imagined axes costs indirection.

CP Senior ◷ 19 min
Level
FoundationsJuniorMiddleSenior

“One responsibility” sounds like a counting exercise — keep each class doing one thing — and that framing is useless in practice, because every useful class does several things. A Report class loads data, applies a rule, and formats output: that is three things, and yet splitting on “things” gives you no principled place to stop. Count verbs and you can justify any number of classes from one to a dozen.

The version that actually guides design is sharper: a module should have one reason to change — one source of change requests. The way you find those reasons is to ask, for each line of code, who asks for it to change and along which axis. Lines that answer to the same person, varying for the same reason, belong together. Lines that answer to different people, varying independently, are a coupling waiting to bite you.

Goal

After this lesson you can identify the independent axes of change in a tangled component by asking “who requests a change along this axis?”; group code so that a change on one axis touches exactly one module; explain why this kills shotgun surgery and why cohesion (“things that change together live together”) is the same idea seen from the other side; and recognise the failure mode — splitting along axes that never actually vary independently, which buys indirection and no flexibility.

1

An “axis of change” is a direction along which requirements move independently — and each axis has an owner who pushes it. Forget counting methods. Ask instead: what kinds of change request land on this code, and do they arrive separately? A pricing service typically faces at least three: marketing changes how prices are displayed (currency, locale, rounding); finance changes how tax or discount is computed (the business rule); platform/ops changes how results are stored (schema, datastore, audit columns). Those three requests come from three different people on three different schedules. Each is an axis. The owner of the axis — the “actor” in Uncle Bob’s phrasing of SRP — is who you’d go ask when that kind of change is needed.

The point of naming the actor is that it makes the boundary objective. “Is formatting a separate responsibility from tax math?” is a matter of taste. “Does the person who changes formatting differ from the person who changes the tax rule?” has an answer, and on most teams the answer is yes.

2

When two axes share a module, a change on one axis risks breaking the other — that is coupling across axes, and it is the cost SRP attacks. Consider this service, where presentation policy, a business rule, and persistence all live in one method:

class InvoiceService {
  render(invoice: Invoice): string {
    // business rule (finance owns this)
    const tax = invoice.subtotal * 0.2;
    const total = invoice.subtotal + tax;
    // persistence (ops owns this)
    db.execute(
      "UPDATE invoices SET total_cents = ? WHERE id = ?",
      [Math.round(total * 100), invoice.id],
    );
    // presentation policy (marketing/locale owns this)
    return `Total: $${total.toFixed(2)}`;
  }
}

Three actors are now entangled in one method. When finance changes the tax rule, the diff also sits next to the SQL and the $-formatting — a reviewer from finance has to read code they don’t own, and a careless edit to the rule can shift the rounding written to the database. The three concerns don’t just coexist; they share state and execution order, so each is exposed to the others’ mistakes.

3

Separating by axis means a change on one axis touches exactly one module — this is the cure for shotgun surgery. Shotgun surgery is the smell where one logical change forces edits scattered across many places. SRP’s specific claim is that the scatter is caused by mixing axes: if tax math is smeared across rendering, storage, and reporting, then changing tax math is a shotgun. Pull each axis into its own seam and the scatter collapses:

// axis 1: the business rule — finance's module
class InvoicePricing {
  total(invoice: Invoice): number {
    const tax = invoice.subtotal * taxRate(invoice.region);
    return invoice.subtotal + tax;
  }
}
// axis 2: persistence — ops's module
class InvoiceStore {
  saveTotal(id: string, totalCents: number) {
    db.execute("UPDATE invoices SET total_cents = ? WHERE id = ?", [totalCents, id]);
  }
}
// axis 3: presentation policy — locale's module
class InvoiceFormatter {
  line(total: number, locale: Locale): string {
    return format(total, locale); // currency, rounding, symbol position
  }
}

Now a tax-rule change is one edit inside InvoicePricing; a datastore migration is one edit inside InvoiceStore; a locale change is one edit inside InvoiceFormatter. None of them can break the others, because they no longer share a body.

4

Cohesion is the same principle from the other side: things that change together live together; things that change apart live apart. SRP is often taught as “split things up”, but the deeper rule is grouping, and the grouping criterion is co-variation. Code that always changes for the same reason has high cohesion — keep it in one place even if it is several functions. Code that changes for different reasons has low cohesion when forced together — that is precisely the entanglement above. So SRP doesn’t tell you to make modules small; it tells you to make each module’s reasons-to-change singular. A 200-line module that only ever changes when the tax rule changes is more cohesive — and more SRP-compliant — than a 20-line module that changes for tax, formatting, and storage reasons.

This is why “one responsibility” and “one axis of change” and “high cohesion” are three names for one design target: align module boundaries with the boundaries between independent change requests.

Worked example

The test in action: “if requirement R changes, how many modules must I touch, and do unrelated ones risk breaking?” Take the mixed InvoiceService.render from Step 2 and run three concrete requirements through it.

Before (one mixed method):

  • R1 — “tax is now region-based”: edit render. The diff sits beside the SQL and the format string; a reviewer must reason about storage and display to approve a finance change. Blast radius: unrelated axes exposed.
  • R2 — “move from MySQL to Postgres”: edit render again — the same method finance just touched. Two actors now contend over one file.
  • R3 — “show prices in EUR for EU users”: edit render a third time. Every requirement, from every actor, funnels through the same method. That is the shotgun, inverted into a bottleneck.

After (split by axis):

  • R1 → one edit in InvoicePricing.total. InvoiceStore and InvoiceFormatter are not opened, so they cannot break.
  • R2 → one edit in InvoiceStore.saveTotal. Pricing and formatting are untouched.
  • R3 → one edit in InvoiceFormatter.line. Pricing and storage are untouched.

The answer to the test went from “touch one method, risk three axes” to “touch one module, risk nothing else.” Notice the count of edits per requirement didn’t fall — it was one method before and one module after. What changed is the isolation: each change is now confined to the axis that owns it, so unrelated requirements no longer collide in the same diff.

Why this works

Why “who requests the change”, and not “what does the code do”? Because what it does is observed at one moment and gives you no information about how it will move. Two lines can do superficially similar things (both “format a string”) yet answer to different actors who change them on different schedules — and two lines that look unrelated can always change together because one actor owns both. The actor question predicts co-variation, which is the thing module boundaries are supposed to track. Tying boundaries to actors is also what makes SRP testable on a real team: you can literally point at the two people who would file the two change requests. If you can’t name two different requesters for two pieces of code, you probably don’t have two responsibilities — you have one, and splitting it is premature.

Common mistake

The failure mode is splitting along axes that never actually vary independently. SRP is a knife, and over-using it shreds. If you split InvoicePricing into TaxCalculator, DiscountCalculator, TotalAssembler, RoundingPolicy — but every pricing change in your history has touched all four together — you’ve invented four modules that change in lockstep. Now a single conceptual change is back to shotgun surgery, except self-inflicted: four files, four constructors, indirection between them, and no flexibility bought, because the axes you imagined were never separate. The discipline cuts both ways: separate axes that demonstrably vary apart (different actors, different change history), and keep together the ones that don’t. Premature separation is as much an SRP violation as entanglement — it just fails on the cohesion side instead of the coupling side.

Check yourself
Quiz

You're deciding whether to split a Report class that loads data, applies a discount rule, and renders HTML. By SRP-as-axes, what is the deciding question — and what would make splitting it the WRONG call?

Recap

“One responsibility” is meaningless as a verb-count; it becomes operational when you read it as one reason to change, and you find those reasons by asking, for each piece of code, who requests the change and along which axis. A typical component hides several axes — presentation policy, business rules, persistence — each owned by a different actor who pushes it on their own schedule. When axes share a module they couple across: a change on one risks breaking the others, and one logical change scatters into shotgun surgery. Aligning module boundaries with axis boundaries means a change on any axis touches exactly one place and endangers nothing else — which is just cohesion (things that change together live together) seen from the grouping side. The senior test is “if requirement R changes, how many modules must I touch, and do unrelated ones risk breaking?” But the knife cuts both ways: splitting along imagined axes that never vary independently re-creates shotgun surgery as indirection with no flexibility bought. Separate the axes that demonstrably move apart; keep together the ones that move as one.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 4 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.