When does applying SOLID make a codebase worse? How do you decide whether a given piece of code needs an abstraction or should stay concrete?
answer
- abstraction = a bet on an axis of change
- wrong abstraction costs more than duplication
- one-impl interfaces, factories of factories
- rule of three + churn/change-history evidence
- invert volatile boundaries only; reversibility matters
basics
~20 sWhen the abstraction guesses wrong or is never needed: interfaces with one implementation forever, factories wrapping factories, layers you must step through to read simple logic. Add the abstraction when a second real variant appears, not before.
solid answer
~50 sSOLID buys flexibility along a chosen axis, and every abstraction costs indirection, navigation and code you must maintain. It makes things worse when the axis is guessed and guessed wrong, because callers then grow flags and workarounds to bend the wrong abstraction - Sandi Metz's 'duplication is far cheaper than the wrong abstraction'. Symptoms of over-application: one-implementation interfaces that never gain a second, factories creating factories, a five-file trail to read one behaviour, hexagonal ports for a CRUD screen, and tests that assert on mocks rather than behaviour. Symptoms of the opposite failure - rigidity - are shotgun surgery, untestable code with hard-coded infrastructure, and merge conflicts across teams. Decision heuristics: rule of three, evidence from change history (which files actually churn together), invert only across volatile boundaries (I/O, clock, external services), prefer discovering seams by refactoring after the change arrives, and weigh reversibility - abstractions at published boundaries are expensive to undo, internal ones are cheap.
code
pseudocode · 9 lines// Speculative: interface + factory for something with exactly one implementation
interface PricingStrategy { fun price(o: Order): Money }
class DefaultPricingStrategy : PricingStrategy { /* only impl, 3 years running */ }
class PricingStrategyFactory { fun create() = DefaultPricingStrategy() }
// Evidence-based: stays concrete until a second real variant appears
class Pricing { fun price(o: Order): Money { /* ... */ } }
// ...later, a second market with different rules arrives ->
// extract the interface THEN, guided by how the two actually differgo deeper
Say that abstractions cost extra files and indirection, that interfaces with one implementation are a warning sign, and that you add the abstraction when a second real case appears.
Contrast both failure modes concretely (speculative generality vs shotgun surgery), and give the rule of three plus 'invert volatile boundaries like clock, network, database'.
Add the wrong-abstraction argument (flags accreting on shared code), use change history/hotspots as evidence, and discuss reversibility and published-boundary asymmetry.
Frame it as capital allocation under uncertainty: spend design budget where change is measured and where decisions are hard to reverse, keep internal seams cheap and deletable, and set team norms (review questions, hotspot-driven refactoring) rather than blanket rules.
## The real trade-off Every abstraction is a **bet**: you pay indirection now to make one specific future change cheap. The costs are real and mostly invisible in review: - **Navigation cost** - reading one behaviour requires opening N files; 'go to implementation' resolves to an interface. - **Cognitive load** - names must be understood before behaviour can be traced. - **Maintenance surface** - each interface, factory and wiring line is code that can rot. - **Debugging cost** - stack traces run through dispatch layers; dynamic wiring hides which implementation ran. - **Test damage** - mock-heavy tests coupled to the abstraction assert on interactions, not outcomes, and break on refactoring while missing real bugs. The payoff arrives only if the anticipated change actually happens *along the axis you chose*. ## Failure mode A - over-engineering (speculative generality) Recognisable patterns: - Interfaces with exactly one implementation, and no second one after years - especially `FooService` / `FooServiceImpl` naming, which signals the interface exists for ceremony (or for a mocking library) rather than for a real second implementation. - Abstract base classes with a single subclass. - Factories, builders and providers around objects with a single construction path. - Configuration/strategy hooks nobody configures. - Ports-and-adapters layering over a straightforward CRUD form. - 'Generic' code with type parameters used at exactly one type. The deepest cost is the **wrong abstraction**: once several call sites depend on it, they add boolean flags and conditional parameters to squeeze their case through. Metz's advice is to inline the abstraction back to duplication, then re-extract along the seam the real requirements revealed. ## Failure mode B - rigidity (under-application) Equally real, and the reason SOLID exists: - Shotgun surgery: one logical change touches many files. - A class that cannot be instantiated in a test without a database, network or clock. - Type-code conditionals duplicated across the codebase. - Long, tangled classes that several teams edit simultaneously. - Fear-driven development: nobody changes a module because the blast radius is unknown. ## Deciding: heuristics that actually work 1. **Rule of three.** Write it concrete. Duplicate once. On the third occurrence, extract - by then you can see what actually varies. 2. **Use change history as evidence.** Files that churn together, and hotspots with high change frequency, mark the real axes of variation. This beats architectural intuition because it is measured. 3. **Invert across volatile boundaries only.** Time, randomness, network, filesystem, third-party services, anything you must fake in a test. Do not invert stable dependencies (standard collections, a maths library) - the interface buys nothing. 4. **Prefer a testability reason over a flexibility reason.** 'I need a seam to test this' is evidence today; 'we might swap the database' is usually a guess that never pays out. 5. **Ask who owns the change.** If a different team or an external plugin author must extend the code without editing it, OCP earns its keep. Within one team's file, a switch is fine. 6. **Weigh reversibility.** An internal abstraction is cheap to remove; one exposed in a published API or across a service boundary is expensive - be stricter there and looser inside. 7. **Prefer simple mechanisms first.** A data table, a function parameter or a higher-order function often gives the same flexibility as a class hierarchy at a fraction of the ceremony. 8. **Match the code's expected lifetime.** A one-off migration script and a payments core deserve different budgets. ## Answering the 'when is it worse' question well A strong answer says: the failure is not 'too much SOLID' but *abstraction without evidence*. Both directions are expensive; the discipline is to let the third occurrence, the failing test, or the actual change request tell you where the seam belongs - and to be willing to delete an abstraction that turned out to be wrong, rather than layering flags onto it.
- You inherit a codebase with interfaces that all have exactly one implementation. What do you do?Nothing wholesale - churn for its own sake is a cost too. Judge case by case: keep the interface where it is a genuine seam (external I/O, a published boundary, a place a second implementation is imminent); inline it where it exists only for ceremony and the file is being actively worked on anyway. Prioritise by hotspot: only pay refactoring cost in code that is actually changing.
- How do you justify skipping an abstraction to a reviewer who cites SOLID?Ask what change the abstraction is closing against, and whether that change has been requested or observed. If it hasn't, note the concrete costs (extra indirection, mock-based tests, navigation) and point out that the seam is cheap to add later because the code is internal and reversible. If the boundary is published or cross-team, that argument weakens and the reviewer is probably right.
- What is the practical difference between duplication and the wrong abstraction?Duplicated code is easy to find and change - the cost is linear and local. A wrong abstraction is shared, so every caller that does not quite fit adds a flag or a special case; the cost is superlinear and spread across callers, and each workaround makes it harder to see the real seam. That asymmetry is why premature extraction is riskier than a little duplication.
Adding an abstraction is like installing extra plumbing junctions in a house for a bathroom you might build later. One or two at likely spots is prudent; a junction every metre makes the walls unmaintainable and still misses the place the bathroom actually goes.
saying these in an interview costs you the question
- Claiming more SOLID is always better, with no cost side to the trade.
- Treating YAGNI and SOLID as opposites rather than as a budget question about evidence.
- Adding an interface purely so a mocking library can stub it, then calling it dependency inversion.
- Never abstracting anything and calling all indirection over-engineering - rigidity is the more common and more expensive failure in long-lived systems.
- Refusing to delete an abstraction that turned out wrong, and adding boolean parameters to make it fit instead.