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.
- S: Single Responsibility
- O: Open/Closed
- L: Liskov Substitution
- I: Interface Segregation
- D: Dependency Inversion
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
- Does each class have exactly one reason to change (SRP)?
- Can I add features by adding code, not modifying stable code (OCP)?
- Do all implementations truly substitute the interface (LSP)?
- Are interfaces small and client‑specific (ISP)?
- Do high‑level modules depend on abstractions (DIP)?