What are Solid Principles? (Five Object-Oriented Design Solid Principles)

SOLID principles represent a set of five fundamental object-oriented design (OOD) principles introduced by Robert C. Martin.

The SOLID principles provide guidelines for building software that is easier to maintain and scale as a project grows. These principles can help eliminate code smells, simplify code refactoring, and support Agile or Adaptive development practices.

This guide introduces each principle individually, explaining how they can enhance your development skills and help create better, more robust java code.

What is Solid Principles?

The acronym SOLID represents:

  • S: Single Responsibility Principle
  • O: Open-Closed Principle
  • L: Liskov Substitution Principle
  • I: Interface Segregation Principle
  • D: Dependency Inversion Principle

Single Responsibility Principle

The Single Responsibility Principle means that each class should focus on only one specific task or purpose, ensuring it has just one reason to be changed.

In technical terms, a class's design should be influenced by only one type of change, such as database logic or logging logic. For example, if a class represents a data entity like a Book or a Student, it should only be updated when there is a change in the structure or attributes of that entity.

Adhering to this principle has significant benefits. It reduces the risk of multiple teams working on the same class for unrelated purposes, which could otherwise lead to conflicting or incompatible changes.

Additionally, it simplifies version control. For instance, if a class dedicated to database operations is updated, we can easily identify the changes related to database functionality.

This principle also minimizes merge conflicts during collaborative development. Ensuring each class has a single purpose, fewer changes overlap, making conflicts less frequent and easier to address.

Example

Here’s an example demonstrating the Single Responsibility Principle. Let's consider a Student class:

Before Applying the SRP (Single Responsibility Principle), the Student class handles both student-specific responsibilities and a notification-related responsibility, which violates SRP.

public class Student {
public String getDetails(int studentID) { 
// Logic to fetch student details
}
public void updateGrade(int studentID, int grade) { 
// Logic to update student grade
}
public void sendNotification(String message) { 
// Logic to send notifications to the student
}
}

After applying the Single Responsibility Principle, we separate the notification responsibility into a new NotificationService class:

public class Student {
public String getDetails(int studentID) { 
// Logic to fetch student details
}
public void updateGrade(int studentID, int grade) { 
// Logic to update student grade
}
}
public class NotificationService {
public void sendNotification(String message) { 
// Logic to send notifications to students
}
}

The Student class now focuses only on student-related behaviors.

Notification logic is encapsulated within the NotificationService class, making the code easier to manage and modify.

Each class has a clear and single responsibility, reducing coupling and improving maintainability.

Open-Closed Principle

The Open/Closed Principle states that "software entities (like classes, modules, or functions) should be open for extension but closed for modification." This means you can add new functionality to a system without altering the existing code.

Explore this principle with an example

Imagine you have a Shape class used to calculate the area of different shapes in a geometry application. Initially, the class only supports calculating the area of rectangles. Later, you want to add support for calculating the area of circles.

Instead of modifying the existing Shape class to include the logic for circles, you can create a new class, such as Circle, that extends the behavior. This keeps the Shape class unchanged but allows for the new functionality to be added.

Here’s how this can be implemented:

// Base class
public abstract class Shape {
public abstract double calculateArea();
}
// Rectangle class
public class Rectangle extends Shape {
private double length;
private double width;
public Rectangle(double length, double width) {
this.length = length;
this.width = width;
}
@Override
public double calculateArea() {
return length * width;
}
}
// Circle class
public class Circle extends Shape {
private double radius;
public Circle(double radius) {
this.radius = radius;
}
@Override
public double calculateArea() {
return Math.PI * radius * radius;
}
}

In the above code, according to the Open/Closed Principle:

The Shape class is not modified, ensuring stability in existing code. Adding new shapes (like Triangle or Square) is straightforward by extending the Shape class. This approach promotes code reusability, flexibility, and maintainability.

Liskov Substitution Principle

The Liskov Substitution Principle, introduced by Barbara Liskov in 1987, states that “subtypes must be substitutable for their base types.” This means any child class should be able to replace its parent class without causing unexpected behavior.

To understand this principle, consider the example of a Bird class. A Bird generally has behaviors like flying. A specific type of bird, such as a Penguin, can inherit from the Bird class. However, penguins cannot fly, which would lead to a violation of the Liskov Substitution Principle if flying behavior is directly inherited.

Example

Here’s how it can be explained through code:

Below is the Violation of the Liskov Substitution Principle:

// Parent class
public class Bird {
public void fly() {
System.out.println("Flying...");
}
}
// Child class
public class Penguin extends Bird {
@Override
public void fly() {
throw new UnsupportedOperationException("Penguins can't fly");
}
}

In this example, substituting Penguin for Bird results in unexpected behavior because the Penguin class overrides the fly method with an exception.

To apply the Liskov Substitution Principle, we can restructure the code by separating flying behavior into a different abstraction:

// Base class
public abstract class Bird {
public abstract void eat();
}
// Flying behavior
public interface Flyable {
void fly();
}
// Subclass for birds that can fly
public class Sparrow extends Bird implements Flyable {
@Override
public void eat() {
System.out.println("Sparrow is eating...");
}
@Override
public void fly() {
System.out.println("Sparrow is flying...");
}
}
// Subclass for birds that cannot fly
public class Penguin extends Bird {
@Override
public void eat() {
System.out.println("Penguin is eating...");
}
}

The Bird class now focuses only on shared behaviors like eating.

Flying behavior is isolated, and only birds that can fly implement the Flyable interface.

Substituting any bird for the Bird class works as expected, maintaining consistency and avoiding runtime errors. 

The above code example ensures that child classes can be substituted for their parent without violating the Liskov Substitution Principle.

Interface Segregation Principle

The Interface Segregation Principle focuses on interfaces rather than classes and is closely related to the Single Responsibility Principle. It states, "No client should be forced to implement an interface it does not use."

The idea is to avoid creating large, generic interfaces and instead break them into smaller, more specific ones that cater to the needs of individual clients.

In simpler terms interface segregation principle, a class or entity should only be required to implement methods that are relevant to its specific purpose. This reduces unnecessary dependencies and ensures flexibility in the design.

Example

Imagine a library management system. There is a general interface, LibraryServices, that includes methods for borrowing books, returning books, paying late fees, and reserving e-books.

Now, consider that there are two types of users: in-person visitors and online users.

In-person visitors only need access to borrowing and returning books.

Online users are focused on reserving e-books and might not use borrowing or returning services.

Having a single LibraryServices interface for both types of users forces each to implement methods they don’t need, violating the Interface Segregation Principle.

Violation of the Interface Segregation Principle

// Fat interface with unrelated methods
public interface LibraryServices {
void borrowBook();
void returnBook();
void payLateFee();
void reserveEBook();
}
// In-person visitor implements all methods but uses only a few
public class InPersonVisitor implements LibraryServices {
@Override
public void borrowBook() {
System.out.println("Borrowing a book...");
}
@Override
public void returnBook() {
System.out.println("Returning a book...");
}
@Override
public void payLateFee() {
System.out.println("Paying a late fee...");
}
@Override
public void reserveEBook() {
// Not applicable for in-person visitors
throw new UnsupportedOperationException("Not applicable for in-person visitors");
}
}

By adhering to the Interface Segregation Principle, the single, large interface is divided into smaller, client-specific interfaces.

// Smaller, focused interfaces
public interface BookBorrowing {
void borrowBook();
void returnBook();
}
public interface FeePayment {
void payLateFee();
}
public interface EBookReservation {
void reserveEBook();
}
// In-person visitors only implement relevant interfaces
public class InPersonVisitor implements BookBorrowing, FeePayment {
@Override
public void borrowBook() {
System.out.println("Borrowing a book...");
}
@Override
public void returnBook() {
System.out.println("Returning a book...");
}
@Override
public void payLateFee() {
System.out.println("Paying a late fee...");
}
}
// Online users implement only their relevant interface
public class OnlineUser implements EBookReservation {
@Override
public void reserveEBook() {
System.out.println("Reserving an e-book...");
}
}

The above approach ensures that the design remains clean and adheres to the Interface Segregation Principle.

The benefits of following the Principle

  • Each client works only with interfaces that are relevant to them. 
  • Reduces unnecessary dependencies.
  • Makes the system easier to maintain and extend.
  • Avoids potential runtime errors, such as unimplemented methods throwing exceptions.

Dependency Inversion Principle

The Dependency Inversion Principle emphasizes that:

"Entities should depend on abstractions rather than concrete implementations."

This means that high-level modules (responsible for overall functionality) should not directly depend on low-level modules (responsible for implementation details). Instead, both should rely on abstractions (like interfaces or abstract classes).

By adhering to this principle, you can achieve decoupling, making the system more flexible, maintainable, and easier to modify without disrupting other components.

Example

Consider a NotificationService that needs to send messages through different mediums like SMS and Email. If the NotificationService directly depends on concrete classes for SMS or Email sending, it becomes tightly coupled with those implementations. Any changes in the low-level classes (e.g., adding WhatsApp notifications) would require changes in the NotificationService, violating the principle.

// High-level module directly depends on low-level modules
public class NotificationService {
private EmailSender emailSender;
private SMSSender smsSender;
public NotificationService() {
this.emailSender = new EmailSender(); // Tight coupling
this.smsSender = new SMSSender(); // Tight coupling
}
public void sendNotification(String message) {
emailSender.sendEmail(message);
smsSender.sendSMS(message);
}
}
class EmailSender {
public void sendEmail(String message) {
System.out.println("Sending Email: " + message);
}
}
class SMSSender {
public void sendSMS(String message) {
System.out.println("Sending SMS: " + message);
}
}

In this example, NotificationService is tightly coupled to both EmailSender and SMSSender. Adding a new notification method (e.g., WhatsAppSender) would require modifying the NotificationService class, violating the Dependency Inversion Principle.

To Adhere to the Dependency Inversion Principle, introduce an abstraction (an interface) that both the high-level module (NotificationService) and low-level modules (EmailSender, SMSSender) depend on.

// Abstraction for sending notifications
public interface NotificationSender {
void send(String message);
}
// Low-level module: EmailSender
public class EmailSender implements NotificationSender {
@Override
public void send(String message) {
System.out.println("Sending Email: " + message);
}
}
// Low-level module: SMSSender
public class SMSSender implements NotificationSender {
@Override
public void send(String message) {
System.out.println("Sending SMS: " + message);
}
}
// High-level module: NotificationService
public class NotificationService {
private final NotificationSender notificationSender;
// Dependency injection via constructor
public NotificationService(NotificationSender notificationSender) {
this.notificationSender = notificationSender;
}
public void sendNotification(String message) {
notificationSender.send(message);
}
}

Usage Example

Now, the NotificationService can work with any implementation of the NotificationSender interface, without being directly dependent on specific classes.

public class Main {
public static void main(String[] args) {
NotificationSender emailSender = new EmailSender();
NotificationService emailService = new NotificationService(emailSender);
emailService.sendNotification("Your email message!");
NotificationSender smsSender = new SMSSender();
NotificationService smsService = new NotificationService(smsSender);
smsService.sendNotification("Your SMS message!");
}
}

Benefits of The Dependency Inversion Principle

  • Reduces coupling between high-level and low-level modules.
  • Facilitates adding new implementations (e.g., WhatsAppSender) without modifying existing code.
  • Encourages dependency injection, which simplifies testing and improves flexibility.

By depending on abstractions rather than concrete implementations, you can create a modular and scalable system.

Conclusion

This article explored the five SOLID principles for writing clean and maintainable code. By following these principles, projects become easier to share with team members, extend with new features, modify, test, and refactor, all while minimizing potential challenges.

Are you looking for the best VPS hosting services? At BlueVPS, we provide powerful, scalable, and customizable hosting solutions with a dedicated environment and unlimited traffic. Start today and take your hosting to the next level!

Blog