Back to Blog
SOLIDTypeScriptDesign PrinciplesOOP

SOLID Principles in TypeScript

Apply SOLID principles effectively. From single responsibility to dependency inversion with practical examples.

B
Bootspring Team
Engineering
May 20, 2022
7 min read

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.

Share this article

Help spread the word about Bootspring