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
// 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.
// 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 responsibilityOpen-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.
// 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.
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 bugFix: 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.
// 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.
// 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.
// 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) -
ButtonPropsnotUIProps; 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
fsmodule: 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
// 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
// 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 BTCAdding 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
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 worksNo 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 readyA concise answer to help you respond confidently on this topic during an interview.