Guide

SOLID principles explained

Harbor Commerce's checkout service started as a 1,400-line OrderService class that validated carts, calculated tax, charged Stripe, sent confirmation emails, updated inventory, and wrote analytics events. Every new payment method or tax jurisdiction required editing that god object; unit tests needed a database, Redis, and a mocked SMTP server. Refactoring around the SOLID principles — five object-oriented design rules coined by Robert C. Martin (“Uncle Bob”) — split the monolith into focused collaborators wired through interfaces. Checkout latency did not change, but merge conflicts dropped and new regions shipped in days instead of weeks. SOLID is not a framework or a folder layout; it is a vocabulary for why some codebases stay flexible while others ossify. The five letters stand for Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion. Together they push you toward small, composable units that depend on abstractions, not concrete infrastructure. This guide explains each principle with plain examples, maps them to clean architecture and dependency injection, walks through the Harbor Commerce refactor, offers a pragmatism decision table, common pitfalls, and a production checklist.

Why SOLID matters in production code

SOLID addresses the cost of change. When requirements shift — a new tax API, a second payment processor, a GDPR data-export endpoint — code organized around SOLID tends to change in one place instead of rippling through unrelated modules. The principles are guidelines, not laws: a 20-line script does not need five interfaces. They pay off when a module has multiple reasons to change, when several teams touch the same files, or when you need fast unit tests without standing up the whole stack.

SOLID predates microservices but aligns with them: a service boundary is a coarse-grained single responsibility. Inside a monolith or a serverless function, the same ideas keep functions testable and side-effect boundaries clear. They also complement Gang-of-Four design patterns — strategy and adapter patterns often emerge naturally when you apply open/closed and dependency inversion.

S — Single Responsibility Principle (SRP)

A class (or module) should have one reason to change. “Reason to change” means a stakeholder or axis of variation: business rules, persistence format, UI presentation, or third-party API shape. SRP is often misread as “one method per class” or “never more than 100 lines.” The real test: if tax rules change, do you edit the same file that sends email?

Practical SRP boundaries

Split by role, not by layer alone. A TaxCalculator knows jurisdiction rules; OrderRepository persists orders; PaymentGateway talks to Stripe. The orchestrator (PlaceOrderUseCase) coordinates them but does not implement their internals. In Harbor Commerce, extracting InventoryReserver from OrderService meant warehouse rule changes no longer touched payment code.

SRP at function and package scale

SRP applies above the class level: a billing/ package should not also own marketing email templates. Node and Go teams often express SRP as “one concern per file” with explicit barrel exports. Too many tiny classes create navigation overhead — balance SRP with cohesion: things that change together can stay together.

O — Open/Closed Principle (OCP)

Software entities should be open for extension but closed for modification. You add behavior by plugging in new types, not by editing a growing switch (paymentType) in core logic. OCP is how you avoid every feature request becoming a risky edit to shared checkout code.

Extension without modification

Define a PaymentProcessor interface with charge(order). Stripe and PayPal implementations live in separate files; registering a new processor means adding a class and wiring it in composition root — not reopening OrderService. The strategy pattern is the textbook OCP tool. Plugin architectures, middleware chains, and event handlers are OCP at system scale.

When OCP is overkill

If you have one payment provider and no roadmap for a second, an interface adds indirection without benefit. Apply OCP when the second variant is plausible within the module's lifetime, or when tests need a fake implementation. Premature abstraction violates YAGNI; a hard-coded path behind a narrow function is fine until variation arrives.

L — Liskov Substitution Principle (LSP)

Subtypes must be substitutable for their base types without breaking correctness. If code expects a Storage interface, every implementation — S3, local disk, in-memory fake — must honor the contract: same preconditions, same observable behavior, no surprise exceptions.

Classic LSP violations

A ReadOnlyFile subclass of File that throws on write() breaks callers who pass a File and assume writes work. Square/rectangle inheritance puzzles illustrate the same idea: if widening a type narrows behavior, the hierarchy is wrong. Prefer composition: ReadOnlyStorage wraps Storage and rejects writes explicitly at the type level.

LSP in APIs and DTOs

Returning null where the interface promises a list violates LSP for consumers that iterate without null checks. Nullable return types should be explicit in the contract (optional, Result type, empty collection). In Harbor's refactor, all TaxProvider implementations return a TaxQuote or a typed TaxUnavailable error — never silent zero tax.

I — Interface Segregation Principle (ISP)

Clients should not depend on methods they do not use. One fat UserRepository with CRUD, search, audit export, and GDPR erase forces every consumer to mock the whole surface. Split into UserReader, UserWriter, and UserCompliance so read-only handlers depend only on read methods.

ISP vs SRP

SRP is about the implementing class having one reason to change; ISP is about the consumer's view of the contract. A single Postgres-backed class can implement multiple small interfaces. In TypeScript, prefer narrow types and pick/omit over one mega-interface. In Go, embed small interfaces in larger ones only where callers truly need the union.

HTTP and RPC segregation

ISP applies to API design: a mobile client should not download admin-only fields because they share one bloated REST resource. GraphQL field selection and dedicated BFF endpoints are ISP at the network boundary.

D — Dependency Inversion Principle (DIP)

High-level modules should not depend on low-level modules; both should depend on abstractions. Business rules define interfaces (PaymentGateway, OrderRepository); infrastructure implements them (StripeGateway, PostgresOrderRepository). Dependencies point inward toward policy, not outward toward Postgres drivers.

DIP and dependency injection

DIP is the principle; constructor injection is a common technique. The composition root (main, DI container, or factory) wires concrete classes to interfaces. See our dependency injection guide for NestJS, Spring, and manual wiring patterns. DIP enables unit tests that pass in-memory fakes without touching Stripe's sandbox.

DIP without over-abstracting

Not every import needs an interface. Stable standard-library utilities (JSON parse, date formatting) are fine as direct dependencies. Invert dependencies that vary, are slow, or are hard to test: databases, queues, HTTP clients, clock, randomness.

Worked example: Harbor Commerce checkout refactor

Before SOLID, OrderService.placeOrder() sequenced 12 steps inline. After:

  • SRP: CartValidator, TaxCalculator, PaymentCapturer, InventoryReserver, OrderNotifier — each one file, one test suite.
  • OCP: New AfterpayProcessor implements PaymentProcessor; registry picks it by config. No edit to PlaceOrderUseCase.
  • LSP: All tax providers return Result<TaxQuote, TaxError>; checkout never assumes US sales tax rules.
  • ISP: Analytics subscribes to OrderEventPublisher (one method: publish(OrderPlaced)), not the full repository.
  • DIP: Use case constructor accepts interfaces; Fastify route handler builds the graph with real Stripe and Postgres adapters.

The structure mirrors hexagonal architecture ports without mandating a full Uncle Bob folder tree. Teams that need stricter boundaries can promote the same interfaces to explicit inbound and outbound ports.

When to apply SOLID: decision table

SituationRecommendation
Prototype or throwaway scriptSkip SOLID; optimize for speed
Stable CRUD with one integrationSRP in folders; defer interfaces
Second payment provider or regionOCP + DIP with strategy interfaces
Large team on shared moduleSRP + ISP to reduce merge conflicts
Heavy unit-test requirementDIP + LSP-safe fakes
Library/framework for external usersFull SOLID; document contracts
Performance-critical hot pathMeasure first; inline only proven bottlenecks

Common pitfalls

  • Interface per class reflex — abstractions without a second implementation or test fake add noise.
  • God interface — one Repository with 30 methods violates ISP worse than the original god class.
  • SRP over-splitting — seven classes to add two numbers makes navigation harder than one clear function.
  • LSP-blind mocks — test doubles that never throw errors production code throws hide real bugs.
  • DIP in the wrong direction — domain importing ORM annotations inverts the graph backward.
  • SOLID as religion — shipping features matters; refactor toward SOLID when pain appears, not preemptively everywhere.

Production checklist

  • Identify modules with multiple unrelated edit reasons; split on stakeholder axes first.
  • Extract variation points (payment, tax, notification) behind narrow interfaces before the third if branch.
  • Document interface contracts: errors, nullability, idempotency.
  • Verify subtypes with contract tests shared by real and fake implementations.
  • Keep composition root in one place; avoid new Stripe() inside business logic.
  • Run unit tests on use cases with in-memory adapters; reserve integration tests for wiring.
  • Review PRs for new dependencies crossing layer boundaries.
  • Re-evaluate abstractions quarterly; delete interfaces with only one implementer for two releases.
  • Pair structural refactors with test coverage so LSP holds under change.
  • Align folder layout with team mental model — SOLID does not mandate a specific directory tree.

Key takeaways

  • SRP limits reasons to change; split by role and stakeholder, not arbitrary line counts.
  • OCP adds features via new types and plugins, not edits to stable core logic.
  • LSP keeps subtypes honest; broken contracts in tests or subclasses surface as production bugs.
  • ISP shrinks what each client depends on; narrow interfaces beat fat ones.
  • DIP points dependencies at abstractions so policy outlives infrastructure swaps.

Related reading