Skip to main content

SOLID principles

SOLID - five object-oriented design principles (Single Responsibility, Open-Closed, Liskov Substitution, Interface Segregation, Dependency Inversion) that make code easier to change, test, and extend without breaking unrelated parts.

Theory

TL;DR

  • Five principles: SRP, OCP, LSP, ISP, DIP - each targets a different way code breaks under change
  • Without them, editing one class tends to ripple into five others
  • Kitchen analogy: each chef (class) handles one station (responsibility), tools are interchangeable (DIP), any cook can substitute another without chaos (LSP)
  • Apply when a class has more than one reason to change, or adding a feature keeps breaking existing tests
  • Formulated by Robert C. Martin ("Uncle Bob")

Quick example

javascript
// Bad: UserService has two reasons to change (DB schema AND email template) class UserService { save(user) { /* DB save */ } email(user) { /* send email */ } } // Good: split by responsibility (SRP) + inject dependencies (DIP) class UserRepository { save(user) { /* DB */ } } class EmailService { send(user) { /* email */ } } class UserController { constructor(repo, mailer) { this.repo = repo; this.mailer = mailer; } register(user) { this.repo.save(user); this.mailer.send(user); } }

Change the email template? Touch only EmailService. Switch databases? Swap UserRepository. Neither change touches the other class.

Single Responsibility Principle (SRP)

A class should have one reason to change. Not one method, not one file - one reason. If both a DB schema change and an email template change could force you to open the same file, that file has two responsibilities.

The typical trap here is over-splitting. GetUser and SaveUser as separate classes is not SRP, that is fragmentation. UserRepository with get() and save() is fine - both methods change for the same reason: database logic.

javascript
// Bad: Circle renders itself - two reasons to change class Circle { constructor(radius) { this.radius = radius; } area() { return Math.PI * this.radius ** 2; } print() { console.log(`Area: ${this.area()}`); } // rendering lives here } // Good: rendering is separate class Circle { constructor(radius) { this.radius = radius; } area() { return Math.PI * this.radius ** 2; } } const print = (shape) => console.log(`Area: ${shape.area()}`); // Output: "Area: 78.54" - same result, isolated responsibility

Open-Closed Principle (OCP)

Classes should be open for extension, closed for modification. The goal is adding behavior without editing existing code.

Strategy pattern is the cleanest way to achieve this in JavaScript. Instead of a growing chain of if/else, each variant becomes its own class.

javascript
// Bad: add a new user type? Edit this function. function getDiscount(user) { if (user.type === 'regular') return 5; if (user.type === 'vip') return 10; // every new type lands here } // Good: add a new type by adding a new class class RegularUser { getDiscount() { return 5; } } class VipUser { getDiscount() { return 10; } } class ProUser { getDiscount() { return 15; } } // new type, zero edits elsewhere function showDiscount(user) { return user.getDiscount(); // never changes }

From practice: OCP via inheritance alone tends to create hidden LSP violations down the line. Subclasses that override parent behavior can break callers in unexpected ways. Strategy or composition is usually the safer path.

Liskov Substitution Principle (LSP)

If code expects a Rectangle, it should work correctly when given a Square instead - without knowing the difference. That is the complete idea.

The classic counterexample is exactly this. A Square that extends Rectangle overrides setWidth to also set height. This breaks any code that sets width and height independently then checks the area.

javascript
class Rectangle { constructor(w, h) { this.w = w; this.h = h; } setWidth(w) { this.w = w; } setHeight(h) { this.h = h; } area() { return this.w * this.h; } } class Square extends Rectangle { setWidth(w) { this.w = this.h = w; } // LSP violation setHeight(h) { this.w = this.h = h; } } // Client code that assumes Rectangle behavior: const shape = new Square(5, 5); shape.setWidth(5); shape.setHeight(4); console.log(shape.area()); // Expected 20, got 25 - silent bug

Fix: don't make Square extend Rectangle. Use a shared Shape base with only area(), or use composition. Another common LSP violation: a Bird base class with fly(), then Ostrich that throws from fly(). The moment you do birds.forEach(b => b.fly()), ostriches break it.

Interface Segregation Principle (ISP)

Clients should not depend on interfaces they don't use. In JavaScript this means: don't force a class to carry methods it has no use for.

The fat-interface problem shows up in Express middleware and React prop types. A component that receives a huge UIProps object but reads two fields is technically coupled to everything in that object.

javascript
// Bad: Animal "interface" - Dog can't fly, Bird doesn't need swim class Animal { eat() {} walk() {} swim() {} fly() {} } // Good: split by capability class Eatable { eat() {} } class Walkable { walk() {} } class Flyable { fly() {} } class Dog extends Walkable { /* inherits Eatable if needed */ } class Bird extends Flyable { /* no swim(), no walk() */ }

In React terms: ButtonProps with onClick and label beats a giant UIElementProps interface that includes modal state, form values, and animation config.

Dependency Inversion Principle (DIP)

High-level modules should not depend on low-level modules. Both should depend on abstractions.

In practice: don't instantiate dependencies inside a class. Pass them in. JavaScript's structural typing makes this simple - no formal interface declaration needed. Any object with the right methods works.

javascript
// Bad: UserService is locked to MySQL class UserService { constructor() { this.db = new MySQLDatabase(); // hard-coded } getUsers() { return this.db.query('SELECT * FROM users'); } } // Good: inject whatever matches the contract class UserService { constructor(database) { this.database = database; } getUsers() { return this.database.query('SELECT * FROM users'); } } // Production const service = new UserService(new MySQLDatabase()); // Swap to Mongo: const mongoService = new UserService(new MongoDatabase()); // In tests - plain mock object works: const testService = new UserService({ query: () => [{ id: 1, name: 'Alice' }] });

This is exactly how NestJS controllers work. They receive repos via constructor injection and never instantiate concrete classes themselves. Switching from Postgres to DynamoDB becomes a one-line config change.

Decision rules

  • Class has 2+ reasons to change: SRP, split into focused classes
  • Adding a feature requires editing existing logic: OCP, extract to strategy
  • A subclass changes expected parent behavior: LSP, restructure the hierarchy
  • A class implements methods it never calls: ISP, split the interface
  • A class creates its own dependencies with new: DIP, inject them instead

Common mistakes

Mistake 1: SRP means one method per class

Wrong. HttpClient has get(), post(), put(), delete() - multiple methods, one responsibility (HTTP communication). Over-splitting creates brittle micro-classes with no cohesion.

javascript
// Brittle - each operation is a separate class class GetUser { get(id) {} } class SaveUser { save(u) {} } // Correct - cohesion by responsibility class UserRepository { get(id) {} save(u) {} }

Mistake 2: OCP through inheritance only

Subclasses that override parent logic often create hidden LSP violations. Strategy pattern or composition handles OCP without that risk.

Mistake 3: DIP means create interfaces for everything

In TypeScript/JavaScript, structural typing means any object with matching methods satisfies the contract. A plain { query: fn } passed as database is valid DIP. Over-engineering every dependency behind a formal interface slows development without real benefit.

Mistake 4: LSP "works" so it's fine

The Square/Rectangle example passes JavaScript type checks. The bug surfaces at runtime. This is why LSP violations are the hardest to catch without tests. If a subclass overrides a method in a way that changes the expected output, it is a violation regardless of whether it currently crashes.

Mistake 5: ISP is about method count, not coupling

ISP is violated when a client depends on an object with methods it doesn't use - not simply when a class has many methods. A UserService with 15 methods is fine if its callers use all 15.

Real-world usage

  • React: component props through small interfaces (ISP) - ButtonProps not UIProps; context providers inject shared state (DIP)
  • Express/NestJS: controllers receive repos via constructor injection (DIP); each middleware handles one concern (SRP)
  • Redux: one action creator per domain event (SRP); reducers extend behavior without modifying core logic (OCP)
  • Node.js fs module: pass streams as arguments instead of hardcoding files (DIP pattern)
  • Decision vs alternatives: SOLID for codebases with 10+ classes; YAGNI/GRASP for early prototypes where abstractions add noise

Follow-up questions

Q: Give a non-trivial SRP example from e-commerce.
A: OrderService places orders. It should not send confirmation emails. When the email provider changes, you want to touch exactly one file: NotificationService. If both live in OrderService, any email provider migration requires retesting the entire order placement flow.

Q: How does OCP work without inheritance?
A: Strategy pattern. payment.process(new StripeStrategy()) adds a new payment provider by adding a new class. The Payment class itself never changes. This is safer than subclassing because there is no shared state to accidentally break.

Q: What is the correct fix for the Square/Rectangle LSP violation?
A: Don't inherit Square from Rectangle. Use a shared Shape base with only area(), or use composition. The root problem is that a square and a rectangle have different invariants: a square requires width === height, a rectangle does not. Inheritance cannot reconcile that.

Q: How do you apply ISP in a REST API?
A: Split UserPublicApi (read-only endpoints) from UserAdminApi (mutations, deletions). Consumer clients implement only the read interface; admin panels implement both. No client carries dead-weight methods.

Q: How do you apply DIP in vanilla JS without a DI container?
A: Factory functions. const service = new UserService(createRepo('postgres')) where createRepo returns an object with the expected methods. Swap 'postgres' for 'mongo' and everything downstream adjusts.

Q (senior): I have a Bird class with fly() and an Ostrich subclass that throws from fly(). Tests pass because nobody calls fly() on Ostrich yet. Is this an LSP violation?
A: Yes. LSP is about substitutability, not current usage. The moment any code path calls fly() on a Bird reference, an Ostrich breaks it. Fix: remove fly() from the base Bird class and create a FlyingBird subclass. Ostrich extends Bird directly.

Examples

Basic: SRP in a user registration flow

javascript
// One class, three concerns class UserService { register(user) { if (!user.email) throw new Error('No email'); // validation db.insert('users', user); // persistence mailer.send(user.email, 'Welcome!'); // notification } } // Three concerns, three classes class UserValidator { validate(user) { if (!user.email) throw new Error('No email'); } } class UserRepository { save(user) { db.insert('users', user); } } class WelcomeMailer { send(user) { mailer.send(user.email, 'Welcome!'); } } class UserService { constructor(validator, repo, mailer) { this.validator = validator; this.repo = repo; this.mailer = mailer; } register(user) { this.validator.validate(user); this.repo.save(user); this.mailer.send(user); } }

Change the email template: touch only WelcomeMailer. Switch to PostgreSQL: touch only UserRepository. Add a new validation rule: touch only UserValidator. No other class needs to know.

Intermediate: OCP with a payment system

javascript
// Bad: every new payment method requires editing processPayment function processPayment(order, method) { if (method === 'stripe') { /* Stripe logic */ } if (method === 'paypal') { /* PayPal logic */ } if (method === 'crypto') { /* Crypto - new requirement, editing here */ } } // Good: each strategy is isolated class StripePayment { process(order) { console.log(`Stripe: $${order.total}`); } } class PayPalPayment { process(order) { console.log(`PayPal: $${order.total}`); } } class CryptoPayment { // New requirement - new file, zero edits to existing code process(order) { console.log(`Crypto: ${order.total} BTC`); } } function processPayment(order, strategy) { strategy.process(order); } processPayment({ total: 100 }, new StripePayment()); // Stripe: $100 processPayment({ total: 100 }, new CryptoPayment()); // Crypto: 100 BTC

Adding a new payment method means adding a new file. processPayment never changes. This is OCP via the strategy pattern.

Advanced: LSP violation that passes type checks

javascript
class Rectangle { constructor(w, h) { this.w = w; this.h = h; } setWidth(w) { this.w = w; } setHeight(h) { this.h = h; } area() { return this.w * this.h; } } class Square extends Rectangle { setWidth(w) { this.w = this.h = w; } // "smart" override - LSP violation setHeight(h) { this.w = this.h = h; } } function assertRectangleArea(rect) { rect.setWidth(5); rect.setHeight(4); console.log(rect.area()); // Caller expects 20 } assertRectangleArea(new Rectangle(1, 1)); // 20 - correct assertRectangleArea(new Square(1, 1)); // 25 - silent bug, no type error // Fix: no shared mutable hierarchy class Shape { area() {} } class Rectangle extends Shape { constructor(w, h) { super(); this.w = w; this.h = h; } area() { return this.w * this.h; } } class Square extends Shape { constructor(side) { super(); this.side = side; } area() { return this.side ** 2; } } // Both have area(), no shared mutable state, substitution works

No type checker catches the original bug - only a test does. This is why LSP violations are the most expensive to find in production.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?