One Reason to Change: Splitting the Class That Logs, Validates, and Persists
Every codebase has a class like this one. OrderService.placeOrder() validates the cart, computes a discount, reserves inventory, persists the order, sends a confirmation email, writes an audit entry, and logs each step. It works. Then the changes start arriving, and they never arrive one at a time: a fraud team wants the validation rules changed, marketing wants a second discount scheme, compliance wants a new audit field, and the email vendor's SDK upgrade touches the send path. Four unrelated teams now queue on one file.
The single responsibility principle gives each module one reason to change. The classic phrasing is 'one class, one responsibility', which invites arguments about what a responsibility is. The productive definition is the one about change: a module has one responsibility when there is exactly one source of change that forces edits to it. The source is usually a stakeholder — the fraud team, marketing, compliance — or an external system like a payment provider. Look at your module's change history and you can usually name the sources directly:
| Change source | What it forces in the god class |
|---|---|
| Fraud team | validation rule edits |
| Marketing | discount logic edits |
| Compliance | audit field edits |
| Email vendor | SDK upgrade in the send path |
| DBA | persistence query changes |
Five sources, one file: this is not merely untidy. It creates three concrete costs. Changes collide — two teams editing the same method produce merge conflicts and review bottlenecks. Testing degrades — exercising the discount logic requires a database and a mail server, so tests get slow and teams write fewer of them. And reasoning degrades — nobody can hold all five concerns in their head, so a change to validation accidentally breaks the audit trail.
The split follows the change sources. Pull each concern behind its own seam:
// Before: one file, five reasons to change
class OrderService {
void placeOrder(OrderRequest req) { /* validate, discount, reserve,
persist, email, audit, log */ }
}
// After: collaborators, one reason each
class OrderService { // orchestration only
private final OrderValidator validator;
private final PricingEngine pricing;
private final InventoryGateway inventory;
private final OrderRepository orders;
private final List<OrderObserver> observers; // email, audit, metrics
Order placeOrder(OrderRequest req) { /* wire the flow */ }
}
class PricingEngine { Money discountFor(Cart c) { ... } } // marketing
interface OrderObserver { void orderPlaced(Order o); } // email/audit
Two properties of the result matter. First, OrderService still owns the workflow — SRP does not mean one method per class; it means the reason to change of the orchestrator is 'the sequence of steps changed', while each collaborator absorbs one other source. Second, the OrderObserver list is where SRP meets extensibility: the email sender and the audit writer implement the same interface, so each evolves without the other knowing — a bridge you will recognize when Open-Closed lands in the next chapter.
A caution, because SOLID is where over-engineering is born: the split is justified by observed change sources, not imagined ones. If no external party has ever touched the discount logic and none plausibly will, extracting a PricingEngine interface with two implementations is a tax with no payer. The test is simple: can you name the second change source? If not, extract the collaborator class but skip the abstraction.
Adding a New Discount Rule Without Touching Checkout Code
Marketing ships a new promotion every month. Without a design principle in place, each one lands the same way: an engineer opens PricingEngine, finds the growing conditional, and adds a branch.
Money discountFor(Cart cart) {
if (coupon.isPresent()) return couponDiscount(cart);
if (isBogoSeason()) return bogoDiscount(cart);
if (cart.total().above(100)) return bulkDiscount(cart); // + a new
// branch per campaign, forever // line per promo
}
Every campaign is now a merge into the pricing module, every release risks the branches that were working, and the pricing module's tests re-run for changes that never logically touched pricing. Open-closed design adds behavior through new code rather than editing existing modules: the pricing module stays closed for modification while remaining open for extension.
The standard fix turns each branch into a module behind a shared interface and moves selection out of the engine:
interface DiscountRule {
boolean appliesTo(Cart cart);
Money discountFor(Cart cart);
int priority();
}
class BulkDiscount implements DiscountRule { /* >100 total */ }
class BogoSeasonRule implements DiscountRule { /* calendar */ }
class PricingEngine {
private final List<DiscountRule> rules; // injected, ordered
Money discountFor(Cart cart) {
return rules.stream()
.filter(r -> r.appliesTo(cart))
.sorted(comparingInt(DiscountRule::priority))
.map(r -> r.discountFor(cart))
.reduce(Money.ZERO, Money::plus);
}
}
Now the February campaign is a new class plus one registration line (or, with a DI container, an annotation), and the engine has not changed at all — its tests still pass untouched, which is the actual reward: a module you must not edit to extend is a module whose regression risk stops at its own test suite. The next campaign cannot break last month's rule because it cannot reach it.
Three honest costs of this design, because OCP is not free. There is indirection: a newcomer tracing 'why did this cart get 10% off?' now walks an interface and a registration list instead of reading one if-chain, so compensating mechanisms (an ordered registry with names, a debug endpoint that lists applied rules with reasons) are part of the pattern, not optional polish. There is composition risk: two rules that both apply may interact (a coupon plus BOGO on the same items), so the engine must define composition semantics — sum, cap, or precedence — explicitly. And there is the over-application trap: if the organization has shipped exactly one discount rule in three years, the interface tax has no payer; an if is the honest implementation.
The interview framing is compact and comes up constantly: 'what does open-closed mean in practice?' Answer with the mechanism — abstractions at the variation points, injection of the variants, behavior added as new modules — and then add the qualifier that shows judgment: the pattern pays only where variation is real and repeated. The next topic's Liskov and interface segregation principles are exactly the constraints that keep these extension interfaces honest. One refinement you will meet in real codebases: the extension point often needs guardrails for exactly the reason the OCP refactor was worth doing — third parties extend freely. Registry validation (reject duplicate priorities, cap applicable rules per cart), a latency budget so one slow rule cannot sink the whole pricing call, and a feature flag per rule give the openness a floor. An extension point without rails is how a pricing change becomes an incident.
Reading SRP and OCP Violations From Git History, Not From Dogma
Principles argued in the abstract end in 'it depends'. Principles read from evidence end in decisions. Both SRP and OCP violations leave fingerprints in the repository, and learning to read them is what lets an architect justify a refactor with data instead of taste.
SRP fingerprints. For each hot file, ask git:
# Which teams/people touch this file? (one file, many sources = split)
git shortlog -sn --since=6.months -- path/to/OrderService.java
# How often is it edited at all? (churn = change pressure)
git log --oneline --since=3.months -- path/to/OrderService.java | wc -l
# Does one commit type cluster? (divergent themes in the log)
git log --format='%h %an %s' -- path/to/OrderService.java | head -30
A file edited 30 times in a quarter by five authors from four teams is a responsibility cluster wearing a filename. When the commit subjects alternate between 'fix fraud check', 'add spring promo', 'audit field', and 'upgrade mail SDK', you are looking at five sources of change in one artifact — the exact SRP definition, measurable. This evidence does more than justify the split; it tells you the seam lines, because the commit themes name the collaborators.
OCP fingerprints. An OCP violation shows up as recurring edits to a module that should be finished:
| Signal | What it means |
|---|---|
| The same conditional extended in 3+ commits by 3+ authors | Variation point without an extension seam |
| A module's tests edited whenever a feature ships elsewhere | Behavior you must modify to extend |
| Merge conflicts in a 'stable' pricing or routing file | Two extensions colliding because there is nowhere to put them |
switch/if ladders over a type code added per feature |
The classic closed-for-modification failure |
The architectural move is to time the refactor against the evidence. One new branch per quarter in a two-year-old module is fine — that is maintenance. The third branch in two months is the signal to extract the extension point, because you are past coincidence and into a pattern. A useful rule of thumb, borrowed from the refactorings literature: add the seam on the second variation, extract the abstraction on the third, when the common interface is visible from real examples rather than imagined from one.
And keep the reverse case armed, because interviews also probe over-application. If the churn data shows a file touched twice in two years by the same author, leave it alone. If an OCP refactor would introduce a four-level class hierarchy to serve one caller, the honest design is the conditional with a comment explaining why it is still a conditional. Principles are heuristics backed by cost, and the cost side is what separates an architect from a doctrine reciter: the question is never 'does this violate SRP?' — it is 'what does the violation cost here, what does the split cost here, and does the arithmetic pay?' When a boundary does need justification beyond your team, the formal write-up lives in the same decision-record habit described in The Role of the Architect.
Quiz
1. `OrderService.placeOrder()` validates input, computes discounts, persists the order, and sends email. Under the 'reasons to change' definition of SRP, why is this a violation?
2. After extracting `DiscountRule` implementations into the pricing engine, what is the primary regression benefit of open-closed design?
3. Git evidence shows a pricing module gained its fourth discount branch in two months, from four different authors, with merge conflicts in two of the commits. What is the justified move?
4. A module has shipped exactly one behavior variant in three years and is edited twice per year by one author. An engineer proposes an OCP refactor with a plugin interface. What is the correct architectural response?
Flashcards
Question
State the single responsibility principle in the 'reasons to change' form and explain why this form is more useful than 'one class, one responsibility'.
Click to reveal answer
Answer
A module should have exactly one source of change (a stakeholder, an external system) that forces edits to it. The change-source form is actionable because you can measure it — `git shortlog` on a file shows how many teams touch it and the commit themes name the seams — while 'one responsibility' invites unresolvable arguments about what a responsibility is.
Question
What are the three concrete costs of a multi-responsibility class, and why is the first one an organizational problem before it is a technical one?
Click to reveal answer
Answer
Change collisions (merge conflicts and review queues as multiple teams edit one file), degraded testing (exercising one concern drags in database and mail infrastructure, so tests slow and shrink), and degraded reasoning (nobody holds all concerns, so changes break neighbors). Collisions are organizational first: they make separate teams serially dependent on one file's release cadence.
Question
What does open-closed design mean mechanically, and what does it actually buy?
Click to reveal answer
Answer
Variation points sit behind abstractions; each new behavior is a new module registered via constructor/DI rather than an edit to the stable engine. The buy is containment: a module that cannot be edited to extend it has regression risk bounded by its own unchanged test suite — next campaign cannot break last month's rule because it cannot reach it.
Question
What two costs of open-closed design must be designed for, and what mechanism addresses composition risk?
Click to reveal answer
Answer
Indirection (tracing behavior now crosses interfaces and registries — compensate with named ordered registries or a debug endpoint listing applied rules) and composition risk (two applicable rules interacting, e.g., coupon plus BOGO on the same items). Composition needs explicit semantics — sum, cap, or priority precedence — defined in the engine, not left to arrival order.
Question
What is the evidence-driven timing rule for extracting SRP/OCP seams?
Click to reveal answer
Answer
Add the seam on the second observed variation and extract the full abstraction on the third, when a real common interface is visible from examples. Inputs are git evidence: churn count, distinct authors/teams per file, commit-theme clustering, and merge conflicts in 'stable' files. One variant or trickle churn means the conditional stays.
Revision Notes
Key Takeaways
- 1. SRP is measured, not debated: one source of change per module, evidenced by git authorship, churn, and commit-theme clustering in the hot files
- 2. A god class serializes unrelated teams, drags infrastructure into unit tests, and hides interactions — the split follows the change sources while the orchestrator keeps the workflow
- 3. Open-closed works by putting abstractions at variation points and injecting variants, so each extension is new code and regression risk stops at the unchanged module
- 4. OCP costs are real — indirection for readers and rule-composition ambiguity — so named registries and explicit composition semantics are part of the pattern
- 5. Timing is everything: extract on the second variation, abstract on the third, and decline when no second change source exists; principles are cost arithmetic, not doctrine
Interview Tips
- • 'What is the single responsibility principle?' — give the reasons-to-change definition, then the measurement: git shortlog shows a file touched by four teams, which is the violation made objective
- • 'Show open-closed in practice' — walk the discount-rule example: interface, variants injected into the engine, new campaign as a new class; then name the containment benefit (unchanged test suite)
- • 'When would you NOT apply open-closed?' — one variant, no second change source, indirection tax with no payer; being able to decline the pattern separates architects from doctrine reciters
- • 'How do you justify the refactor cost to management?' — evidence: N commits/month by M teams, conflicts in review, test runtime; then the seam count and the rule of thumb (2nd variation seam, 3rd abstraction)
Cheat Sheet
Single Responsibility and Open-Closed
SRP = one source of change per module. Measure it:
git shortlog -sn --since=6.months -- path/File.java # many authors = split
git log --oneline --since=3.months -- path/File.java | wc -l # churn
git log --format='%h %an %s' -- path/File.java | head -30 # themes
| Cost of violation | Mechanism |
|---|---|
| Change collisions | teams queue on one file; merge conflicts |
| Degraded testing | unit test needs DB + mail server → slow, few tests |
| Degraded reasoning | change in A breaks unseen B |
Split follows change sources; the orchestrator keeps the workflow ('the sequence changed' is its one reason). Skip the abstraction when no second change source can be named.
OCP = extend with new modules, never edits:
interface DiscountRule { boolean appliesTo(Cart c);
Money discountFor(Cart c); int priority(); }
class PricingEngine { List<DiscountRule> rules; /* filter+sort+reduce */ }
New campaign = new class + one registration line; engine and its tests untouched.
Timing rule: seam on 2nd variation, abstraction on 3rd. Watch: 3+ branches in 2 months, 3+ authors, conflicts in stable files → pattern, extract now.
Decline OCP when there is one variant in three years — the indirection tax has no payer; keep the conditional.