what-are-solid-principles/header what-are-solid-principles/header
Table of content

TL;DR

SOLID principles are essential rules for clean and maintainable software. They include SRP (single responsibility), OCP (open for extension, closed for modification), LSP (substitution), ISP (smaller interfaces), and DIP (reduced coupling). Implementing these principles leads to robust code, but beware of overengineering. Major tech companies like Netflix use SOLID principles for successful software development.

Introduction

In the world of software development, creating code that is not only functional but also maintainable and extensible is a paramount goal. This is where the SOLID principles come into play. These principles are a set of guidelines that help developers design clean, organized, and robust software. In this article, we will delve into each of the five SOLID principles and explore how they can be applied to create software that stands the test of time.

What are the SOLID Principles?

SOLID is an acronym that represents a set of five design principles in object-oriented programming and software development. These principles were introduced by Robert C. Martin and are aimed at improving the design and maintainability of software systems. Before we dive into each individual principle, let’s get an overview of what the SOLID principles are and why they are essential in software development:

Single Responsibility Principle (SRP)

The SRP states that a class should have only one reason to change. In other words, a class should have a single responsibility or job. This principle promotes code modularity and makes it easier to maintain and understand.

Open/Closed Principle (OCP)

The OCP emphasizes that software entities (classes, modules, functions) should be open for extension but closed for modification. This means you can add new functionality without changing existing code, reducing the risk of introducing bugs.

Liskov Substitution Principle (LSP)

The LSP states that objects of a derived class should be able to replace objects of the base class without affecting the correctness of the program. It ensures that inheritance hierarchies are well-designed and adhere to the “is-a” relationship.

Interface Segregation Principle (ISP)

The ISP encourages the use of smaller, more focused interfaces rather than large, monolithic ones. This principle prevents classes from being forced to implement methods they don’t need.

Dependency Inversion Principle (DIP)

The DIP promotes loose coupling by depending on abstractions rather than concrete implementations. It allows for easier substitution of components and enhances code flexibility.

Single Responsibility Principle (SRP)

The SRP dictates that each class should have only one reason to change. This means that a class should focus on a single responsibility or task. When adhering to SRP, your code becomes more modular, making it easier to maintain and extend.

For example, consider a class that handles both user authentication and sending emails. By splitting these responsibilities into two separate classes, one for authentication and another for email sending, you not only follow SRP but also make your code more reusable and easier to test.

Violating SRP

class UserManager {
  func authenticateUser(username: String, password: String) {
    // some logic here..
  }

  func sendEmail(to email: String, subject: String, message: String) {
    // some logic here..
  }
}
class UserManager {
  func authenticateUser(username: String, password: String) {
    // some logic here..
  }

  func sendEmail(to email: String, subject: String, message: String) {
    // some logic here..
  }
}

Adhering SRP

class Authenticator {
  func authenticate(username: String, password: String) {
    // some logic here..
  }
}

class EmailSender {
  func send(to email: String, subject: String, message: String) {
    // some logic here..
  }
}
class Authenticator {
  func authenticate(username: String, password: String) {
    // some logic here..
  }
}

class EmailSender {
  func send(to email: String, subject: String, message: String) {
    // some logic here..
  }
}

Open/Closed Principle (OCP)

The OCP encourages developers to design software entities in a way that allows for extension without modifying existing code. This is achieved through techniques like abstraction and interfaces.

Imagine you have a shape class hierarchy with different shapes like circles and rectangles. To add a new shape, you create a new class that inherits from a common shape interface. This adheres to OCP as you extend the system without altering existing shape classes.

Violating OCP

class Circle {
  let radius: Double

  func area() -> Double {
    // some logic here..
  }
}

class Rectangle {
  let width: Double
  let height: Double

  func area() -> Double {
    // some logic here..
  }
}
class Circle {
  let radius: Double

  func area() -> Double {
    // some logic here..
  }
}

class Rectangle {
  let width: Double
  let height: Double

  func area() -> Double {
    // some logic here..
  }
}

Adhering OCP

protocol Shape {
  func area() -> Double
}

class Circle: Shape {
  let radius: Double

  func area() -> Double {
    // some logic here..
  }
}

class Rectangle: Shape {
  let width: Double
  let height: Double

  func area() -> Double {
    // some logic here..
  }
}
protocol Shape {
  func area() -> Double
}

class Circle: Shape {
  let radius: Double

  func area() -> Double {
    // some logic here..
  }
}

class Rectangle: Shape {
  let width: Double
  let height: Double

  func area() -> Double {
    // some logic here..
  }
}

Liskov Substitution Principle (LSP)

The LSP ensures that derived classes can be used interchangeably with their base classes without introducing errors. In other words, if a program is using a base class, you should be able to replace it with any of its derived classes without impacting the program’s correctness.

A classic example is a rectangle and a square. While it might seem intuitive to model a square as a subclass of a rectangle, doing so violates the LSP. A square should not be a subclass of a rectangle because it behaves differently. This principle guides you to design your class hierarchies with care.

Violating LSP

protocol Shape {
  func area() -> Double
}

class Circle: Shape {
  let radius: Double

  func area() -> Double {
    // some logic here..
  }
}

class Rectangle: Shape {
  let width: Double
  let height: Double

  func area() -> Double {
    // some logic here..
  }
}

class Square: Rectangle {
  // nothing here, since everything needed for square is in rectangle..
}
protocol Shape {
  func area() -> Double
}

class Circle: Shape {
  let radius: Double

  func area() -> Double {
    // some logic here..
  }
}

class Rectangle: Shape {
  let width: Double
  let height: Double

  func area() -> Double {
    // some logic here..
  }
}

class Square: Rectangle {
  // nothing here, since everything needed for square is in rectangle..
}

Adhering LSP

protocol Shape {
  func area() -> Double
}

class Circle: Shape {
  let radius: Double

  func area() -> Double {
    // some logic here..
  }
}

class Rectangle: Shape {
  let width: Double
  let height: Double

  func area() -> Double {
    // some logic here..
  }
}

class Square: Shape {
  let width: Double
  let height: Double

  func area() -> Double {
    // some logic here..
  }
}
protocol Shape {
  func area() -> Double
}

class Circle: Shape {
  let radius: Double

  func area() -> Double {
    // some logic here..
  }
}

class Rectangle: Shape {
  let width: Double
  let height: Double

  func area() -> Double {
    // some logic here..
  }
}

class Square: Shape {
  let width: Double
  let height: Double

  func area() -> Double {
    // some logic here..
  }
}

Interface Segregation Principle (ISP)

The ISP advises that interfaces should be client-specific. In essence, clients should not be forced to depend on interfaces they do not use. By creating smaller, more focused interfaces, you ensure that implementing classes are only required to provide the methods relevant to their functionality.

For instance, instead of having a massive “Shape” interface with dozens of methods, you can create smaller interfaces like “AreaCalculable” and “Drawable,” each with its set of methods. Classes can then implement only the interfaces that are pertinent to their purpose.

Violating ISP

protocol Shape {
  func area() -> Double
  func draw(on canvas: Canvas)
}

class Circle: Shape {
  let radius: Double

  func area() -> Double {
    // some logic here..
  }

  func draw(on canvas: Canvas) {
    // some drawing here..
  }
}

class Rectangle: Shape {
  let width: Double
  let height: Double

  func area() -> Double {
    // some logic here..
  }

  func draw(on canvas: Canvas) {
    // some drawing here..
  }
}
protocol Shape {
  func area() -> Double
  func draw(on canvas: Canvas)
}

class Circle: Shape {
  let radius: Double

  func area() -> Double {
    // some logic here..
  }

  func draw(on canvas: Canvas) {
    // some drawing here..
  }
}

class Rectangle: Shape {
  let width: Double
  let height: Double

  func area() -> Double {
    // some logic here..
  }

  func draw(on canvas: Canvas) {
    // some drawing here..
  }
}

Adhering ISP

protocol AreaCalculable {
  func area() -> Double
}

protocol Drawable {
  func draw(on canvas: Canvas)
}

class Circle: AreaCalculable, Drawable {
  let radius: Double

  func area() -> Double {
    // some logic here..
  }

  func draw(on canvas: Canvas) {
    // some drawing here..
  }
}

class Rectangle: AreaCalculable, Drawable {
  let width: Double
  let height: Double

  func area() -> Double {
    // some logic here..
  }

  func draw(on canvas: Canvas) {
    // some drawing here..
  }
}
protocol AreaCalculable {
  func area() -> Double
}

protocol Drawable {
  func draw(on canvas: Canvas)
}

class Circle: AreaCalculable, Drawable {
  let radius: Double

  func area() -> Double {
    // some logic here..
  }

  func draw(on canvas: Canvas) {
    // some drawing here..
  }
}

class Rectangle: AreaCalculable, Drawable {
  let width: Double
  let height: Double

  func area() -> Double {
    // some logic here..
  }

  func draw(on canvas: Canvas) {
    // some drawing here..
  }
}

Dependency Inversion Principle (DIP)

The DIP promotes the use of abstractions and interfaces to reduce the coupling between different parts of your code. This principle enables more flexible and interchangeable components, making your codebase easier to maintain and extend.

For instance, instead of directly instantiating a database connection within a class, you can create an interface for the database and inject it as a dependency. This allows you to switch between different database implementations without changing the dependent class.

Violating ISP

class Auth {
  func signIn(username: String, password: String) {
    // some logic here..
  }
}

class User {
  let auth: Auth
  let username: String
  let password: String

  func signIn() {
    auth.signIn(username: username, password: password)
  }
}
class Auth {
  func signIn(username: String, password: String) {
    // some logic here..
  }
}

class User {
  let auth: Auth
  let username: String
  let password: String

  func signIn() {
    auth.signIn(username: username, password: password)
  }
}

Adhering ISP

protocol Auth {
  func signIn(username: String, password: String)
}

class FirebaseAuth {
  func signIn(username: String, password: String) {
    // some logic here..
  }
}

class SupabaseAuth {
  func signIn(username: String, password: String) {
    // some logic here..
  }
}

class User {
  let username: String
  let password: String
  
  func signIn(auth: Auth) {
    auth.signIn(username: username, password: password)
  }
}
protocol Auth {
  func signIn(username: String, password: String)
}

class FirebaseAuth {
  func signIn(username: String, password: String) {
    // some logic here..
  }
}

class SupabaseAuth {
  func signIn(username: String, password: String) {
    // some logic here..
  }
}

class User {
  let username: String
  let password: String
  
  func signIn(auth: Auth) {
    auth.signIn(username: username, password: password)
  }
}

Benefits of following SOLID principles

Following the SOLID principles in software development offers numerous benefits:

  • Improved Maintainability
  • Enhanced Reusability
  • Increased Scalability
  • Better Testability
  • Reduced Code Smells and Anti-patterns
  • Easier Collaboration
  • Flexibility and Adaptability
  • Reduced Risk of Bugs
  • Alignment with Design Patterns
  • Quality Assurance
  • Reduced Technical Debt
  • Long-Term Sustainability

Common Pitfalls and Misconceptions

While SOLID principles provide valuable guidance, there are common pitfalls to avoid:

  • Overengineering: Trying to apply SOLID principles everywhere, even when they don’t bring significant benefits, can lead to complex and overly abstract code.
  • Misinterpreting SRP: Identifying the “responsibility” of a class can sometimes be subjective. It’s essential to strike a balance between too few and too many responsibilities.
  • Overusing Interfaces: Creating numerous small interfaces for every class can lead to excessive complexity.

To avoid these pitfalls, start by gradually incorporating SOLID principles into your projects and seek feedback from peers.

Conclusion

The SOLID principles offer a solid foundation for building maintainable and extensible software. By following these guidelines, you can create code that is easier to understand, maintain, and adapt to changing requirements. Remember that mastering SOLID principles takes practice, but the benefits are well worth the effort. Start applying them in your projects today and witness the positive impact on your codebase.


Share this article