SOLID Principles

overview:

The SOLID principles are a set of design principles that aim to make software designs more understandable, flexible, and maintainable. They were introduced by Robert C. Martin, also known as Uncle Bob.

Why SOLID Principles ?

Maintainability: Following sound design principles makes code more maintainable. When code is well-structured and adheres to these principles, it becomes easier to identify and Gx issues, add new features, and make improvements without causing unintended consequences.

Scalability: Well-designed software is scalable. It can accommodate changes and growth in requirements without requiring extensive rework or becoming increasingly complex.

Code Reusability: Adhering to design principles often leads to code that is more reusable. Reusable components save time and ePort in development and testing.

Collaboration: Design principles provide a common framework for developers to work within. This common understanding promotes collaboration and reduces misunderstandings among team members.

Reduced Bugs and Pitfalls: Following design principles helps to identify and mitigate common programming pitfalls and design Kaws. This results in fewer bugs and more robust software.

Future-Proofing: Well-designed software can adapt to changing requirements and technologies. It’s an investment in the long-term viability of the software product.

Single Responsibility Principle (SRP)

The “S” in the SOLID principles stands for the Single Responsibility Principle (SRP), which states that a class should have only one reason to change or, in other words, it should have a single, well-defined responsibility or job within a software system.

Illustrating a Violation of SRP

public class Employee {

 private String name;
 private double salary;

  public void calculateSalary() {
  // definition
  }

  public void generatePayrollReport() {
  // definition
  }
}

In the above example, the Employee class has two responsibilities: calculating an employee’s salary and generating a payroll report. This violates the SRP because it has more than one reason to change.

Fixing the Violation (SRP)

let’s refactor the code to separate concerns and ensure that each class has a single, well-defined responsibility. We’ll create distinct classes for calculating an employee’s salary and generating a payroll report:

public class Employee {

 private String name;
 private double salary;

 public void calculateSalary() {
 // definition
 }
}

public class PayrollReportGenerator {

 public void generatePayrollReport(Employee employee) {
 // definition
 }
}

In the refactored solution, the responsibilities of calculating the salary and generating a payroll report have been separated into two distinct classes (Employee and PayrollReportGenerator), each with a single responsibility

Open/Closed Principle (OCP)

The Open/Closed Principle (OCP) states that software entities (such as classes, modules, and functions) should be open for extension but closed for modification. In other words, you should be able to add new functionality or behaviour to a software entity without altering its existing source code.

Illustrating a Violation of OCP

public class Rectangle {

 protected int width;
 protected int height;

 public Rectangle(int width, int height) {
  this.width = width;
  this.height = height;
 }

 public int getWidth() { 
  return width;
 }

 public void setWidth(int width) {
  this.width = width;
 }

 public int getHeight() {
  return height;
 }

 public void setHeight(int height) {
  this.height = height;
 }

 public int calculateArea() {
  return width * height;
 }
}

In the above example, the Rectangle class is designed to calculate the area of a rectangle. However, if you want to extend it to work with other shapes like circles or triangles, you would need to modify the class, violating the OCP.

Fixing the Violation (OCP)

public abstract class Shape {
 public abstract int calculateArea();
}

public class Rectangle extends Shape {
 private int width;
 private int height;

 public Rectangle(int width, int height) {
  this.width = width;
  this.height = height;
 }

 @Override
 public int calculateArea() {
  return width * height;
 }
}

public class Circle extends Shape {
 private int radius; 

 public Circle(int radius) {
  this.radius = radius;
}

 @Override
 public int calculateArea() {
  return (int) (Math.PI * radius * radius);
 }

}

In this refactored solution, the Shape class is introduced as an abstract base class for all shapes. Each specific shape (Rectangle, Circle) extends the Shape class and provides its implementation of the calculate Area method. This adheres to the OCP because you can add new shapes without modifying existing code.

Liskov Substitution Principle (LSP)

The Liskov Substitution Principle (LSP) is one of the five SOLID principles of object-oriented programming, named after Barbara Liskov, who introduced it in a 1987 conference keynote address. The principle states:

“Functions that use pointers or references to base classes must be able to use objects of derived classes without knowing it.”

In simpler terms, objects of a superclass should be replaceable with objects of a subclass without altering the correctness of the program. In other words, if class A is a subtype of class B, then instances of class B should be replaceable with instances of class A without causing issues.

Illustrating a Violation of LSP

class Bird {
 void fly() {
  System.out.println("A bird can fly.");
 }
}

class Ostrich extends Bird {
 @Override
 void fly() {
 // Ostriches cannot fly, so this method is overridden to do nothing.
 }
}

In this example, the Ostrich class is a subtype of the Bird class. However, it violates the LSP because it overrides the Ky method which is not related to Ostrich. This means that an instance of Ostrich cannot be substituted for an instance of Bird without causing unexpected behaviour.

Fixing the Violation (LSP)

class Bird {
 void move() {
 System.out.println("A bird can move.");
 }
}

class Ostrich extends Bird {
 @Override
 void move() {
  System.out.println("An Ostrich can move.");
 }
}

In this revised example, the Ostrich class is a subtype of the Bird class, and it overrides the move method to provide a meaningful behaviour that aligns with the LSP. An instance of Ostrich can be substituted for an instance of Bird without causing issues. Here is the visual representation of the classes and implementation of LSP.

Interface Segregation Principle (ISP)

The “I” in the SOLID acronym stands for the Interface Segregation Principle (ISP) which emphasizes that clients (classes or components that use interfaces) should not be forced to depend on interfaces they don’t use. In other words, an interface should have a specific and focused set of methods that are relevant to the implementing classes.

Illustrating a Violation of ISP

// Monolithic interface
 interface Worker {
 void work();
 void eat();
 void sleep();
}

In this example, the Worker interface contains three methods: work(), eat(), and sleep(). However, not all classes that implement this interface may need all these methods. This can lead to classes being forced to implement methods that are irrelevant to their functionality, violating the ISP.

Fixing the Violation (ISP)

 interface Workable {
 void work();
 }

 interface Eatable {
 void eat();
 }

 interface Sleepable {
 void sleep();
 }

In this refactored solution, the Worker interface has been split into three smaller, more focused interfaces: Workable, Eatable, and Sleepable. Now, classes can choose to implement only the interfaces that are relevant to their functionality, adhering to the ISP.

Dependency Inversion Principle (DIP)

The Dependency Inversion Principle (DIP) states that

“High-level modules should not depend on low-level modules. Both should depend on abstractions. Abstractions should not depend on details. Details should depend on abstractions.”

In simpler terms, the Dependency Inversion Principle advocates for designing software components (modules, classes, etc.) in a way that higher-level modules depend on abstractions (interfaces or abstract classes), rather than concrete implementations. Here’s a breakdown of the principle:

  1. Dependency Direction: The Dependency Inversion Principle suggests that dependencies should flow towards abstractions rather than concrete implementations. High-level modules should depend on interfaces or abstract classes, while low-level modules should implement those interfaces or inherit from those abstract classes.
  2. Decoupling: By depending on abstractions rather than concrete implementations, modules become decoupled from each other. This reduces the risk of changes in one module affecting other modules and promotes modular design.
  3. Flexibility: Depending on abstractions allows for greater flexibility in the system. Concrete implementations can be swapped out or replaced with alternative implementations without affecting the higher-level modules that depend on them. This facilitates easier maintenance and evolution of the software.
  4. Testability: Dependency inversion promotes testability by enabling dependency injection and the use of mock objects in testing. Dependencies can be easily replaced with mock objects during testing, allowing for more isolated and comprehensive unit tests.
  5. Abstraction Level: The Dependency Inversion Principle encourages defining abstractions at the appropriate level of granularity. Abstractions should capture common behaviors or concepts relevant to the modules that depend on them, without being overly specific or general.

Illustrating a Violation of DIP

class LightBulb {
 void turnOn() {
 // Code to turn on the light bulb.
 }
}

class Switch {
 private LightBulb bulb;

 Switch(LightBulb bulb) {
 this.bulb = bulb;
 }

 void operate() {
 bulb.turnOn();
 }
}

In this example, the Switch class directly depends on the LightBulb class, which is a lower-level detail. This is a violation of the DIP because high-level modules like Switch should not depend on low-level details.

Fixing the Violation (DIP)

interface Switchable {
 void turnOn();
}

class LightBulb implements Switchable {
 @Override
 public void turnOn() {
 // Code to turn on the light bulb.
 }
}

class Switch {
 private Switchable device;
 Switch(Switchable device) { 
 this.device = device;
}

 void operate() {
  device.turnOn();
 }
}

In this refactored solution, the Switch class depends on the Switchable interface, which is an abstraction. The LightBulb class implements the Switchable interface. This adheres to the DIP because high-level modules now depend on abstractions rather than low-level details.

Here are some best practices to help you implement each of the SOLID principles effectively:

  1. Single Responsibility Principle (SRP):
    • Identify cohesive responsibilities within your classes and modules.
    • Aim for classes to have only one reason to change.
    • Refactor classes that have multiple responsibilities into smaller, more focused classes.
    • Use design patterns like the Strategy pattern to separate concerns.
  2. Open/Closed Principle (OCP):
    • Design classes and modules to be open for extension but closed for modification.
    • Utilize abstraction and polymorphism to allow for extension through inheritance or composition.
    • Apply the Dependency Inversion Principle to depend on abstractions rather than concrete implementations.
    • Use design patterns like the Template Method pattern or the Strategy pattern to enable customization without modification.
  3. Liskov Substitution Principle (LSP):
    • Ensure that subclasses can be substituted for their base classes without affecting the correctness of the program.
    • Respect preconditions, postconditions, and invariants established by the base class.
    • Design class hierarchies with care, avoiding overriding methods in a way that alters behavior or breaks contracts.
    • Write comprehensive unit tests to verify that subclasses adhere to the LSP.
  4. Interface Segregation Principle (ISP):
    • Identify and define cohesive interfaces that represent specific sets of behaviors.
    • Avoid creating large, monolithic interfaces that force clients to depend on methods they don’t use.
    • Split large interfaces into smaller, client-specific interfaces.
    • Apply the Adapter pattern when working with existing interfaces that violate the ISP.
  5. Dependency Inversion Principle (DIP):
    • Design modules and components to depend on abstractions rather than concrete implementations.
    • Use dependency injection to provide dependencies to classes from external sources.
    • Implement inversion of control (IoC) containers or frameworks to manage dependencies automatically.
    • Apply the Dependency Injection pattern to facilitate unit testing and promote decoupling.

In addition to these best practices, it’s essential to continuously review and refactor your codebase to ensure that SOLID principles are being followed effectively. Regular code reviews, pair programming, and adherence to coding standards can help maintain SOLID design principles throughout the development lifecycle.

Leave a Reply