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
AfterpayProcessorimplementsPaymentProcessor; registry picks it by config. No edit toPlaceOrderUseCase. - 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
| Situation | Recommendation |
|---|---|
| Prototype or throwaway script | Skip SOLID; optimize for speed |
| Stable CRUD with one integration | SRP in folders; defer interfaces |
| Second payment provider or region | OCP + DIP with strategy interfaces |
| Large team on shared module | SRP + ISP to reduce merge conflicts |
| Heavy unit-test requirement | DIP + LSP-safe fakes |
| Library/framework for external users | Full SOLID; document contracts |
| Performance-critical hot path | Measure first; inline only proven bottlenecks |
Common pitfalls
- Interface per class reflex — abstractions without a second implementation or test fake add noise.
- God interface — one
Repositorywith 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
ifbranch. - 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
- Clean architecture explained — concentric layers, the dependency rule, and use-case-centric design
- Dependency injection explained — constructor injection, DI containers, and test doubles
- Software design patterns explained — strategy, adapter, observer, and when patterns help
- Hexagonal architecture explained — ports, adapters, and isolating the application core