There is a kind of code that gets sold as “SOLID code” and is, in practice, a maze. One-method classes, interfaces with a single implementation, factories that only forward parameters, layers that exist solely to justify the next layer. You open the repo to fix a small bug and end up following the flow across seven files.

That is not SOLID done well. It is SOLID applied without pain. The principles were written to solve concrete problems of maintenance, extension, and testability. When you apply all of them, all the time, without any of those problems showing up, the result tends to be the opposite of what the principles exist to protect.

The thesis of this post is simple: SOLID is a set of diagnostics, not a recipe. Apply each principle when the matching pain already exists, not before.

The mistake is not knowing SOLID. It is applying it preemptively

The five principles that make up SOLID — single responsibility, open/closed, Liskov substitution, interface segregation, dependency inversion — came out of observations about code that aged badly. Classes that grew too large, hierarchies that broke on the first new subclass, modules that forced a full rebuild to change one rule, fat interfaces that forced empty implementations, direct coupling to infrastructure details that made testing painful.

Each principle is a response to a symptom. Not to an aesthetic.

When a team applies SOLID “on paper”, the flow usually runs backwards: the interface shows up before a second implementation exists, the class is split before it has a second real responsibility, dependencies are injected before anything needs to be swapped in tests. It is defensive engineering against problems that have not appeared yet, paid for with complexity that already has.

SRP: useful as a question, harmful as an automatic knife

The Single Responsibility Principle works well as a question: does this class have more than one reason to change? If the honest answer is yes, splitting tends to pay off. If it is no, splitting only produces two files that exist because someone believed “big class is bad”.

The real symptom SRP solves is specific:

  • the same class changes every time a business rule changes AND every time an output format changes
  • different teams touch the same file for different reasons
  • testing one part of the class requires standing up the whole world around it

Without those symptoms, breaking OrderProcessor into OrderValidator, OrderCalculator, OrderPersister, OrderNotifier, and OrderLogger just spreads the same logic across five files. The coupling did not disappear, it moved. And now nobody can find the flow.

The operational rule is more conservative than the popular version of the principle: only split when you already have two concrete, distinct reasons to change. Until then, a well-named 200-line class is easier to maintain than five 40-line classes scattered across the module.

OCP: the right abstraction comes later, not earlier

Open/Closed says a module should be open for extension and closed for modification. The trouble is that most code that tries to be OCP “from day one” ends up with a speculative abstraction: the interface was shaped around a second implementation that never arrived, or arrived different from what the abstraction assumed.

The case where OCP earns its place is clear:

  • you have already extended the same variation point more than once
  • new extensions keep colliding with if and switch branches that grow with every new rule
  • tests regress every time someone touches the core to add a variant

When those signs show up, abstracting the variation point pays off. When they do not, you are choosing the shape of the abstraction in the dark — and the wrong abstraction is more expensive to correct than honest duplication.

A heuristic that works: duplicate two or three times before abstracting. The third duplication reveals the real axis of variation. The first two usually lie.

LSP: the one principle with a hard rule

Liskov Substitution is the principle that does not tolerate loose interpretation. If B inherits from A, any code that worked with A must keep working with B. Period.

Classic break: Square extends Rectangle where setWidth is overridden to also change setHeight. Code that handles Rectangle and relies on width and height being independent breaks silently when it receives a Square. The compiler does not flag it. The old tests still pass. The new behavior is inconsistent.

The practical value of LSP is not in memorizing the principle, it is in distrusting inheritance that restricts the parent’s behavior. When the subclass throws where the parent did not, ignores a parameter the parent respected, or breaks an invariant the parent guaranteed, LSP is already broken.

The fix is usually composition instead of inheritance, or an honest hierarchy where Square and Rectangle are siblings under a shared abstraction rather than parent and child.

ISP: helps when the interface has become a catalog

Interface Segregation earns its place when an interface has grown to the point of forcing empty implementations or NotImplementedError throws. If Repository has fourteen methods and half the implementations ignore ten of them, the interface is a catalog, not a contract.

The honest symptom for ISP:

  • implementations have to stamp “not supported” on methods they inherited
  • changing one method forces rebuilds or rework in consumers that do not even use it
  • testing one feature forces mocking methods unrelated to that feature

When those signs show up, splitting into smaller interfaces pays off. When they do not, preemptive ISP fragments the surface so much that a reader has to check three interfaces to understand what one object does. An interface that is too small suffers the same problem as a class that is too small: the flow disappears.

DIP: earns its place when tests or substitution demand it

Dependency Inversion says to depend on abstractions, not implementations. In daily practice, that tends to collapse into “use interfaces and dependency injection” — and that is where the shortcut hurts.

The principle is good. The mechanical application is not.

DIP pays off when:

  • you want to swap the implementation in tests without standing up infrastructure
  • more than one real implementation exists for the contract (production, sandbox, fake, in-memory)
  • domain policy should not know which database, queue, or external provider sits on the other side

DIP does not pay off when:

  • there is a single implementation and no honest near-term plan for another one
  • the interface exists only to satisfy the principle and mirrors the concrete class line by line
  • tests already work fine against the real implementation, and injecting an abstraction only spreads configuration around

The tell that DIP was pushed too far is seeing IOrderServiceImpl implements IOrderService with a single implementation, no intent to add another, and consumers receiving the interface “just in case”.

Dogmatic application produces the very symptoms SOLID was meant to prevent

When all five principles get applied all the time, without matching pain, a few patterns show up repeatedly:

  • navigating the code requires opening many files to follow a simple flow
  • most interfaces have a single implementation
  • small classes depend on each other in long chains
  • tests are full of mocks because everything became an injected dependency
  • changing one small rule requires edits across four layers

Those are exactly the symptoms SOLID, well applied, was supposed to ease: hard maintenance, fragile tests, change that spreads. Dogma produces the same pain as the full absence of the principles, only with more ceremony around it.

An operational rule: pain first, principle after

Before applying any principle, it is worth asking what it is solving right now.

PrincipleSignal that justifies applying itRisk if applied without the signal
SRPThe class changes for two different reasons, driven by different ownersLogic scattered without real reduction in coupling
OCPThere is already more than one concrete extension of the same variation pointSpeculative abstraction that locks in the wrong shape
LSPReal inheritance exists with substitution that could break the contractLow risk from applying it, high risk from ignoring it
ISPImplementations ignore methods or throw “not supported”Fragmentation that hides what the object actually does
DIPTests require real substitution, or more than one live implementation existsMirror interfaces with no purpose, configuration scattered around

The table is not rigid. The point is to force the question: what concrete symptom justifies the change? If there is no answer, the principle probably does not need to enter yet.

What SOLID does not solve

Worth stating explicitly, because plenty of code review debates mix concerns.

SOLID deals with the structure of classes and dependencies inside a module. It is not a substitute for:

  • correct domain modeling
  • clear boundaries between modules and bounded contexts
  • concurrency and consistency decisions
  • choice of data structure and algorithmic complexity
  • integration architecture across systems

A system can be strictly SOLID and still be badly modeled, badly partitioned, and slow. And a system can have large classes, sparse inheritance, and few interfaces, yet age well if the larger boundaries are right.

Treating SOLID as a universal quality checklist is confusing microstructure with architecture.

A small concrete example

Consider an OrderProcessor that validates, calculates, persists, and notifies. Dogmatic application turns this into five classes, five interfaces, and an orchestrator. Pain-driven application asks first:

// Direct version. One reason to change: order rules.
class OrderProcessor {
  constructor(private db: Database, private mailer: Mailer) {}

  async process(order: Order): Promise<void> {
    this.validate(order);
    const total = this.calculate(order);
    await this.db.save({ ...order, total });
    await this.mailer.send(order.customerEmail, `Order ${order.id} confirmed`);
  }

  private validate(order: Order): void { /* ... */ }
  private calculate(order: Order): number { /* ... */ }
}

This class is SOLID enough as long as:

  • validation and calculation change for the same reason (both are order rules)
  • Database and Mailer are the abstractions you already need to test without standing up infrastructure
  • there is no second notification policy competing with email

The day SMS, push, and webhooks show up, notify becomes a real variation point and abstracting pays off. The day tax calculation starts depending on jurisdiction, calculation earns its own responsibility. Before that day, splitting is inventing cost.

A short checklist for code review

When a change feels like it might be applying SOLID or inventing ceremony, these filters help:

  • Has the pain this principle solves already shown up in this code, or is it hypothetical?
  • Does the proposed abstraction have more than one real implementation planned in the short term?
  • Do the tests get simpler, or more mock-heavy, after the change?
  • Does the reader open more or fewer files to follow the flow?
  • Does the rule most likely to change next quarter get easier or harder to touch?

If most answers point to worse reading, worse tests, or slower change, the principle is being applied preemptively. Back off.

Closing

SOLID still holds. The issue was never the content of the principles, it was the way they became dogma: applied always, applied early, applied with no symptom. Used well, they are a set of questions you ask the code when it starts to hurt. Used badly, they produce the same pain they claimed to prevent, just wearing nicer names.

If one idea is worth taking away, let it be this: only apply the principle when the matching pain has already shown up. Until then, prefer direct, well-named code that is easy to open.