SOLID Principles Explained with Real Examples
A comprehensive guide to the five SOLID principles with practical TypeScript examples that show how each principle improves your code.
The SOLID principles are the cornerstone of object-oriented design. Coined by Robert C. Martin, these five principles guide developers toward writing code that is maintainable, extensible, and resilient to change. Let’s break down each one with real-world TypeScript examples.
S — Single Responsibility Principle (SRP)
A class should have only one reason to change. This means each class should do one thing and do it well.
The Problem
// ❌ This class does too many things
class UserService {
createUser(name: string, email: string) {
// Validate input
if (!email.includes('@')) throw new Error('Invalid email');
// Save to database
const user = { id: Date.now(), name, email };
db.query('INSERT INTO users...', user);
// Send welcome email
const html = `<h1>Welcome ${name}!</h1>`;
smtpClient.send({ to: email, subject: 'Welcome', html });
// Log the action
fs.appendFileSync('audit.log', `User created: ${email}\n`);
return user;
}
}
This class has four reasons to change: validation rules, database schema, email templates, and logging format.
The Solution
// ✅ Each class has a single responsibility
class UserValidator {
validate(data: { name: string; email: string }): void {
if (!data.email.includes('@')) {
throw new ValidationError('Invalid email format');
}
if (data.name.length < 2) {
throw new ValidationError('Name too short');
}
}
}
class UserRepository {
async create(data: { name: string; email: string }): Promise<User> {
return db.query('INSERT INTO users (name, email) VALUES ($1, $2)',
[data.name, data.email]);
}
}
class WelcomeEmailService {
async send(user: User): Promise<void> {
const html = this.renderTemplate(user);
await this.mailer.send({ to: user.email, subject: 'Welcome', html });
}
private renderTemplate(user: User): string {
return `<h1>Welcome ${user.name}!</h1>`;
}
}
class AuditLogger {
log(action: string, details: Record<string, unknown>): void {
const entry = { timestamp: new Date(), action, ...details };
this.writer.append(JSON.stringify(entry));
}
}
// Orchestrator that composes the single-responsibility classes
class UserRegistrationService {
constructor(
private validator: UserValidator,
private repository: UserRepository,
private emailService: WelcomeEmailService,
private logger: AuditLogger,
) {}
async register(name: string, email: string): Promise<User> {
this.validator.validate({ name, email });
const user = await this.repository.create({ name, email });
await this.emailService.send(user);
this.logger.log('user_created', { userId: user.id, email });
return user;
}
}
Each class now has exactly one reason to change, and testing becomes straightforward — you can mock any dependency independently.
O — Open/Closed Principle (OCP)
Software entities should be open for extension but closed for modification. You should be able to add new behavior without changing existing code.
The Problem
// ❌ Adding a new payment method requires modifying this function
function processPayment(method: string, amount: number): void {
if (method === 'credit_card') {
chargeCreditCard(amount);
} else if (method === 'paypal') {
chargePayPal(amount);
} else if (method === 'bitcoin') {
chargeBitcoin(amount);
}
// Every new method = change this function
}
The Solution
// ✅ Open for extension, closed for modification
interface PaymentProcessor {
readonly name: string;
process(amount: number): Promise<PaymentResult>;
}
class CreditCardProcessor implements PaymentProcessor {
readonly name = 'credit_card';
async process(amount: number): Promise<PaymentResult> {
return stripe.charges.create({ amount, currency: 'usd' });
}
}
class PayPalProcessor implements PaymentProcessor {
readonly name = 'paypal';
async process(amount: number): Promise<PaymentResult> {
return paypal.createPayment({ amount });
}
}
// Adding crypto? Just create a new class — no existing code changes
class CryptoProcessor implements PaymentProcessor {
readonly name = 'crypto';
async process(amount: number): Promise<PaymentResult> {
return cryptoGateway.charge({ amount });
}
}
class PaymentService {
private processors = new Map<string, PaymentProcessor>();
register(processor: PaymentProcessor): void {
this.processors.set(processor.name, processor);
}
async pay(method: string, amount: number): Promise<PaymentResult> {
const processor = this.processors.get(method);
if (!processor) throw new Error(`Unknown payment method: ${method}`);
return processor.process(amount);
}
}
L — Liskov Substitution Principle (LSP)
Subtypes must be substitutable for their base types. If your code works with a base class, it should work with any derived class without surprises.
The Classic Violation
// ❌ Square violates LSP when inheriting from Rectangle
class Rectangle {
constructor(protected width: number, protected height: number) {}
setWidth(w: number): void { this.width = w; }
setHeight(h: number): void { this.height = h; }
area(): number { return this.width * this.height; }
}
class Square extends Rectangle {
setWidth(w: number): void {
this.width = w;
this.height = w; // Surprise! Setting width also changes height
}
setHeight(h: number): void {
this.width = h;
this.height = h;
}
}
// This test PASSES for Rectangle but FAILS for Square
function testArea(rect: Rectangle): void {
rect.setWidth(5);
rect.setHeight(4);
console.assert(rect.area() === 20); // Square gives 16!
}
The Fix
// ✅ Use composition or separate interfaces
interface Shape {
area(): number;
}
class Rectangle implements Shape {
constructor(readonly width: number, readonly height: number) {}
area(): number { return this.width * this.height; }
}
class Square implements Shape {
constructor(readonly side: number) {}
area(): number { return this.side * this.side; }
}
// Both work correctly as Shape — no surprises
function printArea(shape: Shape): void {
console.log(`Area: ${shape.area()}`);
}
I — Interface Segregation Principle (ISP)
Clients should not be forced to depend on interfaces they do not use. Prefer many small, focused interfaces over one large one.
// ❌ Fat interface — forces implementors to handle things they don't need
interface Worker {
work(): void;
eat(): void;
sleep(): void;
attendMeeting(): void;
writeReport(): void;
}
// ✅ Segregated interfaces
interface Workable {
work(): void;
}
interface Feedable {
eat(): void;
}
interface Reportable {
writeReport(): void;
}
// A robot only needs to implement what it actually does
class Robot implements Workable {
work(): void {
console.log('Processing tasks...');
}
}
// A human employee can implement multiple interfaces
class Employee implements Workable, Feedable, Reportable {
work(): void { /* ... */ }
eat(): void { /* ... */ }
writeReport(): void { /* ... */ }
}
D — Dependency Inversion Principle (DIP)
High-level modules should not depend on low-level modules. Both should depend on abstractions.
// ❌ High-level module depends directly on low-level implementation
class OrderService {
private db = new MySQLDatabase(); // Tight coupling!
async createOrder(items: Item[]): Promise<Order> {
return this.db.insert('orders', { items });
}
}
// ✅ Both depend on an abstraction
interface OrderRepository {
create(items: Item[]): Promise<Order>;
findById(id: string): Promise<Order | null>;
}
class MySQLOrderRepository implements OrderRepository {
async create(items: Item[]): Promise<Order> {
return this.db.insert('orders', { items });
}
async findById(id: string): Promise<Order | null> {
return this.db.query('SELECT * FROM orders WHERE id = ?', [id]);
}
}
class InMemoryOrderRepository implements OrderRepository {
private orders: Order[] = [];
async create(items: Item[]): Promise<Order> {
const order = { id: crypto.randomUUID(), items, createdAt: new Date() };
this.orders.push(order);
return order;
}
async findById(id: string): Promise<Order | null> {
return this.orders.find(o => o.id === id) ?? null;
}
}
// High-level module depends on abstraction
class OrderService {
constructor(private repository: OrderRepository) {}
async createOrder(items: Item[]): Promise<Order> {
if (items.length === 0) throw new Error('Order must have items');
return this.repository.create(items);
}
}
// In production
const service = new OrderService(new MySQLOrderRepository());
// In tests
const service = new OrderService(new InMemoryOrderRepository());
Putting It All Together
SOLID principles are not independent rules — they reinforce each other:
- SRP keeps classes focused, making them easier to extend (OCP)
- LSP ensures your abstractions are trustworthy, enabling DIP
- ISP prevents bloated interfaces, supporting both SRP and DIP
The goal isn’t to apply every principle in every class. Use them as guidelines that help you make better design decisions. When your code is hard to change, hard to test, or hard to understand — that’s when SOLID shows you the path forward.
“The secret to building large apps is never build large apps. Break your applications into small pieces. Then, assemble those testable, bite-sized pieces into your big application.” — Justin Meyer