Understanding SOLID Principles: The Key to Clean and Maintainable Code

Sumit Bopche
5 min readDec 31, 2024

--

In the world of software development, speed often becomes the priority. However, haste can lead to poorly structured code, which can become unmanageable as new features and requirements are added. This makes life miserable for developers who have to maintain and extend the codebase, leading to slower development over time. SOLID principles provide a framework to counteract these issues, enabling you to write clean, maintainable, and scalable code.

What Are the SOLID Principles?

SOLID principles are five design principles that help developers create software that is easy to understand, flexible, and less prone to bugs. Here’s a breakdown:

  1. Single Responsibility Principle (SRP)
  2. Open/Closed Principle (OCP)
  3. Liskov Substitution Principle (LSP)
  4. Interface Segregation Principle (ISP)
  5. Dependency Inversion Principle (DIP)

By following these principles, you can ensure:

  • Loose coupling
  • Better maintainability
  • Easier dependency management

Why Are SOLID Principles Important?

Ignoring SOLID principles often leads to tightly coupled, fragile, and difficult-to-read codebases. This makes implementing changes or adding new features time-consuming and error-prone. For instance:

  • Without SRP, classes can become bloated, making them hard to debug.
  • Violating OCP can lead to cascading changes across the codebase.

Adhering to SOLID principles ensures:

  • Faster onboarding for new developers.
  • Easier scalability of features.
  • Reduced likelihood of bugs due to modular, testable components.

1. Single Responsibility Principle (SRP)

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

Example:

Before Applying SRP:

class Book {
title: string;
author: string;

constructor(title: string, author: string) {
this.title = title;
this.author = author;
}

getTitle(): string {
return this.title;
}

getAuthor(): string {
return this.author;
}

printBook(): void {
console.log(`Printing book: ${this.title} by ${this.author}`);
}
}

Here, the Book class has two responsibilities: managing book data and printing. This violates SRP.

After Applying SRP:

class Book {
title: string;
author: string;

constructor(title: string, author: string) {
this.title = title;
this.author = author;
}

getTitle(): string {
return this.title;
}

getAuthor(): string {
return this.author;
}

}

class BookPrinter {
print(book: Book): void {
console.log(`Printing book: ${book.getTitle()} by ${book.getAuthor()}`);
}
}

Now, each class has a single responsibility.

Why It Matters:

  • Easier to test each class independently.
  • Changes to printing logic won’t affect book data logic.

2. Open/Closed Principle (OCP)

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

Example:

Before Applying OCP:

class Circle {
draw(): void {
console.log("Drawing a circle");
}
}

class Square {
draw(): void {
console.log("Drawing a square");
}
}

If we want to add a new shape, we might modify the existing code, violating OCP.

After Applying OCP:

abstract class Shape {
abstract draw(): void;
}

class Circle extends Shape {
draw(): void {
console.log("Drawing a circle");
}
}

class Square extends Shape {
draw(): void {
console.log("Drawing a square");
}
}

class Triangle extends Shape {
draw(): void {
console.log("Drawing a triangle");
}
}

Now, adding a new shape only requires creating a new subclass of Shape.

Why It Matters:

  • Enhances scalability by minimizing changes to existing code.
  • Reduces the risk of introducing bugs in previously working functionality.

3. Liskov Substitution Principle (LSP)

Definition: Derived classes must be substitutable for their base classes without altering the correctness of the program.

Example:

Before Applying LSP:

class Bird {
fly(): void {
console.log("This bird is flying");
}
}

class Penguin extends Bird {
fly(): void {
throw new Error("Penguins cannot fly");
}
}

This violates LSP because substituting a Penguin for a Bird breaks the program.

After Applying LSP:t

abstract class Bird {
// Common bird properties
}

class FlyingBird extends Bird {
fly(): void {
console.log("This bird is flying");
}
}

class Penguin extends Bird {
// Penguins don't fly
}

Why It Matters:

  • Avoids unexpected behaviors when substituting objects.
  • Simplifies the understanding and usage of class hierarchies.

4. Interface Segregation Principle (ISP)

Definition: Clients should not be forced to depend on interfaces they do not use.

Example:

Before Applying ISP:

interface Worker {
work(): void;
eat(): void;
}

class Robot implements Worker {
work(): void {
console.log("Robot is working");
}
eat(): void {
throw new Error("Robots don't eat");
}
}

After Applying ISP:

interface Workable {
work(): void;
}

interface Eatable {
eat(): void;
}

class Human implements Workable, Eatable {
work(): void {
console.log("Human is working");
}
eat(): void {
console.log("Human is eating");
}
}

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

Why It Matters:

  • Simplifies interfaces, making them easier to understand and use.
  • Avoids implementing unnecessary methods.

5. Dependency Inversion Principle (DIP)

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

Example:

Before Applying DIP:

class Keyboard {
type(): void {
console.log("Typing on the keyboard");
}
}

class Monitor {
display(): void {
console.log("Displaying on the monitor");
}
}

class Computer {
private keyboard: Keyboard;
private monitor: Monitor;
constructor() {
this.keyboard = new Keyboard();
this.monitor = new Monitor();
}
}

After Applying DIP:

interface InputDevice {
input(): void;
}

interface OutputDevice {
output(): void;
}

class Keyboard implements InputDevice {
input(): void {
console.log("Typing on the keyboard");
}
}

class Monitor implements OutputDevice {
output(): void {
console.log("Displaying on the monitor");
}
}

class Computer {
private inputDevice: InputDevice;
private outputDevice: OutputDevice;
constructor(inputDevice: InputDevice, outputDevice: OutputDevice) {
this.inputDevice = inputDevice;
this.outputDevice = outputDevice;
}
}

Why It Matters:

  • Reduces dependencies between modules, making the system more flexible.
  • Allows swapping out implementations without altering higher-level code.

Benefits of SOLID Principles

  1. Loose Coupling: Reduces interdependencies between components.
  2. Code Maintainability: Easier to read, debug, and extend.
  3. Scalability: Easily accommodate new requirements without major refactoring.
  4. Improved Dependency Management: Clear boundaries between components.

By following SOLID principles, you’ll create software that grows gracefully over time, ensuring a better experience for developers and end-users alike.

Conclusion and Best Practices

When applying SOLID principles:

  • Focus on solving real-world problems instead of blindly enforcing rules.
  • Keep your solutions pragmatic; avoid over-engineering.
  • Use tools like linters and code analyzers to identify and maintain SOLID compliance.

Remember, these principles are guidelines, not rigid rules. Use them as a compass to write clean, maintainable code that is a joy to work with.

--

--

Sumit Bopche
Sumit Bopche

No responses yet