Software Engineering Essential

SOLID Principles

Five foundational principles of object-oriented design that guide developers toward flexible, maintainable, and testable software systems.

⏱ 12 min read

What it is

SOLID is an acronym coined by Robert C. Martin (Uncle Bob) representing five design principles intended to make object-oriented software more understandable, flexible, and maintainable. The principles are: Single Responsibility Principle (SRP), Open/Closed Principle (OCP), Liskov Substitution Principle (LSP), Interface Segregation Principle (ISP), and Dependency Inversion Principle (DIP). Together they address the root causes of brittle, rigid, and immobile code.

Each principle targets a specific class of design smell. SRP combats the "God class" problem where a single class carries too much responsibility. OCP guards against cascading changes when requirements evolve. LSP ensures polymorphism is used correctly so substitutions don't break consumers. ISP prevents interface bloat that forces implementors to depend on methods they never call. DIP decouples high-level business logic from low-level infrastructure by inverting the direction of dependencies. Applying all five together produces a design where each class has one reason to change and modules communicate through stable abstractions.

Why it exists

Before SOLID became widely taught, many OOP codebases accumulated what Martin called "design rot": rigidity (changing one thing forces many other changes), fragility (changes break unrelated parts), immobility (code cannot be reused because it drags too many dependencies), viscosity (it's easier to hack than to do the right thing). These symptoms share a common cause — tightly coupled, highly cohesive-by-accident classes that violate abstraction boundaries.

SOLID emerged from decades of industrial experience building large Java and C++ systems where the cost of change became the primary engineering bottleneck. The principles encode heuristics that make code more testable (because dependencies can be swapped), more reusable (because modules depend on abstractions), and more modifiable (because each class has one actor driving change). They also align naturally with test-driven development: if a class is hard to unit-test, it likely violates SRP or DIP.

When to use

  • Building domain models that will evolve over time as business requirements change.
  • Designing libraries or SDKs where consumers need stable interfaces across versions.
  • Writing code that will be unit-tested in isolation — SOLID designs are inherently more testable.
  • Onboarding teams to a shared design vocabulary; SOLID names provide a common language for code reviews.
  • Refactoring legacy code that has become hard to change — SOLID violations are useful diagnostics.
  • Any context where long-term maintainability matters more than short-term delivery speed.

When not to use

  • Throwaway scripts and prototypes: Over-engineering a one-off migration script with interfaces and DI frameworks adds cost without value.
  • Data transfer objects (DTOs): Simple containers for data do not benefit from SOLID decomposition and are made worse by it.
  • Performance-critical hot paths: Excessive indirection through interfaces can defeat CPU branch-prediction and JIT optimisations in tight loops.
  • Very small codebases: Introducing abstract factories and DI containers into a 200-line utility is speculative generality.

Typical architecture


┌──────────────────────────────────────────────────────────────┐
│  SRP: Each class has ONE reason to change                     │
│                                                              │
│  ┌──────────────┐   ┌────────────────┐   ┌───────────────┐  │
│  │  OrderService │   │ OrderValidator │   │ OrderNotifier │  │
│  │  (orchestrate)│   │  (validation)  │   │  (email/SMS)  │  │
│  └──────────────┘   └────────────────┘   └───────────────┘  │
│                                                              │
│  OCP: Open for extension, closed for modification            │
│                                                              │
│  «interface»          ┌───────────────┐  ┌────────────────┐ │
│  DiscountStrategy ◄───│ VIPDiscount   │  │ SeasonDiscount │ │
│  + calculate()        └───────────────┘  └────────────────┘ │
│                                                              │
│  LSP: Subtypes must be substitutable for base types          │
│                                                              │
│  Shape (area())                                              │
│    ├── Circle   ✓  area() = π r²                             │
│    └── Square   ✓  area() = s²                               │
│    (NOT Rectangle → Square which violates width/height inv.) │
│                                                              │
│  ISP: Role-specific interfaces                               │
│                                                              │
│  «Readable»  «Writable»  «Seekable»                          │
│       ▲           ▲           ▲                              │
│       └───────────┴───────────┘                              │
│               FileStream                                     │
│                                                              │
│  DIP: Depend on abstractions                                 │
│                                                              │
│  OrderService ──► «interface» Repository ◄── SqlRepository   │
│                 (high-level depends on abstraction)          │
└──────────────────────────────────────────────────────────────┘

Pros and cons

Pros

  • Dramatically improves testability by enabling dependency injection and mocking.
  • Reduces the blast radius of changes — a single-responsibility class has fewer reasons to break.
  • Facilitates parallel development; teams can work on separate abstractions independently.
  • Promotes reuse — stable interfaces can be extracted to shared libraries.
  • Creates a shared design language for code reviews and architectural discussions.

Cons

  • Can lead to over-abstraction (interface explosion) when applied dogmatically to simple domains.
  • Increases the number of files and classes, raising the cognitive load for new contributors.
  • Indirection through interfaces makes stack traces harder to follow during debugging.
  • Upfront investment is higher — designing correct abstractions requires anticipating change directions.
  • Misapplied SOLID (e.g., premature OCP) creates complexity without benefit if requirements never change in that dimension.

Implementation notes

SRP in practice: The crucial insight is that "single responsibility" means a class should have one actor driving change, not necessarily one method. Martin's example: an Employee class that combines salary calculation (driven by the CFO), report formatting (driven by the COO), and DB persistence (driven by the CTO) has three actors and therefore three reasons to change. Split by actor, not by number of methods. In TypeScript: class PayrollService, class EmployeeReporter, class EmployeeRepository — each owned by a different stakeholder group.

OCP via interfaces: The canonical approach is the Strategy or Template Method pattern. Instead of a switch statement on a type discriminator (if (type === "visa") { ... } else if (type === "paypal") { ... }), define a PaymentProcessor interface and inject implementations. Adding Stripe support means creating a new class — not modifying existing ones. In Java, this maps naturally to interface + Spring DI. LSP gotcha: The classic violation is the Rectangle/Square hierarchy. A Square redefines the width setter to also update height, which breaks code that independently sets width and height expecting them to remain independent — a silent behavioral violation detected only at runtime. DIP: High-level modules (OrderService) should not import low-level modules (MySQLOrderRepository) directly. Introduce a OrderRepository interface owned by the domain layer; both the service and the repository implementation depend on it.

Common failure modes

  • SRP via utility classes: StringUtils, DateUtils with 50 static methods are SRP violations hiding in plain sight — they serve no single actor.
  • OCP misapplied as no-modification dogma: Teams refuse to fix bugs without adding new classes, creating bizarre inheritance hierarchies.
  • LSP silent contracts: Subclasses that throw UnsupportedOperationException for inherited methods (Java's AbstractList pattern misused) break the substitutability contract silently.
  • ISP fat interfaces: A single UserService interface with 20 methods forces every test double to stub 19 irrelevant methods, hiding true dependencies.
  • DIP in name only: Declaring an interface with a single implementation that is never swapped adds ceremony without value; true DIP requires the abstraction to be defined in the consumer's package, not the implementor's.

Decision checklist

  • Does each class have a single well-defined reason to change, traceable to one stakeholder or requirement area?
  • Can you add new behaviour (new payment type, new discount rule) without touching existing classes?
  • If you replace a concrete type with a subtype, do all existing tests still pass without modification?
  • Do your interfaces have only the methods that the specific client actually needs?
  • Does your high-level business logic import only abstractions (interfaces/abstract classes), never concrete infrastructure classes?
  • Are abstractions defined in the package that consumes them, not the package that implements them?

Example use cases

  • E-commerce checkout: Separate classes for tax calculation (SRP), pluggable payment gateways via interface (OCP + DIP), and role interfaces for read-only vs read-write order access (ISP).
  • Reporting engine: Abstract renderer interface with PDF, HTML, and CSV implementations (OCP); report data assembled by a dedicated factory (SRP).
  • Authentication service: High-level AuthService depends on TokenStore interface (DIP); Redis and in-memory implementations are interchangeable (LSP).
  • Design Patterns — GoF patterns such as Strategy, Observer, and Decorator are natural implementations of SOLID principles.
  • Domain-Driven Design — DDD aggregates and bounded contexts operationalise SRP at the macro-architecture level.
  • Test-Driven Development — TDD acts as a feedback mechanism for SOLID violations; untestable code almost always violates SRP or DIP.
  • Refactoring Patterns — Techniques for incrementally moving existing code toward SOLID designs.

Further reading