← Back to principles

Design Principle

SOLID Principles

Five guiding heuristics to keep software modular, testable, and change-ready.

The SOLID principles help engineers design software that stays malleable as teams grow. Each principle is a conversation starter, not a strict rule.


🧩 S — Single Responsibility Principle (SRP)

A module should have one reason to change. Define boundaries by the decision they protect, not by line count.

SRP isolates change driversTS
1// ❌ Mixed responsibilities: persistence + messaging.
2class UserService {
3createUser(input: CreateUserRequest) {
4 const user = this.repo.save(input);
5 this.emailer.sendWelcome(user.email);
6}
7}
8
9// ✅ Separate causes of change: data vs. delivery.
10class UserService {
11constructor(private readonly users: UserRepository) {}
12
13createUser(input: CreateUserRequest) {
14 return this.users.save(input);
15}
16}
17
18class WelcomeWorkflow {
19constructor(private readonly mailer: Mailer) {}
20
21send(user: User) {
22 this.mailer.sendWelcome(user.email);
23}
24}

Lesson: SRP isn’t about fewer methods — it’s about isolating causes of change so incidents stay contained.


🔄 O — Open/Closed Principle (OCP)

Systems should be open for extension, closed for modification. Add behaviour through clear extension points, not increasingly fragile edits.

1// ❌ Editing core logic for every new plan.
2function renderInvoice(plan: "starter" | "pro" | "enterprise") {
3if (plan === "starter") return /* ... */;
4if (plan === "pro") return /* ... */;
5if (plan === "enterprise") return /* ... */;
6}
7
8// ✅ Extend by registering a new strategy.
9interface PricingStrategy {
10total(lineItems: LineItem[]): Money;
11}
12
13class Subscription {
14constructor(private strategy: PricingStrategy) {}
15
16checkout(items: LineItem[]) {
17 return this.strategy.total(items);
18}
19}
20
21class ProPricing implements PricingStrategy {
22total(items: LineItem[]) {
23 // ...
24}
25}
26
27const subscription = new Subscription(new ProPricing());

Lesson: Strategy patterns or registries convert “edit the enum” into “add a module”. Extension points buy you calm evolutions.


🤝 L — Liskov Substitution Principle (LSP)

Subtypes must honour the expectations of their supertypes. Violations show up as surprising runtime checks or instanceof escapes.

1// ❌ Subclass tightens contract by throwing unexpectedly.
2class Queue {
3protected items: string[] = [];
4enqueue(value: string) {
5 this.items.push(value);
6}
7dequeue() {
8 return this.items.shift();
9}
10size() {
11 return this.items.length;
12}
13}
14
15class LimitedQueue extends Queue {
16constructor(private readonly limit: number) {
17 super();
18}
19
20enqueue(value: string) {
21 if (this.items.length >= this.limit) {
22 throw new Error("Limit reached");
23 }
24 super.enqueue(value);
25}
26}
27
28// ✅ Compose instead of changing base guarantees.
29class LimitedQueue {
30constructor(
31 private readonly limit: number,
32 private readonly queue = new Queue(),
33) {}
34
35enqueue(value: string) {
36 if (this.queue.size() >= this.limit) {
37 return; // Drop or buffer based on policy.
38 }
39 this.queue.enqueue(value);
40}
41
42dequeue() {
43 return this.queue.dequeue();
44}
45}

Lesson: If you find yourself tightening preconditions or relaxing postconditions, question whether inheritance was the right model.


🔁 I — Interface Segregation Principle (ISP)

Clients should not depend on methods they don’t use. Lean interfaces keep mocks honest and avoid leaking surface area.

1// ❌ Inflated interface forces read-only clients to depend on writes.
2interface Storage {
3put(key: string, value: string): Promise<void>;
4get(key: string): Promise<string | null>;
5delete(key: string): Promise<void>;
6}
7
8// ✅ Segment interfaces by capability.
9interface StorageWriter {
10put(key: string, value: string): Promise<void>;
11delete?(key: string): Promise<void>;
12}
13
14interface StorageReader {
15get(key: string): Promise<string | null>;
16}
17
18type Cache = StorageReader & StorageWriter;
19
20class TokenCache implements StorageReader {
21async get(key: string) {
22 // ...
23}
24}

Lesson: Split read/write responsibilities or sync/async requirements. Segregated interfaces keep dependency graphs aligned to use cases.


🧱 D — Dependency Inversion Principle (DIP)

High-level modules should not depend on low-level implementation details. Invert via abstractions so policies lead, mechanisms follow.

1// ❌ High-level service constructs concrete gateway directly.
2class BillingService {
3async bill(account: Account, invoice: Invoice) {
4 const gateway = new StripeGateway(process.env.STRIPE_KEY!);
5 return gateway.charge({ account, invoice });
6}
7}
8
9// ✅ Depend on abstraction and inject implementation.
10interface PaymentGateway {
11charge(request: ChargeRequest): Promise<ChargeResponse>;
12}
13
14class BillingServiceWithGateway {
15constructor(private readonly gateway: PaymentGateway) {}
16
17async bill(account: Account, invoice: Invoice) {
18 return this.gateway.charge({ account, invoice });
19}
20}
21
22const billing = new BillingServiceWithGateway(
23new StripeGateway(process.env.STRIPE_KEY!),
24);

Lesson: DIP turns hard-coded adapters into pluggable contracts. When the API team changes authentication, you swap adapters instead of rewriting business logic.


💡 Real-World Insight

At scale, responsibilities blend easily. SOLID provides a checklist to notice entanglements before they dominate on-call rotations.

  • Use SRP to define service boundaries.
  • Reach for OCP when roadmap volatility accelerates.
  • Watch for LSP violations when refactoring hierarchies.

Final thought: SOLID isn't about dogma. It's a vocabulary to describe painful coupling and a compass for finding better seams.


Interview Questions

Beginner

Q: What does the Single Responsibility Principle mean? Give a simple example of violating it.

A: SRP states that a class or module should have only one reason to change. A violation would be a User class that handles both user data persistence and sending emails. If email service changes, you'd need to modify the User class even though it's not related to user data. The fix: separate into UserRepository (handles data) and EmailService (handles notifications).


Intermediate

Q: How would you refactor a payment processing system to follow the Open/Closed Principle? The system currently uses if-else chains to handle different payment methods (credit card, PayPal, bank transfer).

A: Create a PaymentStrategy interface with a processPayment(amount, details) method. Each payment method (CreditCardPayment, PayPalPayment, BankTransferPayment) implements this interface. The payment service accepts a strategy via dependency injection. To add a new payment method, you create a new strategy class without modifying existing code. This follows OCP: open for extension (new strategies), closed for modification (core payment service).


Senior

Q: Design a microservices architecture for an e-commerce platform where the order service needs to communicate with inventory, payment, and shipping services. How do SOLID principles apply at the service level? How would you handle eventual consistency and failure scenarios?

A:

  • SRP: Each service owns one domain (orders, inventory, payments, shipping)
  • OCP: Use event-driven architecture with event handlers that can be extended without modifying core services
  • LSP: Service contracts (APIs) must be substitutable - if you swap payment providers, the order service shouldn't break
  • ISP: Order service shouldn't depend on shipping-specific methods it doesn't use - use narrow, focused APIs
  • DIP: Order service depends on abstractions (event bus, service interfaces) not concrete implementations

For consistency: Use saga pattern or event sourcing. Order service publishes "OrderCreated" event. Inventory reserves items, payment processes charge, shipping creates label. If any step fails, compensating transactions roll back. Services communicate via events/API contracts, not direct dependencies.


Key Takeaways

  • SRP isolates causes of change - if a bug fix needs to happen in multiple places, extract shared knowledge
  • OCP enables extension without modification - use strategy patterns, plugins, or event handlers instead of if-else chains
  • LSP ensures subtypes honor supertype contracts - prefer composition over inheritance when contracts might diverge
  • ISP keeps interfaces lean - clients shouldn't depend on methods they don't use (split read/write interfaces)
  • DIP inverts dependencies - high-level modules depend on abstractions, not concrete implementations
  • SOLID principles work together: SRP defines boundaries, OCP enables evolution, LSP/ISP ensure safe substitution, DIP decouples layers
  • At scale, SOLID helps notice entanglements before they dominate on-call rotations
  • These are heuristics, not strict rules - use them as conversation starters to identify painful coupling

Keep exploring

Principles work best in chorus. Pair this lesson with another concept and observe how your architecture conversations change.