DEV Community

Cover image for Mastering the SOLID Principles — The Foundation of Clean, Scalable Code
coder7475
coder7475

Posted on

Mastering the SOLID Principles — The Foundation of Clean, Scalable Code

If you want to write cleaner, more maintainable, and scalable software, mastering the SOLID principles is essential. These five core object-oriented design principles empower you to build systems that are easier to understand, extend, and test — without introducing chaos.

Here's a quick breakdown:

S — Single Responsibility Principle (SRP)

Every class or module should have one clear responsibility.

When a class tries to do too many things, it becomes harder to maintain and more prone to bugs.

O — Open/Closed Principle (OCP)

Software should be open for extension, but closed for modification.

You should be able to add new features without rewriting existing code, minimizing the risk of regressions.

L — Liskov Substitution Principle (LSP)

Subtypes must be replaceable for their base types without altering the program’s behavior.

If a subclass breaks the expectations set by the parent class, it violates this principle.

I — Interface Segregation Principle (ISP)

Don't force a class to implement methods it doesn't need.

Instead of one large interface, create smaller, role-specific interfaces.

D — Dependency Inversion Principle (DIP)

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

This decouples your code and makes it easier to modify and test.


The SOLID principles are foundational guidelines for object-oriented design. They help you create clean, maintainable, and scalable software systems. This guide includes detailed explanations and practical TypeScript examples to illustrate each principle.

S — Single Responsibility Principle (SRP)

Definition: A class should have only one reason to change, meaning it should have a single, well-defined responsibility.

Why It Matters: Mixing responsibilities (e.g., validation and persistence) makes a class fragile—changes to one task can break others. SRP improves modularity and testability.

Violation Example:

// SRP Violating Example
interface Order {
    id: number;
    items: string[];
    total: number;
    customerEmail: string;
}

class OrderProcessor {
    processOrder(order: Order): void {
        if (!order) throw new Error("Order cannot be null");

        // 1. Validate order
        if (order.items.length === 0) {
            throw new Error("Order must have at least one item");
        }

        // 2. Process payment
        console.log(`Processing payment of $${order.total} for order ${order.id}`);

        // 3. Save to database
        console.log(`Saving order ${order.id} with ${order.items.length} items to database`);

        // 4. Send confirmation email
        console.log(`Sending confirmation email to ${order.customerEmail}`);
    }
}
Enter fullscreen mode Exit fullscreen mode

This class handles too much, making it hard to maintain.

Refactored (SRP Applied):

interface Order {
    id: number;
    items: string[];
    total: number;
}

class OrderValidator {
    validate(order: Order): void {
        if (!order) throw new Error("Order cannot be null");
    }
}

class PaymentProcessor {
    processPayment(order: Order): void {
        console.log(`Processing payment for order ${order.id}`);
    }
}

class OrderRepository {
    save(order: Order): void {
        console.log(`Saving order ${order.id} to database`);
    }
}

class EmailNotifier {
    sendConfirmation(order: Order): void {
        console.log(`Sending confirmation email for order ${order.id}`);
    }
}

class OrderProcessor {
    private validator: OrderValidator;
    private paymentProcessor: PaymentProcessor;
    private repository: OrderRepository;
    private notifier: EmailNotifier;

    constructor(
        validator: OrderValidator,
        paymentProcessor: PaymentProcessor,
        repository: OrderRepository,
        notifier: EmailNotifier
    ) {
        this.validator = validator;
        this.paymentProcessor = paymentProcessor;
        this.repository = repository;
        this.notifier = notifier;
    }

    process(order: Order): void {
        this.validator.validate(order);
        this.paymentProcessor.processPayment(order);
        this.repository.save(order);
        this.notifier.sendConfirmation(order);
    }
}
Enter fullscreen mode Exit fullscreen mode

Each class now focuses on one task, enhancing clarity and flexibility.


O — Open/Closed Principle (OCP)

Definition: Software entities should be open for extension but closed for modification.

Why It Matters: Modifying existing code risks bugs. OCP allows new functionality via extensions (e.g., interfaces) without altering tested code.

Violation Example:

class InvoicePrinter {
    print(invoice: any, format: string): void {
        if (format === "PDF") {
            // Generate PDF
        } else if (format === "Excel") {
            // Generate Excel
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Adding a new format requires changing this class.

Refactored (OCP Applied):

interface Invoice {
    id: number;
    amount: number;
}

interface IInvoicePrinter {
    print(invoice: Invoice): void;
}

class PdfInvoicePrinter implements IInvoicePrinter {
    print(invoice: Invoice): void {
        console.log(`Printing PDF invoice for invoice ${invoice.id}`);
    }
}

class ExcelInvoicePrinter implements IInvoicePrinter {
    print(invoice: Invoice): void {
        console.log(`Printing Excel invoice for invoice ${invoice.id}`);
    }
}

class InvoiceService {
    private printer: IInvoicePrinter;

    constructor(printer: IInvoicePrinter) {
        if (!printer) throw new Error("Printer cannot be null");
        this.printer = printer;
    }

    printInvoice(invoice: Invoice): void {
        this.printer.print(invoice);
    }
}
Enter fullscreen mode Exit fullscreen mode

New printers (e.g., HtmlInvoicePrinter) can be added without modifying InvoiceService.


L — Liskov Substitution Principle (LSP)

Definition: Subtypes must be substitutable for their base types without altering expected behavior.

Why It Matters: Violating LSP causes errors when subclasses don’t meet the parent’s contract.

Violation Example:

class Vehicle {
    startEngine(): void {
        // Start engine
    }
}

class ElectricCar extends Vehicle {
    startEngine(): void {
        throw new Error("Electric cars don’t have engines!");
    }
}
Enter fullscreen mode Exit fullscreen mode

ElectricCar breaks code expecting startEngine.

Refactored (LSP Respected):

abstract class Vehicle {
    abstract start(): void;
}

interface ICombustionVehicle {
    startEngine(): void;
}

class GasolineCar extends Vehicle implements ICombustionVehicle {
    start(): void {
        this.startEngine();
    }

    startEngine(): void {
        console.log("Gasoline engine started.");
    }
}

class ElectricCar extends Vehicle {
    start(): void {
        console.log("Electric motor activated.");
    }
}
Enter fullscreen mode Exit fullscreen mode

Both subclasses work as Vehicle without issues.


I — Interface Segregation Principle (ISP)

Definition: Classes shouldn’t be forced to implement unused interfaces.

Why It Matters: Large interfaces create unnecessary dependencies. ISP favors smaller, specific interfaces.

Violation Example:

interface IWorker {
    work(): void;
    takeBreak(): void;
}

class Robot implements IWorker {
    work(): void {
        console.log("Robot working...");
    }

    takeBreak(): void {
        throw new Error("Robots don’t take breaks!");
    }
}
Enter fullscreen mode Exit fullscreen mode

Robot is forced to implement an irrelevant method.

Refactored (ISP Applied):

interface IWorkable {
    work(): void;
}

interface IBreakable {
    takeBreak(): void;
}

class Human implements IWorkable, IBreakable {
    work(): void {
        console.log("Human working...");
    }

    takeBreak(): void {
        console.log("Human taking a break...");
    }
}

class Robot implements IWorkable {
    work(): void {
        console.log("Robot working...");
    }
}
Enter fullscreen mode Exit fullscreen mode

Robot only implements what it needs.


D — Dependency Inversion Principle (DIP)

Definition: High-level modules should depend on abstractions, not low-level details.

Why It Matters: Tight coupling limits flexibility. DIP uses abstractions for loose coupling.

Violation Example:

class Notification {
    private emailService = new EmailService();

    send(message: string): void {
        this.emailService.sendEmail(message);
    }
}

class EmailService {
    sendEmail(message: string): void {
        console.log(`Email sent: ${message}`);
    }
}
Enter fullscreen mode Exit fullscreen mode

Notification is tied to EmailService.

Refactored (DIP Applied):

interface IMessageService {
    send(message: string): void;
}

class EmailService implements IMessageService {
    send(message: string): void {
        console.log(`Email sent: ${message}`);
    }
}

class SmsService implements IMessageService {
    send(message: string): void {
        console.log(`SMS sent: ${message}`);
    }
}

class Notification {
    private service: IMessageService;

    constructor(service: IMessageService) {
        if (!service) throw new Error("Service cannot be null");
        this.service = service;
    }

    send(message: string): void {
        this.service.send(message);
    }
}
Enter fullscreen mode Exit fullscreen mode

Notification works with any IMessageService implementation.

Final Thoughts

Applying SOLID in TypeScript yields cleaner, more testable, and adaptable code.

Top comments (0)