SOLID is an acronym for five object-oriented design principles that make software easier to maintain, extend, and understand. Coined by Robert C. Martin ("Uncle Bob"), these principles help you avoid code that becomes brittle and hard to change.
Quick Reference
| Letter | Principle | One-line summary |
|---|---|---|
| S | Single Responsibility | A class/function should have one reason to change |
| O | Open-Closed | Open for extension, closed for modification |
| L | Liskov Substitution | Subtypes must be substitutable for their base types |
| I | Interface Segregation | Don't force clients to depend on methods they don't use |
| D | Dependency Inversion | Depend on abstractions, not concretions |
S — Single Responsibility Principle (SRP)
A module should have one, and only one, reason to change.
Violation
class UserService {
constructor(private db: Database) {}
getUser(id: string) {
return this.db.query(`SELECT * FROM users WHERE id = ?`, [id]);
}
sendWelcomeEmail(user: User) {
// Email logic mixed into UserService
const smtp = new SMTPClient({ host: 'smtp.example.com' });
smtp.send({
to: user.email,
subject: 'Welcome!',
body: `Hi ${user.name}, welcome aboard.`,
});
}
formatUserForExport(user: User): string {
// CSV formatting mixed in too
return `${user.id},${user.name},${user.email}`;
}
}
UserService has three reasons to change: database queries, email logic, and export formatting.
Fixed
class UserRepository {
constructor(private db: Database) {}
findById(id: string): Promise<User> {
return this.db.query(`SELECT * FROM users WHERE id = ?`, [id]);
}
}
class EmailService {
sendWelcome(user: User): void {
const smtp = new SMTPClient({ host: 'smtp.example.com' });
smtp.send({ to: user.email, subject: 'Welcome!', body: `Hi ${user.name}` });
}
}
class UserExporter {
toCsv(user: User): string {
return `${user.id},${user.name},${user.email}`;
}
}
Each class now has one reason to change. Changing how emails are sent doesn't touch the database code.
SRP in functions
SRP applies to functions too. A 200-line function that validates input, transforms data, saves to a database, and sends a notification violates SRP. Break it into small, focused functions:
// Before: one function does everything
async function processOrder(data: unknown) {
// validate, transform, save, notify — all here
}
// After: each function does one thing
async function processOrder(rawData: unknown) {
const data = validateOrder(rawData);
const order = transformOrder(data);
const saved = await orderRepository.save(order);
await notificationService.orderConfirmed(saved);
return saved;
}
O — Open-Closed Principle (OCP)
Software entities should be open for extension, but closed for modification.
Add new behaviour without changing existing code. This protects existing, tested code from regressions.
Violation
class DiscountCalculator {
calculate(price: number, customerType: string): number {
if (customerType === 'regular') {
return price;
} else if (customerType === 'member') {
return price * 0.9;
} else if (customerType === 'vip') {
return price * 0.8;
}
// Every new customer type requires editing this class
return price;
}
}
Adding a new discount type requires modifying DiscountCalculator.
Fixed
interface DiscountStrategy {
apply(price: number): number;
}
class RegularDiscount implements DiscountStrategy {
apply(price: number) { return price; }
}
class MemberDiscount implements DiscountStrategy {
apply(price: number) { return price * 0.9; }
}
class VipDiscount implements DiscountStrategy {
apply(price: number) { return price * 0.8; }
}
// New type: add a new class, never touch existing ones
class BlackFridayDiscount implements DiscountStrategy {
apply(price: number) { return price * 0.5; }
}
class DiscountCalculator {
calculate(price: number, strategy: DiscountStrategy): number {
return strategy.apply(price);
}
}
Now you can add as many discount strategies as you want without touching DiscountCalculator.
OCP with higher-order functions
In functional JS/TS, OCP often means accepting functions instead of switch statements:
type Transform = (data: string) => string;
function pipeline(data: string, ...transforms: Transform[]): string {
return transforms.reduce((acc, fn) => fn(acc), data);
}
// Extend by adding new transforms — never modify pipeline()
const trim = (s: string) => s.trim();
const uppercase = (s: string) => s.toUpperCase();
const removeSpaces = (s: string) => s.replace(/\s+/g, '_');
const result = pipeline(' hello world ', trim, uppercase, removeSpaces);
// → 'HELLO_WORLD'
L — Liskov Substitution Principle (LSP)
If S is a subtype of T, then objects of type T may be replaced with objects of type S without altering program correctness.
In plain terms: a subclass must honour the contract of its parent. You should be able to use any subclass wherever the base class is expected.
Violation
class Rectangle {
constructor(protected width: number, protected height: number) {}
setWidth(w: number) { this.width = w; }
setHeight(h: number) { this.height = h; }
area() { return this.width * this.height; }
}
class Square extends Rectangle {
// Square "is-a" Rectangle — classic OOP teaching, but it violates LSP
setWidth(w: number) { this.width = w; this.height = w; }
setHeight(h: number) { this.width = h; this.height = h; }
}
function resizeAndPrint(rect: Rectangle) {
rect.setWidth(5);
rect.setHeight(10);
// Expected: 50. With Square, get: 100 (height call overwrote width)
console.log(rect.area());
}
resizeAndPrint(new Rectangle(2, 3)); // 50 ✓
resizeAndPrint(new Square(2, 2)); // 100 ✗ — substitution breaks behaviour
Fixed
Don't force an inheritance relationship that doesn't hold. Use composition or a shared interface:
interface Shape {
area(): number;
}
class Rectangle implements Shape {
constructor(private width: number, private height: number) {}
area() { return this.width * this.height; }
}
class Square implements Shape {
constructor(private side: number) {}
area() { return this.side * this.side; }
}
function printArea(shape: Shape) {
console.log(shape.area()); // works correctly for any Shape
}
LSP in practice
Common LSP violations:
| Violation pattern | Fix |
|---|---|
| Subclass throws on method parent supports | Favour composition over inheritance |
| Subclass narrows input types (stricter preconditions) | Keep or widen accepted inputs in subclasses |
| Subclass weakens output guarantees (looser postconditions) | Subclasses must return at least as strong a guarantee |
| Subclass ignores inherited method | Redesign the hierarchy |
I — Interface Segregation Principle (ISP)
Clients should not be forced to depend on methods they do not use.
Large, fat interfaces force implementors to provide methods they don't need, leading to empty or throw stubs.
Violation
interface Worker {
work(): void;
eat(): void;
sleep(): void;
}
class HumanWorker implements Worker {
work() { console.log('working'); }
eat() { console.log('eating'); }
sleep() { console.log('sleeping'); }
}
class RobotWorker implements Worker {
work() { console.log('working'); }
eat() { throw new Error('Robots do not eat'); } // forced stub
sleep() { throw new Error('Robots do not sleep'); } // forced stub
}
Fixed
Split the fat interface into smaller, focused ones:
interface Workable {
work(): void;
}
interface Feedable {
eat(): void;
}
interface Restable {
sleep(): void;
}
class HumanWorker implements Workable, Feedable, Restable {
work() { console.log('working'); }
eat() { console.log('eating'); }
sleep() { console.log('sleeping'); }
}
class RobotWorker implements Workable {
work() { console.log('working'); }
// Only implements what it actually supports
}
ISP in TypeScript
TypeScript's structural typing makes ISP natural:
// Instead of one big type, use intersections where needed
type Serialisable = { serialize(): string };
type Loggable = { log(): void };
type Cacheable = { cacheKey(): string };
// A service only asks for what it needs
function saveToCache(item: Cacheable & Serialisable) {
cache.set(item.cacheKey(), item.serialize());
}
// Callers don't need to implement Loggable unless the function asks for it
D — Dependency Inversion Principle (DIP)
High-level modules should not depend on low-level modules. Both should depend on abstractions.
Concretely: inject dependencies rather than creating them inside a class. This makes code testable and swappable.
Violation
class OrderService {
// Hard-coded dependency on a concrete class
private emailer = new SendGridEmailer();
private db = new PostgresDatabase();
async placeOrder(order: Order) {
await this.db.save(order);
await this.emailer.send(order.userEmail, 'Order confirmed');
}
}
- Can't swap
PostgresDatabasefor an in-memory DB in tests. - Can't switch to a different email provider without modifying
OrderService.
Fixed
// Define abstractions
interface Database {
save(record: unknown): Promise<void>;
}
interface Emailer {
send(to: string, subject: string): Promise<void>;
}
// Concrete implementations
class PostgresDatabase implements Database {
async save(record: unknown) { /* postgres logic */ }
}
class SendGridEmailer implements Emailer {
async send(to: string, subject: string) { /* sendgrid logic */ }
}
// High-level module depends on abstractions, not concretions
class OrderService {
constructor(
private db: Database,
private emailer: Emailer,
) {}
async placeOrder(order: Order) {
await this.db.save(order);
await this.emailer.send(order.userEmail, 'Order confirmed');
}
}
// Production
const service = new OrderService(
new PostgresDatabase(),
new SendGridEmailer(),
);
// Tests — swap with lightweight fakes
const fakeDb: Database = { save: async () => {} };
const fakeEmailer: Emailer = { send: async () => {} };
const testService = new OrderService(fakeDb, fakeEmailer);
DIP with a DI container (Node.js)
For larger apps, use a DI container like tsyringe or inversify:
import { injectable, inject, container } from 'tsyringe';
@injectable()
class OrderService {
constructor(
@inject('Database') private db: Database,
@inject('Emailer') private emailer: Emailer,
) {}
}
container.register('Database', { useClass: PostgresDatabase });
container.register('Emailer', { useClass: SendGridEmailer });
const service = container.resolve(OrderService);
Applying SOLID Together
A realistic refactor that applies all five principles:
// Before: one bloated class
class ReportGenerator {
generatePdfReport(userId: string) {
// fetch user from DB directly
// format report
// save to disk
// send email
// all here, all coupled
}
}
// After: SOLID-compliant design
// S: each class has one responsibility
// I: small, focused interfaces
interface UserRepo { findById(id: string): Promise<User> }
interface Formatter { format(user: User): Buffer }
interface FileStore { save(name: string, data: Buffer): Promise<string> }
interface Notifier { notify(to: string, link: string): Promise<void> }
// O: extend by adding new Formatter implementations
class PdfFormatter implements Formatter { format(u: User) { /* ... */ return Buffer.from(''); } }
class ExcelFormatter implements Formatter { format(u: User) { /* ... */ return Buffer.from(''); } }
// D: ReportService depends on abstractions, injected from outside
class ReportService {
constructor(
private users: UserRepo,
private formatter: Formatter,
private store: FileStore,
private notifier: Notifier,
) {}
async generate(userId: string) {
const user = await this.users.findById(userId); // L: any UserRepo subtype works
const data = this.formatter.format(user);
const link = await this.store.save(`report-${userId}.pdf`, data);
await this.notifier.notify(user.email, link);
}
}
Common Mistakes
| Mistake | Why it's a problem | Fix |
|---|---|---|
| Applying SRP by making 1 class = 1 method | Over-engineering, cohesion suffers | Group by reason to change, not by size |
| Using inheritance for code reuse only | Breaks LSP if the "is-a" relationship is false | Prefer composition |
| Fat interfaces with 10+ methods | ISP violation, painful to implement | Split into role-based interfaces |
| Skipping DIP in small projects | Works until tests become impossible | Always inject external dependencies |
| Creating abstractions for everything | Premature abstraction, extra complexity | Abstract when you have ≥2 implementations or need testability |
| Confusing OCP with never editing code | All code is editable; OCP protects stable APIs | Draw the line at public contracts |
SOLID vs Functional Programming
SOLID was designed with OOP in mind, but the ideas translate:
| SOLID Principle | Functional equivalent |
|---|---|
| SRP | Small, pure functions with one purpose |
| OCP | Higher-order functions, pipelines, composition |
| LSP | Functions must honour type contracts (no surprising null returns) |
| ISP | Accept only the data a function needs (destructure parameters) |
| DIP | Pass side effects (DB, email) as function arguments, not globals |
Frequently Asked Questions
Do I need to follow all five principles all the time?
No. Apply them where they reduce real pain: code that's hard to test, modules that change frequently, or classes that keep growing. Over-applying SOLID in a 50-line script creates unnecessary complexity.
Is SOLID only for object-oriented languages?
The acronym comes from OOP, but the underlying ideas (small modules, dependency injection, respecting contracts) apply in any paradigm.
What's the most important SOLID principle?
Dependency Inversion (DIP) and Single Responsibility (SRP) give the biggest practical return because they directly enable testability and understandability.
Can SOLID principles conflict with each other?
Yes. Strict ISP (tiny interfaces) can make DIP verbose (many injection points). Strict OCP can create deep inheritance hierarchies that violate LSP. Use judgement — principles are guides, not laws.
How do SOLID principles relate to design patterns?
Design patterns (Strategy, Observer, Factory…) are common implementations of SOLID principles. Strategy implements OCP. Factory supports DIP. Observer enables SRP by decoupling event senders from receivers.
Should I refactor existing code to be SOLID?
Refactor when you're already changing the code. Don't refactor working code just to make it SOLID — the risk of introducing bugs outweighs the theoretical benefit.