SOLID Principles — Clear Guide

Short, practical explanations with concise TypeScript Node.js examples.

What is SOLID?

SOLID is a set of five principles for designing maintainable, testable, and extensible object‑oriented software.

1) Single Responsibility (SRP)

Each module/class should have one reason to change.

Keep classes cohesiveAvoid "god" classes
  • Signs of violation: Class handles unrelated concerns (logging + DB + validation)
  • Do: Split by responsibilities (e.g., Validator, Repository, Service)
Example
// TypeScript (Node)
class PasswordValidator { isStrong(pw:string){ return /[A-Z].{7,}/.test(pw); } }
class UserRepository { async save(u:{ id:string; hash:string }){ /* ... */ } }
class UserService { constructor(private repo:UserRepository, private validator:PasswordValidator){}
  async register(id:string, pw:string){ if(!this.validator.isStrong(pw)) throw new Error('weak'); return this.repo.save({ id, hash: pw }); }
}

2) Open/Closed (OCP)

Open for extension, closed for modification.

Add behavior via new classesAvoid editing stable code
  • Signs of violation: Editing a stable class whenever a new variant is added
  • Do: Extend via new implementations or plugins; keep callers unchanged
Example
// === Node.js TypeScript ===
// Strategy extension without modifying OrderService
interface Payment { pay(cents:number): Promise }
class CardPayment implements Payment { async pay(c:number){ return true; } }
class PaypalPayment implements Payment { async pay(c:number){ return true; } }
class OrderService { constructor(private payment:Payment){} checkout(c:number){ return this.payment.pay(c); } }
// Add BankTransferPayment later without changing OrderService

// === Java ===
interface Payment { boolean pay(long cents); }
class CardPayment implements Payment { public boolean pay(long c){ return true; } }
class PaypalPayment implements Payment { public boolean pay(long c){ return true; } }
class OrderService { private final Payment payment; public OrderService(Payment p){ this.payment=p; } public boolean checkout(long c){ return payment.pay(c); } }
// Add BankTransferPayment without modifying OrderService

3) Liskov Substitution (LSP)

Subtypes must be usable via base types without surprises.

Honor contractsNo broken assumptions
  • Signs of violation: Subtype throws for valid base cases, changes return semantics
  • Do: Keep behavioral contracts identical for all implementations
Example
// === Node.js TypeScript ===
// Good: both implement the same contract consistently
interface Cache { get(k:string):string|null; set(k:string,v:string):void }
class MemoryCache implements Cache { /* ... */ get(k){return null;} set(k,v){} }
class NoopCache implements Cache { get(k){return null;} set(k,v){} } // still valid substitute

// === Java ===
interface Cache { String get(String k); void set(String k, String v); }
class MemoryCache implements Cache { public String get(String k){ return null; } public void set(String k, String v){} }
class NoopCache implements Cache { public String get(String k){ return null; } public void set(String k, String v){} }

4) Interface Segregation (ISP)

Prefer several small, specific interfaces over a large "fat" one.

Focused APIsLess coupling
  • Signs of violation: Clients implement methods they never use
  • Do: Split large interfaces so each client depends only on what it needs
Example
// === Node.js TypeScript ===
// Split interfaces so clients depend only on what they use
interface Reader { read(path:string):Promise }
interface Writer { write(path:string, data:string):Promise }
class FileReader implements Reader { read(p){ return Promise.resolve('...'); } }
class FileWriter implements Writer { write(p,d){ return Promise.resolve(); } }

// === Java ===
interface Reader { String read(String path) throws Exception; }
interface Writer { void write(String path, String data) throws Exception; }
class FileReaderImpl implements Reader { public String read(String p){ return "..."; } }
class FileWriterImpl implements Writer { public void write(String p, String d){} }

5) Dependency Inversion (DIP)

Depend on abstractions, not concretions.

Inject interfacesSwap implementations
  • Signs of violation: High-level modules construct concrete dependencies directly
  • Do: Depend on interfaces; inject concrete implementations at composition root
Example
// === Node.js TypeScript ===
// High-level module depends on interface
interface Mailer { send(to:string, subject:string, body:string):Promise }
class SmtpMailer implements Mailer { async send(to,s,b){ /* SMTP */ } }
class ConsoleMailer implements Mailer { async send(to,s,b){ console.log('mail', {to,s,b}); } }
class NotificationService { constructor(private mailer:Mailer){} notifyUser(email:string){ return this.mailer.send(email,'Hello','Welcome'); } }
// Choose implementation at composition root
const svc = new NotificationService(process.env.PROD ? new SmtpMailer() : new ConsoleMailer());

// === Java ===
interface Mailer { void send(String to, String subject, String body); }
class SmtpMailer implements Mailer { public void send(String to, String s, String b){} }
class ConsoleMailer implements Mailer { public void send(String to, String s, String b){ System.out.println("mail"+to); } }
class NotificationService { private final Mailer mailer; public NotificationService(Mailer m){ this.mailer=m; } public void notifyUser(String email){ mailer.send(email, "Hello", "Welcome"); } }
// Choose implementation at composition root
NotificationService svcJava = new NotificationService(new ConsoleMailer());

Checklist