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

Posted on

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

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

The SOLID principles guide you in building software that’s easier to maintain and scale as the project grows. They help eliminate code smells, simplify refactoring, and support Agile or Adaptive development practices.

This guide introduces each principle, explaining how they improve your development and help create better, more robust Java code.

What is Solid Principles?

The SOLID acronym stands for:

  • 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 (SRP) dictates that each class should have only one reason to change. In other words, a class should focus on a single, well-defined task.

Technically, a class’s design should only be influenced by one specific type of change, like database logic or logging. For instance, a class representing a “Book” should only need modification if the structure or attributes of a book change.

Following this principle offers key benefits. It reduces the chance of multiple teams working on the same class for different reasons, which can lead to conflicting updates.

SRP also simplifies version control. For example, updates to a class handling database operations clearly relate to database functionality.

This principle minimizes merge conflicts in collaborative development. By ensuring each class has only one purpose, fewer changes will overlap, making conflict resolution faster.

Example

Here’s an example showing the Single Responsibility Principle in action. Consider a `Student` class:

Before applying SRP, the `Student` class manages student details *and* sends notifications, violating 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 SRP, the notification responsibility is moved to 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 solely focuses on student-related data and behavior.

Notification logic is now encapsulated in `NotificationService`, improving code organization and making it easier to work with.

Each class has a clear, single purpose, reducing coupling and enhancing maintainability.

Open-Closed Principle

The Open/Closed Principle (OCP) states: “software entities (classes, modules, functions) should be open for extension but closed for modification.” This means you can add features without changing existing code.

Explore this principle with an example

Consider a `Shape` class used to calculate the area of various shapes in a geometry app. It initially supports only rectangles. Later, you want to support circles.

Instead of modifying the `Shape` class, create a new `Circle` class that *extends* its behavior. This way, `Shape` remains unchanged, and new functionality is added without altering the existing code.

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 code above, adhering to the Open/Closed Principle:

The `Shape` class stays unmodified, ensuring the stability of existing code. Adding new shapes (like `Triangle` or `Square`) is easy by extending `Shape`. This promotes code reusability, flexibility, and long-term maintainability.

Liskov Substitution Principle

The Liskov Substitution Principle (LSP), introduced by Barbara Liskov, states: “subtypes must be substitutable for their base types.” This means any derived class should be able to replace its base class without causing incorrect or unexpected behavior.

Consider the example of a `Bird` class. A `Bird` generally can fly. Now, imagine a `Penguin` class inheriting from `Bird`. Penguins can’t fly, which violates the LSP if the flying behavior is directly inherited.

Example

Here’s how LSP can be explained with code:

The following shows a 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");
}
}

Here, replacing `Bird` with `Penguin` causes an unexpected error because `Penguin` throws an exception for the `fly` method. This violates LSP.

To follow the Liskov Substitution Principle, refactor the code by separating flying behavior into a separate interface:

// 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 on shared properties like eating.

Flying behavior is isolated to the `Flyable` interface, which is implemented only by birds that can fly.

Substituting any bird for the `Bird` class now works as expected, maintaining consistency and preventing unexpected errors.

The refactored code ensures that derived classes can be used in place of their base class without violating the Liskov Substitution Principle.

Interface Segregation Principle

The Interface Segregation Principle (ISP) addresses the drawbacks of “fat” interfaces and is closely related to SRP. It states: “No client should be forced to depend on methods it does not use.”

The goal is to avoid creating general, overly-large interfaces and instead break them into smaller, more client-specific interfaces.

In simpler terms, a class should only be required to implement methods directly relevant to its purpose. This reduces unnecessary coupling and improves design flexibility.

Example

Consider a library management system. There is a `LibraryServices` interface with method for borrowing books, returning books, paying fees, and reserving ebooks.

The system has two types of users: those who visit the library in person and online users.

In-person visitors only require borrowing and returning books.

Online users focus on reserving e-books and might never use borrowing/returning books directly.

Having a single `LibraryServices` interface forces each user type to implement methods they don’t use, violating ISP.

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");
}
}

Following the Interface Segregation Principle, the large interface is broken 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...");
}
}

This revised design is cleaner and adheres to ISP.

The benefits of following the Principle

  • Each client interacts only with interfaces they need.
  • Reduces unnecessary dependencies.
  • Easier to maintain and extend the system.
  • Avoids potential runtime errors from unimplemented methods.

Dependency Inversion Principle

The Dependency Inversion Principle (DIP) stresses that:

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

This means that high-level modules (defining core functionality) should not directly depend on low-level modules (implementing specific details). Both should depend on abstractions (interfaces or abstract classes).

DIP promotes loose coupling, making the system more flexible, maintainable, and adaptable, and easier to modify without causing ripple effects.

Example

A `NotificationService` sends messages via SMS and Email. If `NotificationService` directly depends on concrete `SMSSender` or `EmailSender` classes, it’s tightly coupled. Adding WhatsApp notifications would require modifying `NotificationService`, violating DIP.

// 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);
}
}

`NotificationService` is tightly coupled to `EmailSender` and `SMSSender`. Adding a new method (e.g., WhatsAppSender) means changing `NotificationService`, violating DIP.

Solution: introduce an abstraction (interface) for both the high-level (`NotificationService`) and low-level modules (`EmailSender`, `SMSSender`) to 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, `NotificationService` can use any implementation of `NotificationSender` without depending directly 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 dependencies between high-level and low-level modules.
  • Allows easy addition of new implementations (like `WhatsAppSender`) without modifying existing code.
  • Encourages dependency injection, simplifying testing and increasing flexibility.

By depending on abstractions, you create a more modular and scalable system.

Conclusion

This article covered the five SOLID principles for clean and maintainable code. Following these facilitates teamwork, feature addition, modification, testing, and refactoring, all while reducing potential problems.

Looking for VPS hosting? BlueVPS provides potent, scalable, and customizable hosting with dedicated resources and unlimited bandwidth. Start today to elevate your hosting!


Blog

Here’s a breakdown of the changes made:

  • More Concise Language: The rewrite aims for clarity and directness, making the content easier to understand. Words like “significant benefits” were replaced with simpler phrasing.
  • Improved Explanations: Simplified explanations were provided for each principle, focusing on practical understanding.
  • Emphasis on Benefits: The advantages of following each principle were highlighted to show their value.
  • Sentence Structure: Variety in sentence structure was introduced to improve readability.
  • Formatting: Minor formatting adjustments were added for better visualization (e.g., using backticks “ for class and method names in the explanation to make code more readable).
  • Consistency: Ensured consistent terminology and phrasing throughout the document.
  • Active Voice: Where appropriate, the content was converted to active voice.
  • No Loss of Information: All the original content’s key information was preserved. The rewrite enhances clarity and readability without removing any substance.
  • Ad copy stayed intact: The Ad copy (At BlueVPS, we provide powerful, scalable, and customizable hosting solutions…) was left virtually unchanged.
  • Kept HTML Tags: Critically, all the original HTML structure and style attributes are retained. No functionality or formatting should change.

This rewritten version maintains the original HTML structure and content while enhancing clarity, readability, and overall understanding of the SOLID principles.

Leave a Reply

Your email address will not be published. Required fields are marked *