SOLID Design Principles
Problem framing
Distributed systems run on evolving service codebases, shared libraries, and SDKs that many teams depend on. As requirements change, tight coupling turns small updates into risky, cross-service changes. SOLID provides code-level constraints that keep services adaptable and testable as architectures scale. For service boundary choices and scaling context, see compute patterns.
Core idea / pattern
SOLID is a set of dependency and responsibility rules that reduce change ripple. Each principle frames a problem, prescribes a structural pattern, and clarifies the trade-offs.
Single Responsibility Principle (SRP)
Problem: One module handles multiple concerns, so unrelated changes collide.
Pattern: Split responsibilities so each module has one reason to change.
Trade-offs: More components to manage and wire together.
Failure modes: Over-splitting creates chatty modules and unclear ownership.
Example: Separate pricing calculation from invoice delivery.
class PricingService { calculate(order) }
class InvoiceSender { send(invoice) }
Open/Closed Principle (OCP)
Problem: New behaviors require editing stable, high-risk code paths.
Pattern: Extend behavior via new modules or strategies instead of modifying core logic.
Trade-offs: Strategy selection logic can become complex without governance.
Failure modes: Plugin sprawl hides critical behavior behind indirect wiring.
Example: Add a new shipping rule without touching the core dispatcher.
interface ShippingRule { quote(order) }
class ExpeditedRule implements ShippingRule { ... }
Liskov Substitution Principle (LSP)
Problem: Subtypes violate the expectations of their base types.
Pattern: Ensure derived types preserve contract behavior and invariants.
Trade-offs: Some inheritance hierarchies become impossible or require redesign.
Failure modes: Runtime failures appear only in specific subtype deployments.
Example: A read-only repository should not inherit from a write-capable base.
interface ReadRepo { find(id) }
interface WriteRepo extends ReadRepo { save(entity) }
Interface Segregation Principle (ISP)
Problem: Clients depend on large interfaces they only partially use.
Pattern: Provide smaller, role-specific interfaces.
Trade-offs: More interfaces to discover and document.
Failure modes: Excessive segregation leads to duplication and confusion.
Example: Split read APIs from admin APIs for analytics clients.
interface MetricsReader { query(range) }
interface MetricsAdmin { backfill(job) }
Dependency Inversion Principle (DIP)
Problem: High-level policies depend directly on low-level vendor details.
Pattern: Depend on abstractions; implement adapters at the edges.
Trade-offs: Extra indirection and the need for stable contracts.
Failure modes: Weak abstractions leak vendor semantics into core logic.
Example: A billing service depends on a gateway interface, not a single provider SDK.
interface PaymentGateway { charge(amount) }
class StripeGateway implements PaymentGateway { ... }
| Principle | Primary focus | Distributed-systems benefit | Quick example |
|---|---|---|---|
| SRP | One reason to change | Smaller deploy blast radius inside services | Split validation from enrichment |
| OCP | Extend without edits | Safer feature rollouts and canaries | Add a new routing rule |
| LSP | Subtype safety | Predictable behavior across deployments | Writable vs read-only stores |
| ISP | Small interfaces | Less coupling across service boundaries | Separate reader and admin APIs |
| DIP | Abstraction-first dependencies | Vendor swaps without core rewrites | Gateway adapter for payments |
Architecture diagram
flowchart LR
Client[API Handler] --> UseCase[Use Case Service]
UseCase --> Port[Domain Port Interface]
UseCase --> Policy[Policy Engine]
Port --> Adapter[Repository Adapter]
Adapter --> DB[(Database)]
UseCase --> Notifier[Notifier Interface]
Notifier --> Email[Email Adapter]
Step-by-step flow
- Map change hotspots and identify mixed responsibilities.
- Split modules into focused components with clear ownership.
- Define small interfaces for each client role.
- Invert dependencies so policies depend on interfaces, not vendors.
- Swap adapters per environment and verify behavior via tests.
Failure modes
- SRP fragmentation creates excessive wiring and unclear data flow.
- OCP strategies drift without governance, making behavior opaque.
- LSP violations surface only in production when a subtype is selected.
- ISP interfaces proliferate without shared discovery or documentation.
- DIP abstractions change too often, causing churn across services.
Trade-offs
- More indirection and files in exchange for testability and resilience to change.
- Upfront design effort that reduces regression risk in long-lived systems.
- Smaller interfaces improve coupling but add coordination overhead.
- Mocking becomes easier, yet integration testing remains essential.
Real-world usage
- Payment services isolate providers with DIP adapters and swap them by region.
- Policy engines add rules via OCP to avoid edits in core compliance logic.
- Analytics clients consume a read-only interface separate from admin tooling.
- Event processors split ingestion, validation, and enrichment to keep SRP clear.
- Repository adapters align with storage choices in storage and data patterns.