Skip to main content

SOLID principles

The SOLID principles are design principles for object-oriented programming. Let's learn how to apply them in TypeScript.

What are the SOLID principles

SOLID is an acronym for the following five principles:

  • Single Responsibility Principle
  • Open-Closed Principle
  • Liskov Substitution Principle
  • Interface Segregation Principle
  • Dependency Inversion Principle

Single Responsibility Principle (SRP)

A class should have only a single responsibility.

Bad

❌ Bad: A class with multiple responsibilities
// ❌ A class with multiple responsibilities
class TodoList {
private items: string[] = [];

addItem(text: string): void {
this.items.push(text);
}

removeItem(index: number): void {
this.items.splice(index, 1);
}

toString(): string {
return this.items.join('\n');
}

// It also has the responsibility of file operations
saveToFile(filename: string): void {
// Process to save a file
}

loadFromFile(filename: string): void {
// Process to load a file
}
}

Good

✅ Good: Separate the responsibilities
// ✅ Separate the responsibilities
class TodoList {
private items: string[] = [];

addItem(text: string): void {
this.items.push(text);
}

removeItem(index: number): void {
this.items.splice(index, 1);
}

getItems(): string[] {
return [...this.items];
}

toString(): string {
return this.items.join('\n');
}
}

class FileManager {
save(filename: string, data: string): void {
// Process to save a file
}

load(filename: string): string {
// Process to load a file
return '';
}
}

// Usage
const todoList = new TodoList();
const fileManager = new FileManager();

todoList.addItem('Buy groceries');
fileManager.save('todos.txt', todoList.toString());

Open-Closed Principle (OCP)

A class should be open for extension but closed for modification.

Bad

❌ Bad: Requires modification every time a new payment method is added
// ❌ Requires modification every time a new payment method is added
class PaymentProcessor {
processPayment(amount: number, method: string): void {
if (method === 'creditCard') {
console.log(`Processing credit card payment of ${amount}`);
} else if (method === 'paypal') {
console.log(`Processing PayPal payment of ${amount}`);
} else if (method === 'bitcoin') {
// Requires modification every time a new payment method is added
console.log(`Processing Bitcoin payment of ${amount}`);
}
}
}

Good

✅ Good: Open for extension, closed for modification
// ✅ Open for extension, closed for modification
interface PaymentMethod {
process(amount: number): void;
}

class CreditCardPayment implements PaymentMethod {
process(amount: number): void {
console.log(`Processing credit card payment of ${amount}`);
}
}

class PayPalPayment implements PaymentMethod {
process(amount: number): void {
console.log(`Processing PayPal payment of ${amount}`);
}
}

// Adding a new payment method does not require modifying existing code
class BitcoinPayment implements PaymentMethod {
process(amount: number): void {
console.log(`Processing Bitcoin payment of ${amount}`);
}
}

class PaymentProcessor {
processPayment(amount: number, method: PaymentMethod): void {
method.process(amount);
}
}

// Usage
const processor = new PaymentProcessor();
processor.processPayment(100, new CreditCardPayment());
processor.processPayment(200, new BitcoinPayment());

Liskov Substitution Principle (LSP)

A derived class should be substitutable for its base class.

Bad

❌ Bad: The derived class breaks the base class's contract
// ❌ The derived class breaks the base class's contract
class Rectangle {
constructor(protected width: number, protected height: number) {}

setWidth(width: number): void {
this.width = width;
}

setHeight(height: number): void {
this.height = height;
}

getArea(): number {
return this.width * this.height;
}
}

class Square extends Rectangle {
constructor(size: number) {
super(size, size);
}

// A square must have equal width and height, so this breaks the contract
setWidth(width: number): void {
this.width = width;
this.height = width; // The height is changed too
}

setHeight(height: number): void {
this.width = height;
this.height = height; // The width is changed too
}
}

// A problem occurs
function resizeRectangle(rect: Rectangle): void {
rect.setWidth(10);
rect.setHeight(5);
console.log(rect.getArea()); // Rectangle: 50, Square: 25 (unexpected result)
}

Good

✅ Good: Define a common interface and implement each as an independent class
// ✅ Define a common interface and implement each as an independent class
interface Shape {
getArea(): number;
}

class Rectangle implements Shape {
constructor(private width: number, private height: number) {}

setWidth(width: number): void {
this.width = width;
}

setHeight(height: number): void {
this.height = height;
}

getArea(): number {
return this.width * this.height;
}
}

class Square implements Shape {
constructor(private size: number) {}

setSize(size: number): void {
this.size = size;
}

getArea(): number {
return this.size * this.size;
}
}

// A function that handles Shape works correctly for both
function printArea(shape: Shape): void {
console.log(`Area: ${shape.getArea()}`);
}

Interface Segregation Principle (ISP)

Clients should not be forced to depend on methods they do not use.

Bad

❌ Bad: An interface that is too large
// ❌ An interface that is too large
// (Worker has the same name as a standard browser type, so we use WorkerRole)
interface WorkerRole {
work(): void;
eat(): void;
sleep(): void;
}

class Human implements WorkerRole {
work(): void {
console.log('Working...');
}
eat(): void {
console.log('Eating...');
}
sleep(): void {
console.log('Sleeping...');
}
}

class Robot implements WorkerRole {
work(): void {
console.log('Working...');
}
eat(): void {
// Robots do not eat!
throw new Error('Robots do not eat');
}
sleep(): void {
// Robots do not sleep!
throw new Error('Robots do not sleep');
}
}

Good

✅ Good: Segregate the interfaces
// ✅ Segregate the interfaces
interface Workable {
work(): void;
}

interface Eatable {
eat(): void;
}

interface Sleepable {
sleep(): void;
}

class Human implements Workable, Eatable, Sleepable {
work(): void {
console.log('Working...');
}
eat(): void {
console.log('Eating...');
}
sleep(): void {
console.log('Sleeping...');
}
}

class Robot implements Workable {
work(): void {
console.log('Working...');
}
// eat and sleep are unnecessary
}

Dependency Inversion Principle (DIP)

High-level modules should not depend on low-level modules; both should depend on abstractions.

Bad

❌ Bad: Depends directly on a concrete class
// ❌ Depends directly on a concrete class
class MySQLDatabase {
save(data: string): void {
console.log(`Saving to MySQL: ${data}`);
}
}

class UserRepository {
private database = new MySQLDatabase(); // Depends on a concrete class

saveUser(user: string): void {
this.database.save(user);
}
}

Good

✅ Good: Depend on an abstraction (interface)
// ✅ Depend on an abstraction (interface)
interface Database {
save(data: string): void;
}

class MySQLDatabase implements Database {
save(data: string): void {
console.log(`Saving to MySQL: ${data}`);
}
}

class PostgreSQLDatabase implements Database {
save(data: string): void {
console.log(`Saving to PostgreSQL: ${data}`);
}
}

class UserRepository {
// Depends on the interface (dependency injection)
constructor(private database: Database) {}

saveUser(user: string): void {
this.database.save(user);
}
}

// Usage (you can switch databases easily)
const mysqlRepo = new UserRepository(new MySQLDatabase());
const postgresRepo = new UserRepository(new PostgreSQLDatabase());

Exercises

Refactor the following code to follow the SOLID principles
// Problem
class ReportGenerator {
private data: any[];

constructor(data: any[]) {
this.data = data;
}

generatePdfReport(): void {
console.log('Generating PDF report...');
// PDF generation logic
}

generateExcelReport(): void {
console.log('Generating Excel report...');
// Excel generation logic
}

generateHtmlReport(): void {
console.log('Generating HTML report...');
// HTML generation logic
}

saveToFile(filename: string): void {
console.log(`Saving to ${filename}...`);
}

sendByEmail(email: string): void {
console.log(`Sending to ${email}...`);
}
}

Answer:

✅ Good: Refactoring that follows the SOLID principles
// ✅ Refactoring that follows the SOLID principles

// A class that holds data (single responsibility)
interface ReportData {
title: string;
content: string[];
}

// The report formatter interface (OCP, ISP)
interface ReportFormatter {
format(data: ReportData): string;
}

class PdfFormatter implements ReportFormatter {
format(data: ReportData): string {
return `[PDF] ${data.title}\n${data.content.join('\n')}`;
}
}

class ExcelFormatter implements ReportFormatter {
format(data: ReportData): string {
return `[Excel] ${data.title}\n${data.content.join(',')}`;
}
}

class HtmlFormatter implements ReportFormatter {
format(data: ReportData): string {
return `<html><h1>${data.title}</h1><p>${data.content.join('</p><p>')}</p></html>`;
}
}

// The output destination interface (OCP, ISP)
interface ReportOutput {
output(content: string, destination: string): void;
}

class FileOutput implements ReportOutput {
output(content: string, destination: string): void {
console.log(`Saving to file: ${destination}`);
// File saving logic
}
}

class EmailOutput implements ReportOutput {
output(content: string, destination: string): void {
console.log(`Sending email to: ${destination}`);
// Email sending logic
}
}

// The report generation class (DIP - depends on abstractions)
class ReportGenerator {
constructor(
private formatter: ReportFormatter,
private output: ReportOutput
) {}

generate(data: ReportData, destination: string): void {
const formattedContent = this.formatter.format(data);
this.output.output(formattedContent, destination);
}
}

// Usage example
const data: ReportData = {
title: 'Monthly Report',
content: ['Item 1', 'Item 2', 'Item 3'],
};

const pdfToFile = new ReportGenerator(new PdfFormatter(), new FileOutput());
pdfToFile.generate(data, 'report.pdf');

const htmlToEmail = new ReportGenerator(new HtmlFormatter(), new EmailOutput());
htmlToEmail.generate(data, 'user@example.com');

Principles applied:

  1. SRP: Separate data, formatting, and output
  2. OCP: Easy to add new formats and output destinations
  3. LSP: Each implementation honors the interface's contract
  4. ISP: Keep interfaces small
  5. DIP: Depend on abstractions rather than concretions

Summary of the SOLID principles

LetterPrincipleOverview
SSingle responsibilityA class has only one responsibility
OOpen-closedOpen for extension, closed for modification
LLiskov substitutionDerived classes are substitutable
ISegregationProvide only the interfaces that are needed
DDependency inversionDepend on abstractions, not concretions