SOLID, refactoring & code quality at scale¶
At a glance
Priority: P1 · Complexity: ⅖ · Est. time: 2 h · Phase: 1 · Prereqs: Design patterns You're done when: you can explain each SOLID principle with a violation and fix in Python, describe how the principles scale up to modules and services, run a safe refactoring (characterisation tests first) on a legacy function, and define code-quality guardrails for a multi-team codebase, including for AI-generated code.
Why it matters¶
SOLID is the most quoted and least understood set of design principles. Used dogmatically, it produces interface explosions; used as diagnostic heuristics, it explains why code is hard to change. At Staff level the useful question is: how do these principles scale from classes to modules to services? (SRP becomes bounded contexts; DIP becomes ports & adapters; ISP becomes consumer-driven contracts; OCP becomes plugin architectures and event-driven extension.)
Code quality at scale is also an organisational problem: without shared standards, automated checks and a refactoring culture, 50 engineers plus coding agents produce entropy faster than anyone can review it. In 2026 with AI-generated code, the review bottleneck and "plausible but wrong" code make guardrails (types, tests, linters, architecture tests) more important than ever.
Core concepts¶
SOLID as change-diagnosis heuristics¶
| Principle | Better phrasing | Smell | Fix | Scaled-up form |
|---|---|---|---|---|
| SRP | A module should have one reason to change (one actor/stakeholder) | Class edited for unrelated reasons; "and" in its name | Split by axis of change | Bounded context per team/domain |
| OCP | Add behaviour by adding code, not editing stable code | if/elif on type edited for each new variant |
Polymorphism, registry, plugin | Plugin/microkernel architecture; event subscribers |
| LSP | Subtypes must honour the base contract | raise NotImplementedError in override; isinstance checks |
Fix hierarchy; use composition/protocol | API compatibility: new version must accept old clients |
| ISP | Clients shouldn't depend on methods they don't use | Fat interface, mocks with 15 stubs | Role interfaces / small Protocols | Consumer-driven contracts; narrow tool schemas for agents |
| DIP | Depend on abstractions owned by the high-level policy | Domain imports SDK/ORM | Port + adapter, injection | Hexagonal architecture (hexagonal) |
# OCP + DIP: adding a new carrier doesn't touch BookingService
from typing import Protocol
class CarrierGateway(Protocol):
def quote(self, origin: str, dest: str, teu: int) -> float: ...
class BookingService:
def __init__(self, carriers: dict[str, CarrierGateway]):
self.carriers = carriers
def best_quote(self, origin, dest, teu) -> tuple[str, float]:
return min(((n, c.quote(origin, dest, teu)) for n, c in self.carriers.items()),
key=lambda t: t[1])
# LSP violation (classic): a subtype that strengthens preconditions
class Order:
def cancel(self): self.status = "CANCELLED"
class ShippedOrder(Order):
def cancel(self): raise RuntimeError("cannot cancel shipped order") # clients of Order break
# Fix: model cancellability explicitly — order.can_cancel(), or return a Result, or state pattern
Beyond SOLID: the principles that matter in practice¶
- Tell, don't ask / Law of Demeter: avoid
a.b().c().d(); ask the object to do it. - Composition over inheritance.
- Separation of concerns and Cohesion — see coupling & modularity.
- Simple Design (Beck): passes tests, reveals intention, no duplication, fewest elements — in that order.
- YAGNI / rule of three vs premature abstraction; Wrong abstraction is costlier than duplication (Sandi Metz).
- Functional core, imperative shell: pure decision logic, side effects at the edges — the easiest way to testable code and works in Python/Java alike.
- Make illegal states unrepresentable: types (enums, sealed types, NewType, Pydantic) encode invariants.
Refactoring: safe, small, continuous¶
Refactoring = changing structure without changing behaviour, in small steps, with tests as the safety net (Fowler, 2nd ed.).
Workflow for legacy code (Feathers, Working Effectively with Legacy Code):
- Find a seam — a place to alter behaviour without editing (dependency injection, subclass, parameter, function pointer).
- Characterisation tests: write tests that capture current behaviour (even if wrong) — golden master / approval tests.
- Break dependencies minimally (extract interface, parameterise constructor, sprout method/class).
- Refactor in tiny steps; run tests after each; commit frequently.
- Then change behaviour (make the change easy, then make the easy change — Beck).
flowchart LR
A[Legacy code without tests] --> B[Find seam]
B --> C[Characterisation / approval tests]
C --> D[Small refactorings<br/>extract, rename, move]
D --> E{Tests green?}
E -- no --> D2[Revert step] --> D
E -- yes --> F[Commit] --> G{Design now allows the change?}
G -- no --> D
G -- yes --> H[Make the behaviour change]
High-value refactorings: Extract Function, Rename, Introduce Parameter Object, Replace Conditional with Polymorphism, Replace Primitive with Object, Extract Class, Move Function, Introduce Seam, Replace Temp with Query. Code smells that justify them: long function, large class, shotgun surgery, divergent change, feature envy, primitive obsession, data clumps, speculative generality, message chains.
Tidy First? (Beck): small structural tidyings are separate from behaviour changes — separate PRs/commits; tidy before, after, later, or never depending on economics (cost of tidying vs benefit to upcoming changes).
Code quality at scale: mechanisms, not exhortation¶
| Layer | Mechanism | Tools (Python / Java) |
|---|---|---|
| Style/lint | Automated, non-negotiable, no debates | ruff, formatters / Spotless, Checkstyle |
| Types | Gradual → strict on new code; public APIs typed | mypy/pyright/ty / Java's type system, JSpecify null-safety |
| Tests | Test pyramid, fast unit tests, contract tests; mutation testing on core | pytest, Hypothesis / JUnit 5, Testcontainers, PIT |
| Architecture tests | Enforce layering and dependency rules | import-linter / ArchUnit, jMolecules |
| Complexity gates | Cyclomatic/cognitive complexity thresholds on new code | ruff C901, radon / Sonar |
| Review | Small PRs, checklists, codeowners, trunk-based dev | CODEOWNERS, PR templates |
| Debt tracking | Explicit, ratcheted metrics; "boy scout" rule | SonarQube new-code gates, ratchet scripts |
| Docs-as-code | ADRs, C4, README-per-module | see ADRs |
The ratchet: don't demand fixing all legacy violations; fail the build if the count of violations (mypy errors, complexity hot spots, banned imports) increases. Progress is monotonic without a big-bang cleanup.
AI-generated code and quality¶
- Coding agents optimise for "it works" in the visible context: expect duplicated helpers, over-broad exception handling, missing edge cases, and boundary violations.
- Make the rules machine-checkable (import-linter contracts, typed interfaces, tests) and put conventions in
AGENTS.mdso agents get them in context. - Review shifts from syntax to intent and design: does this belong here? What invariants does it assume? Is there a test that fails without the change?
- Small PRs still matter — agent-produced 2,000-line diffs are unreviewable; enforce size limits and stacked PRs.
- Use agents for refactoring: they're strong at mechanical transformations when a strong test suite exists (characterisation tests first).
Senior-level nuance¶
- SOLID principles often conflict with each other and with YAGNI. Decide by the cost of change actually observed.
- Interfaces in Python: prefer
Protocol(structural) at boundaries; ABCs when you want enforced registration; plain functions where behaviour is a single operation. - Cohesion first, then coupling. Most SRP violations are cohesion problems: code that changes for different reasons sits together.
- "Clean code" isn't a goal; cost of change is. A 300-line function that is stable, tested and understood may be cheaper left alone.
Resources¶
| Resource | Type | Why this one | Level | Cost |
|---|---|---|---|---|
| Refactoring, 2nd ed. (Martin Fowler) / online catalog | book/docs | The catalog with mechanics for each refactoring | intermediate | paid/free |
| Refactoring.Guru — Code smells | docs | Visual, well-organised smell catalogue linking smells to refactorings | intermediate | free |
| Working Effectively with Legacy Code (Feathers) | book | Seams and characterisation tests: the essential legacy-code technique | intermediate | paid |
| Understand Legacy Code |
article | Practical, modern techniques (approval tests, mikado method, refactoring in Java/JS) | intermediate | free |
| Tidy First? (Kent Beck) | newsletter | Economics of small structural improvements | intermediate | freemium |
| A Philosophy of Software Design (Ousterhout) |
book | Deep modules, complexity budget — the strongest counterweight to over-abstraction | advanced | paid |
| Cosmic Python | book | SOLID/DIP applied to real Python services | intermediate | free |
| import-linter | docs | Make layering rules part of CI | intermediate | free |
| ArchUnit | docs | Java equivalent: test your architecture like code | intermediate | free |
Hands-on lab¶
Goal: refactor a legacy function safely. 60–90 min.
- Take a 150-line function mixing parsing, pricing rules, DB writes and notification (write a synthetic one or find one in your repo).
- Write characterisation tests using approval/golden-master style: feed 20 recorded inputs, snapshot outputs.
- Introduce seams: inject the DB and notifier as parameters/Protocols.
- Apply Extract Function/Class in ≤ 10 tiny commits; keep tests green after each.
- Replace primitive obsession with two value objects; replace the type
if/elifwith a registry (OCP). - Add a complexity gate and an import-linter contract; set up a ratchet script (fail if violations increase).
- Ask a coding agent to do steps 3–5 given the tests; review its diff for boundary violations and note what you had to correct.
Expected output: commit history showing small green steps, before/after complexity numbers, and a note on agent performance.
Questions¶
L1 — Recall¶
Q1. State SRP precisely, and why 'a class should do one thing' is a poor formulation.
Answer
SRP: a module should be responsible to one, and only one, actor — it should have one reason to change. "Do one thing" invites arbitrary splitting into tiny classes; the real signal is reasons to change driven by different stakeholders (e.g. pricing logic and report formatting change for different people, so they shouldn't share a class).
Q2. What is the Liskov Substitution Principle in practical terms?
Answer
Code using a base type must work correctly with any subtype without knowing which: subtypes may not strengthen preconditions, weaken postconditions or break invariants. Practical smells: overrides that raise NotImplementedError, isinstance checks in client code, subclasses that ignore inherited behaviour.
Q3. What is a characterisation test?
Answer
A test that captures the current observable behaviour of existing code (right or wrong) to serve as a safety net before refactoring. Created by running the code with representative inputs and recording outputs (golden master / approval tests) — it documents what the system does, not what it should do.
Q4. List five code smells and the refactoring associated with each.
Answer
Long function → Extract Function. Large class/divergent change → Extract Class. Primitive obsession → Replace Primitive with Object (value objects). Feature envy → Move Function. Switch/if-chains on type → Replace Conditional with Polymorphism. Also: data clumps → Introduce Parameter Object; shotgun surgery → Move/Inline to consolidate.
L2 — Apply¶
Q5. Refactor this: def price(o): if o.type == 'FCL': ... elif o.type == 'LCL': ... elif o.type == 'REEFER': ... used in 6 places.
Answer
Introduce a PricingStrategy protocol (price(order)) with FclPricing, LclPricing, ReeferPricing; a registry { 'FCL': FclPricing(), ...} chosen once at the edge (factory from order type) and injected or attached to the order/aggregate. Steps: write characterisation tests for each branch; extract each branch into a function; move into classes/callables; replace call sites one by one. Adding a type becomes adding a class + registration. If the six places all vary differently, consider a sealed type/enum + a single dispatch function instead.
Q6. You must add a feature to a 3,000-line untested class next week. What's your plan?
Answer
Don't refactor everything. (1) Identify the change points; (2) find/introduce a seam around them (extract the method you need to change into a new class — sprout class — with tests); (3) write characterisation tests around the touched behaviour at the highest available level (API/golden master); (4) implement the feature test-first in the new unit, calling it from the legacy class; (5) tidy only what you touched (boy scout); (6) add the class to a ratchet list for future cleanup. Communicate the risk and the debt entry.
Q7. Configure a ratchet for mypy strictness on a large codebase with 4,000 existing errors.
Answer
Options: enable strict mode only for a growing allowlist of packages ([[tool.mypy.overrides]] with strict = true for modules already clean, and new modules by default), plus a baseline file (e.g. mypy baseline tooling or store the error count per module and fail CI if it increases). Require all new files to be strictly typed. Track the number over time on a dashboard; assign owners per package. Avoid blanket # type: ignore — use specific ignores with reasons.
L3 — Design & trade-offs¶
Q8. Is DIP always worth applying? Give a rule of thumb for when to invert dependencies.
Answer
Invert where the dependency is volatile (vendors, LLM providers, storage engines), expensive to test (network, DB, time), or crosses a team/module boundary. Don't invert stable, cheap, in-process dependencies (standard library, pure utility functions) or things with one implementation in a small CRUD service. Cost of DIP: extra interfaces, wiring and mapping. Benefit: testability and replaceability. See hexagonal.
Q9. Duplication vs abstraction: two teams each have similar 60-line validation code. Share or not?
Answer
Determine if they change for the same reason (same rule from the same business owner → share, with one owner) or merely look alike (different rules that may diverge → keep separate; the wrong abstraction is costlier than duplication). If sharing, pick the weakest coupling: a service/API for volatile business rules, a versioned library only for stable technical validation. If two teams must negotiate every change, the shared code is already too expensive.
Q10. Design the quality gates for a monorepo with 8 teams and heavy coding-agent usage.
Answer
Pre-commit and CI: formatter and linter (ruff) with zero-tolerance; type checking strict on new code with ratchet; tests with coverage on changed lines and mutation tests on core packages; import-linter/ArchUnit for module boundaries and forbidden dependencies (e.g. no SDK imports in domain); dependency and secret scanning; PR size limit and required CODEOWNERS review for cross-module changes; AGENTS.md with conventions plus a "definition of done" checklist agents must follow; contract tests for service APIs; performance smoke tests for hot paths. Measure: change failure rate, revert rate, review turnaround, agent-PR acceptance rate. Rely on automation over reviewer vigilance.
L4 — Staff-level ambiguity¶
Q11. The codebase is 'a mess', morale is low, and leadership wants features. How do you make quality investment credible without a rewrite?
Answer
Tie quality to delivery outcomes: identify the 3 hottest files/modules by change frequency × defect density (CodeScene-style hotspots) — the 20% that causes 80% of pain. Propose targeted refactoring inside feature work (make-the-change-easy) with an agreed 10–20% capacity allowance, ratchets to prevent regression, and a visible dashboard (lead time, bug rate in hotspots). Pick one win to demonstrate (e.g. cut hotspot cycle time in half). Reject a rewrite: use strangler-style replacement for modules that are irredeemable (legacy modernization). Get leadership to view debt like financial debt: interest paid (slower delivery) vs principal.
Q12. Engineers complain that AI-generated PRs are exhausting to review and quality is drifting. What changes do you propose?
Answer
Shift verification left and automate: require tests demonstrating the behaviour (fails before, passes after); enforce architecture tests and types in CI so agents get immediate feedback; keep PRs small (stacked); put standards in AGENTS.md and reusable skills so agents follow conventions; have agents self-review against a checklist and produce a summary of design decisions; reviewers focus on design and invariants ("does this belong here?"), not style. Track AI-attributed defect and rework rates to calibrate. Provide guidance on where agents are strong (mechanical refactors with tests, boilerplate) and weak (cross-cutting design, security-sensitive code). Pilot with one team, iterate, then scale.
Real-world use cases¶
- Plugin architectures (OCP): rules engines, IDEs, payment methods added without touching core.
- Legacy insurance/banking cores: characterisation tests + seams to make change safe.
- Google-style large-scale changes: automated refactoring tools (codemods) with strong tests across monorepos.
- Ratchets in large Python codebases (Dropbox, Instagram-style typing adoption): progressive strictness.
- AI-assisted refactoring: agents performing mechanical migrations (API upgrades) behind a test suite.
Pitfalls & anti-patterns¶
- SOLID as dogma: interface-per-class, abstraction with one implementation.
- Refactoring without tests, or mixing refactor and behaviour changes in one commit.
- Big-bang rewrites of "messy" code.
- Style debates instead of automated formatters.
- Quality initiatives with no owner or metrics.
- Coverage targets gamed with assertion-free tests.
- Trusting AI-generated changes without behavioural tests.
Checklist¶
- I can explain SOLID as change heuristics and their scaled-up architectural forms without notes
- I wrote characterisation tests and refactored in small green steps
- I set up a ratchet and one architecture test in CI
- I can describe quality guardrails for AI-generated code
- I answered all L3 questions out loud in < 3 min each