The SOLID Principles
SOLID is an acronym for five design principles that make software designs more understandable, flexible, and maintainable. Introduced by Robert C. Martin, these principles form the foundation of good object-oriented design and apply directly to TypeScript class design.
S — Single Responsibility Principle (SRP)
A class should have only one reason to change. Each class should encapsulate a single responsibility. If a class has multiple responsibilities, changes to one responsibility may break the other.
// BAD: Multiple responsibilities in one class
class UserService {
async createUser(data: UserData): Promise {
// Responsibility 1: Validation
if (!data.email.includes("@")) throw new Error("Invalid email");
// Responsibility 2: Persistence
const user = await db.query("INSERT INTO users ...", [data]);
// Responsibility 3: Notification
await sendEmail(data.email, "Welcome!", "Thanks for joining...");
// Responsibility 4: Logging
console.log(`User created: ${user.id}`);
return user;
}
}
// GOOD: Each class has a single responsibility
class UserValidator {
validate(data: UserData): ValidationResult {
const errors: string[] = [];
if (!data.email.includes("@")) errors.push("Invalid email");
if (data.name.length < 2) errors.push("Name too short");
return { valid: errors.length === 0, errors };
}
}
class UserRepository {
async save(user: User): Promise {
await this.pool.query("INSERT INTO users ...", [user]);
}
}
class WelcomeEmailSender {
async send(email: string, userName: string): Promise {
await this.mailer.send({ to: email, subject: "Welcome!", body: `Hi ${userName}` });
}
}
class CreateUserUseCase {
constructor(
private validator: UserValidator,
private repo: UserRepository,
private emailSender: WelcomeEmailSender,
private logger: Logger
) {}
async execute(data: UserData): Promise {
const validation = this.validator.validate(data);
if (!validation.valid) throw new ValidationError(validation.errors);
const user = User.create(data);
await this.repo.save(user);
await this.emailSender.send(user.email, user.name);
this.logger.info("User created", { userId: user.id });
return user;
}
}
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. This is typically achieved through abstractions and polymorphism.
// BAD: Modifying existing code to add new payment methods
class PaymentProcessor {
process(payment: Payment): void {
if (payment.type === "credit_card") {
// process credit card
} else if (payment.type === "paypal") {
// process PayPal
} else if (payment.type === "crypto") {
// Had to modify this class to add crypto!
}
}
}
// GOOD: Open for extension via new implementations
interface PaymentStrategy {
supports(type: string): boolean;
process(payment: Payment): Promise;
}
class CreditCardPayment implements PaymentStrategy {
supports(type: string): boolean { return type === "credit_card"; }
async process(payment: Payment): Promise {
// Credit card processing logic
return { success: true, transactionId: "cc_123" };
}
}
class PayPalPayment implements PaymentStrategy {
supports(type: string): boolean { return type === "paypal"; }
async process(payment: Payment): Promise {
return { success: true, transactionId: "pp_456" };
}
}
// Adding crypto: NO changes to existing code!
class CryptoPayment implements PaymentStrategy {
supports(type: string): boolean { return type === "crypto"; }
async process(payment: Payment): Promise {
return { success: true, transactionId: "btc_789" };
}
}
class PaymentProcessor {
constructor(private strategies: PaymentStrategy[]) {}
async process(payment: Payment): Promise {
const strategy = this.strategies.find(s => s.supports(payment.type));
if (!strategy) throw new Error(`Unsupported payment type: ${payment.type}`);
return strategy.process(payment);
}
}
L — Liskov Substitution Principle (LSP)
Objects of a superclass should be replaceable with objects of a subclass without affecting the correctness of the program. Subtypes must be substitutable for their base types.
// BAD: Square violates LSP when substituted for 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; } // Breaks LSP!
setHeight(h: number): void { this.width = h; this.height = h; }
}
// This test fails with Square but passes with Rectangle
function testArea(rect: Rectangle): void {
rect.setWidth(5);
rect.setHeight(4);
console.assert(rect.area() === 20); // Square gives 16!
}
// GOOD: Use interfaces and composition
interface Shape {
area(): number;
}
class RectangleShape implements Shape {
constructor(private readonly width: number, private readonly height: number) {}
area(): number { return this.width * this.height; }
}
class SquareShape implements Shape {
constructor(private readonly side: number) {}
area(): number { return this.side * this.side; }
}
// Both work correctly when used as Shape
function printArea(shape: Shape): void {
console.log(`Area: ${shape.area()}`);
}
I — Interface Segregation Principle (ISP)
No client should be forced to depend on methods it does not use. Large interfaces should be split into smaller, more specific ones so that clients only need to know about the methods that are of interest to them.
// BAD: Fat interface forces implementors to provide unused methods
interface Worker {
work(): void;
eat(): void;
sleep(): void;
attendMeeting(): void;
writeCode(): void;
reviewCode(): void;
}
// A robot worker does not eat or sleep!
class RobotWorker implements Worker {
work(): void { /* OK */ }
eat(): void { throw new Error("Robots don't eat"); } // Forced!
sleep(): void { throw new Error("Robots don't sleep"); } // Forced!
attendMeeting(): void { /* OK */ }
writeCode(): void { /* OK */ }
reviewCode(): void { /* OK */ }
}
// GOOD: Segregated interfaces
interface Workable {
work(): void;
}
interface Feedable {
eat(): void;
sleep(): void;
}
interface Collaboratable {
attendMeeting(): void;
}
interface Codeable {
writeCode(): void;
reviewCode(): void;
}
class HumanDeveloper implements Workable, Feedable, Collaboratable, Codeable {
work(): void { /* ... */ }
eat(): void { /* ... */ }
sleep(): void { /* ... */ }
attendMeeting(): void { /* ... */ }
writeCode(): void { /* ... */ }
reviewCode(): void { /* ... */ }
}
class RobotDeveloper implements Workable, Codeable {
work(): void { /* ... */ }
writeCode(): void { /* ... */ }
reviewCode(): void { /* ... */ }
// No eat() or sleep() required!
}
D — Dependency Inversion Principle (DIP)
High-level modules should not depend on low-level modules. Both should depend on abstractions. Abstractions should not depend on details. Details should depend on abstractions.
// BAD: High-level module depends on low-level module
class MySQLDatabase {
query(sql: string): Promise { /* MySQL specific */ return Promise.resolve([]); }
}
class UserService {
private db = new MySQLDatabase(); // Direct dependency on MySQL!
async getUsers(): Promise {
return this.db.query("SELECT * FROM users");
}
}
// GOOD: Both depend on abstraction
interface Database {
query(sql: string, params?: unknown[]): Promise;
}
class MySQLDatabase implements Database {
async query(sql: string, params?: unknown[]): Promise {
// MySQL implementation
return [];
}
}
class PostgreSQLDatabase implements Database {
async query(sql: string, params?: unknown[]): Promise {
// PostgreSQL implementation
return [];
}
}
class UserService {
constructor(private readonly db: Database) {} // Depends on abstraction
async getUsers(): Promise {
return this.db.query("SELECT * FROM users");
}
}
// Can use any database implementation
const service1 = new UserService(new MySQLDatabase());
const service2 = new UserService(new PostgreSQLDatabase());
Applying SOLID in Practice
- Start pragmatically: Do not over-engineer from the start — apply SOLID when complexity requires it
- SRP first: Single Responsibility is the most impactful principle — start with it
- OCP through strategy pattern: When you find yourself adding if/else branches, consider the strategy pattern
- DIP for testability: If you cannot test a class easily, it likely violates DIP