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)