Walk through how you would split a class that answers to multiple stakeholders into single-responsibility pieces without breaking its callers, and explain what that split changes about testability.
answer
- characterization tests first, then extract
- group methods by fields + collaborators + requester
- old class becomes delegating facade
- duplicate coincidental helpers, share real rules
- split moves risk from units to the wiring — test composition
basics
~20 sAdd characterization tests first, extract each responsibility into its own class, keep the original class as a thin wrapper that delegates so callers keep compiling, migrate callers gradually, then shrink or delete the wrapper. Each new piece is now testable alone.
solid answer
~50 sSequence: (1) pin current behavior with characterization tests so refactoring is verifiable; (2) identify seams by grouping methods per actor; (3) extract each group into its own type, moving the fields it exclusively uses with it; (4) keep the original class as a facade delegating to the extracted parts so no caller breaks — this makes the change incremental and reviewable; (5) migrate callers to the new types, ideally by feature area; (6) shrink the facade to the genuinely shared coordination or delete it. Duplicate any shared private helper if the two actors' rules are only coincidentally identical. Testability improves because each piece needs fewer collaborators: setup shrinks, tests target one rule rather than an orchestration, failures point to a single unit, and slow dependencies (I/O, clock, network) get isolated behind their own component instead of contaminating every test of the business rules.
code
pseudocode · 18 lines// Before: three actors in one class
class Employee {
calculatePay() // finance
reportHours() // HR
save() // DBA
}
// After: extracted parts + facade so callers still compile
class PayCalculator { calculate(employeeData) }
class HoursReporter { report(employeeData) }
class EmployeeRepository { save(employeeData) }
class Employee { // temporary facade
calculatePay() -> payCalculator.calculate(this.data)
reportHours() -> hoursReporter.report(this.data)
save() -> repository.save(this.data)
}
// Then migrate callers to the three types and delete the facade.go deeper
Describe the mechanical steps: write tests, extract classes, delegate from the old class so callers still work.
Add how you choose the seams (fields, collaborators, requester) and name the testability wins: fewer fakes, faster tests, clearer failures.
Emphasize characterization tests, the facade-then-migrate incremental path, the DRY-versus-coupling judgment on shared helpers, and the combinatorial reduction in test cases.
Discuss sequencing across teams and releases, risk moving from units to seams (so contract/integration testing must grow), when to stop splitting, and how to prevent the facade from re-accumulating logic.
## Step 0 — decide it is worth it Refactoring has cost and risk. Split when the class is *changed often by multiple streams*. A stable god class is not the highest-value target. ## Step 1 — pin behavior with characterization tests A **characterization test** (also "golden master" / approval test) asserts what the code *currently does*, not what it should do. You write it before refactoring so any behavior drift is caught. For a legacy class with awkward dependencies, wide-grained tests at the class's public surface are acceptable and even preferable — they survive the internal restructuring you are about to do. ## Step 2 — find the seams Group methods by: - the fields they touch (disjoint field sets → candidate boundary), - the collaborators they need (a group that touches only the database is a persistence concern), - **who requests changes** to them (the SRP criterion — the deciding vote). Name each group with a precise noun phrase. If you cannot, the seam is not real yet. ## Step 3 — extract For each group, create a new type and **move the fields it exclusively owns along with it**. Where a field is genuinely shared, either pass it as a parameter or promote it into a value object both parties use. A shared private helper needs a judgment call: - If both actors depend on the *same rule* (one piece of knowledge), extract it as its own explicit concept both use. - If the rule merely happens to look the same today for two actors, **duplicate it**. Coincidental duplication is cheaper than coupling two independent change streams. This is the well-known tension where DRY, naively applied, creates the coupling SRP is trying to remove. ## Step 4 — keep a facade so nothing breaks Leave the original class in place, but let its methods delegate to the new components. Callers keep compiling and behavior is unchanged, so the extraction ships as a low-risk, reviewable commit on its own. This is the standard incremental path; the shape resembles the **Strangler Fig** approach at a smaller scale — the new structure grows inside the old surface until the old surface is empty. ## Step 5 — migrate callers, then shrink the facade Move callers to the new types, usually feature area by feature area. When a caller only needed pricing, it should now depend on the pricing type alone — that is where the coupling reduction is actually realized. Finally shrink the facade to whatever coordination genuinely remains, or delete it. A facade left forever is not a failure if it is a legitimate convenience entry point; it becomes a failure when it re-accumulates logic. ## What the split changes about testability 1. **Fewer collaborators per test.** Testing a pricing rule no longer requires a database or an email gateway fake. 2. **Faster, more deterministic tests.** I/O, the clock, randomness, and network calls concentrate in one component; the rule components become pure and can be tested exhaustively (including property-based or table-driven tests). 3. **Sharper failure localization.** A failing test names one responsibility instead of "the big class broke". 4. **Fewer tests overall for the same coverage.** Combinatorics: a class mixing 3 pricing branches with 4 persistence branches invites 12 scenarios; split, you need 3 + 4 plus a thin integration test of the wiring. 5. **Less test churn.** Previously, changing persistence broke pricing tests because setup was shared. Caveat: you now need a small number of tests for the **composition** — that the facade or orchestrator wires the parts in the right order. Splitting moves risk from "inside a unit" to "between units", and integration/contract tests must cover that seam. Teams that split aggressively and test only units get high coverage with real bugs living in the wiring. ## Anti-patterns during the split - **Layer-only split** (Validator/Mapper/Repository) that leaves every business change touching all pieces — no change isolation gained. - **Anemic extraction**: pulling data into dumb structures and logic into "services", producing procedural code wearing OO clothes. - **Big-bang rewrite** instead of facade-then-migrate; hard to review, hard to roll back. - **Splitting without tests first**, so nobody can prove behavior was preserved.
- Two of the extracted classes both need the same private helper. Share it or duplicate it?Depends on whether it encodes one piece of knowledge or two that coincidentally match. If both actors must always agree (one rule), extract it as an explicit shared concept. If they could diverge on either actor's request, duplicate it — coincidental duplication is cheaper than re-coupling two change streams.
- Coverage stayed at 90% after the split but a production bug appeared in how the parts are combined. What did the test strategy miss?Composition. Splitting relocates risk from inside a unit to the seams between units, so unit tests alone cannot see it. Add integration or contract tests for the orchestrator/facade path, and assert the ordering and data hand-offs between components.