SOLID principles guide maintainable object-oriented design. Here's how to apply them in TypeScript.
Single Responsibility Principle#
1// ❌ Multiple responsibilities
2class UserService {
3 async createUser(data: CreateUserInput) {
4 // Validate user data
5 if (!data.email.includes('@')) {
6 throw new Error('Invalid email');
7 }
8
9 // Save to database
10 const user = await db.user.create({ data });
11
12 // Send welcome email
13 await sendEmail(user.email, 'Welcome!', '...');
14
15 // Log the action
16 console.log(`User created: ${user.id}`);
17
18 return user;
19 }
20}
21
22// ✓ Single responsibility each
23class UserValidator {
24 validate(data: CreateUserInput): ValidationResult {
25 const errors: string[] = [];
26
27 if (!data.email.includes('@')) {
28 errors.push('Invalid email');
29 }
30
31 return { valid: errors.length === 0, errors };
32 }
33}
34
35class UserRepository {
36 async create(data: CreateUserInput): Promise<User> {
37 return db.user.create({ data });
38 }
39}
40
41class EmailService {
42 async sendWelcome(email: string): Promise<void> {
43 await sendEmail(email, 'Welcome!', '...');
44 }
45}
46
47class UserLogger {
48 logCreation(userId: string): void {
49 console.log(`User created: ${userId}`);
50 }
51}
52
53class UserService {
54 constructor(
55 private validator: UserValidator,
56 private repository: UserRepository,
57 private emailService: EmailService,
58 private logger: UserLogger
59 ) {}
60
61 async createUser(data: CreateUserInput): Promise<User> {
62 const validation = this.validator.validate(data);
63 if (!validation.valid) {
64 throw new ValidationError(validation.errors);
65 }
66
67 const user = await this.repository.create(data);
68 await this.emailService.sendWelcome(user.email);
69 this.logger.logCreation(user.id);
70
71 return user;
72 }
73}Open/Closed Principle#
1// ❌ Modifying existing code for new types
2class PaymentProcessor {
3 process(payment: Payment) {
4 if (payment.type === 'credit_card') {
5 // Process credit card
6 } else if (payment.type === 'paypal') {
7 // Process PayPal
8 } else if (payment.type === 'crypto') {
9 // Added later - modifying existing code
10 }
11 }
12}
13
14// ✓ Open for extension, closed for modification
15interface PaymentMethod {
16 process(amount: number): Promise<PaymentResult>;
17 validate(): boolean;
18}
19
20class CreditCardPayment implements PaymentMethod {
21 constructor(private cardNumber: string, private cvv: string) {}
22
23 validate(): boolean {
24 return this.cardNumber.length === 16;
25 }
26
27 async process(amount: number): Promise<PaymentResult> {
28 // Credit card specific logic
29 return { success: true, transactionId: '...' };
30 }
31}
32
33class PayPalPayment implements PaymentMethod {
34 constructor(private email: string) {}
35
36 validate(): boolean {
37 return this.email.includes('@');
38 }
39
40 async process(amount: number): Promise<PaymentResult> {
41 // PayPal specific logic
42 return { success: true, transactionId: '...' };
43 }
44}
45
46// Add new payment types without modifying existing code
47class CryptoPayment implements PaymentMethod {
48 constructor(private walletAddress: string) {}
49
50 validate(): boolean {
51 return this.walletAddress.startsWith('0x');
52 }
53
54 async process(amount: number): Promise<PaymentResult> {
55 // Crypto specific logic
56 return { success: true, transactionId: '...' };
57 }
58}
59
60class PaymentProcessor {
61 async process(method: PaymentMethod, amount: number): Promise<PaymentResult> {
62 if (!method.validate()) {
63 throw new Error('Invalid payment method');
64 }
65 return method.process(amount);
66 }
67}Liskov Substitution Principle#
1// ❌ Subclass changes parent behavior unexpectedly
2class Rectangle {
3 constructor(protected width: number, protected height: number) {}
4
5 setWidth(width: number) {
6 this.width = width;
7 }
8
9 setHeight(height: number) {
10 this.height = height;
11 }
12
13 getArea(): number {
14 return this.width * this.height;
15 }
16}
17
18class Square extends Rectangle {
19 setWidth(width: number) {
20 this.width = width;
21 this.height = width; // Violates LSP!
22 }
23
24 setHeight(height: number) {
25 this.width = height;
26 this.height = height;
27 }
28}
29
30// This breaks expectations
31function resize(rect: Rectangle) {
32 rect.setWidth(10);
33 rect.setHeight(5);
34 console.log(rect.getArea()); // Expected 50, Square returns 25
35}
36
37// ✓ Proper abstraction
38interface Shape {
39 getArea(): number;
40}
41
42class Rectangle implements Shape {
43 constructor(private width: number, private height: number) {}
44
45 getArea(): number {
46 return this.width * this.height;
47 }
48}
49
50class Square implements Shape {
51 constructor(private side: number) {}
52
53 getArea(): number {
54 return this.side * this.side;
55 }
56}
57
58function printArea(shape: Shape) {
59 console.log(shape.getArea()); // Works correctly for all shapes
60}Interface Segregation Principle#
1// ❌ Fat interface
2interface Worker {
3 work(): void;
4 eat(): void;
5 sleep(): void;
6 attendMeeting(): void;
7 writeReport(): void;
8}
9
10class Robot implements Worker {
11 work() { /* ... */ }
12 eat() { throw new Error('Robots don\'t eat'); } // Forced to implement
13 sleep() { throw new Error('Robots don\'t sleep'); }
14 attendMeeting() { throw new Error('Robots don\'t attend meetings'); }
15 writeReport() { /* ... */ }
16}
17
18// ✓ Segregated interfaces
19interface Workable {
20 work(): void;
21}
22
23interface Eatable {
24 eat(): void;
25}
26
27interface Sleepable {
28 sleep(): void;
29}
30
31interface Meetable {
32 attendMeeting(): void;
33}
34
35interface Reportable {
36 writeReport(): void;
37}
38
39class Human implements Workable, Eatable, Sleepable, Meetable, Reportable {
40 work() { /* ... */ }
41 eat() { /* ... */ }
42 sleep() { /* ... */ }
43 attendMeeting() { /* ... */ }
44 writeReport() { /* ... */ }
45}
46
47class Robot implements Workable, Reportable {
48 work() { /* ... */ }
49 writeReport() { /* ... */ }
50 // Only implements what it needs
51}
52
53// Compose interfaces as needed
54interface Employee extends Workable, Eatable, Meetable {}
55interface Machine extends Workable {}Dependency Inversion Principle#
1// ❌ High-level depends on low-level
2class MySQLDatabase {
3 query(sql: string): any[] {
4 // MySQL specific implementation
5 }
6}
7
8class UserRepository {
9 private db = new MySQLDatabase(); // Direct dependency
10
11 getUsers(): User[] {
12 return this.db.query('SELECT * FROM users');
13 }
14}
15
16// ✓ Both depend on abstractions
17interface Database {
18 query<T>(sql: string): Promise<T[]>;
19 execute(sql: string, params: any[]): Promise<void>;
20}
21
22interface Repository<T> {
23 findAll(): Promise<T[]>;
24 findById(id: string): Promise<T | null>;
25 save(entity: T): Promise<T>;
26}
27
28class MySQLDatabase implements Database {
29 async query<T>(sql: string): Promise<T[]> {
30 // MySQL implementation
31 }
32
33 async execute(sql: string, params: any[]): Promise<void> {
34 // MySQL implementation
35 }
36}
37
38class PostgresDatabase implements Database {
39 async query<T>(sql: string): Promise<T[]> {
40 // Postgres implementation
41 }
42
43 async execute(sql: string, params: any[]): Promise<void> {
44 // Postgres implementation
45 }
46}
47
48class UserRepository implements Repository<User> {
49 constructor(private db: Database) {} // Injected dependency
50
51 async findAll(): Promise<User[]> {
52 return this.db.query('SELECT * FROM users');
53 }
54
55 async findById(id: string): Promise<User | null> {
56 const results = await this.db.query(
57 `SELECT * FROM users WHERE id = '${id}'`
58 );
59 return results[0] || null;
60 }
61
62 async save(user: User): Promise<User> {
63 await this.db.execute(
64 'INSERT INTO users (id, name) VALUES ($1, $2)',
65 [user.id, user.name]
66 );
67 return user;
68 }
69}
70
71// Easy to swap implementations
72const mysqlRepo = new UserRepository(new MySQLDatabase());
73const postgresRepo = new UserRepository(new PostgresDatabase());
74
75// Easy to test
76const mockDb: Database = {
77 query: jest.fn().mockResolvedValue([]),
78 execute: jest.fn(),
79};
80const testRepo = new UserRepository(mockDb);Applying All Principles#
1// Real-world example combining all principles
2
3// Interfaces (ISP)
4interface OrderValidator {
5 validate(order: Order): ValidationResult;
6}
7
8interface PaymentGateway {
9 charge(amount: number, method: PaymentMethod): Promise<PaymentResult>;
10}
11
12interface NotificationService {
13 notify(userId: string, message: string): Promise<void>;
14}
15
16interface OrderRepository {
17 save(order: Order): Promise<Order>;
18}
19
20// Implementations (OCP - can add new ones)
21class StripePaymentGateway implements PaymentGateway {
22 async charge(amount: number, method: PaymentMethod): Promise<PaymentResult> {
23 // Stripe implementation
24 }
25}
26
27class EmailNotificationService implements NotificationService {
28 async notify(userId: string, message: string): Promise<void> {
29 // Email implementation
30 }
31}
32
33// Service with single responsibility (SRP) and dependency inversion (DIP)
34class OrderService {
35 constructor(
36 private validator: OrderValidator,
37 private paymentGateway: PaymentGateway,
38 private notificationService: NotificationService,
39 private orderRepository: OrderRepository
40 ) {}
41
42 async placeOrder(order: Order): Promise<OrderResult> {
43 // Each step is a single responsibility
44 const validation = this.validator.validate(order);
45 if (!validation.valid) {
46 return { success: false, errors: validation.errors };
47 }
48
49 const payment = await this.paymentGateway.charge(
50 order.total,
51 order.paymentMethod
52 );
53
54 if (!payment.success) {
55 return { success: false, errors: ['Payment failed'] };
56 }
57
58 const savedOrder = await this.orderRepository.save({
59 ...order,
60 paymentId: payment.transactionId,
61 });
62
63 await this.notificationService.notify(
64 order.userId,
65 'Order placed successfully'
66 );
67
68 return { success: true, order: savedOrder };
69 }
70}Best Practices#
Applying SOLID:
✓ Start with clean interfaces
✓ Inject dependencies
✓ Favor composition over inheritance
✓ Keep classes focused
Balance:
✓ Don't over-engineer
✓ Apply when complexity warrants
✓ Refactor toward SOLID
✓ Consider maintenance cost
Conclusion#
SOLID principles create flexible, maintainable code. Apply them judiciously—not every class needs multiple interfaces or dependency injection. The goal is manageable complexity and easy testing, not architectural purity.