Skip to main content

SOLID Principles

The SOLID principles are a set of five design principles in object-oriented programming intended to make software designs more understandable, flexible, and maintainable. They were promoted by Robert C. Martin (Uncle Bob) and are widely considered best practices for writing clean, robust, and scalable code.

Each letter in SOLID stands for a principle:

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

1. Single Responsibility Principle (SRP)

Definition: A class should have only one reason to change. This means a class should have one, and only one, primary responsibility.

Explanation: This principle aims to prevent a class from becoming a "God object" that handles too many unrelated concerns. When a class has multiple responsibilities, changes to one responsibility might inadvertently affect others, leading to bugs and making the class harder to understand and maintain. Separating concerns means each class focuses on a specific task.

Benefits:

  • Reduced Coupling: Classes are less dependent on each other.
  • Improved Maintainability: Changes to one responsibility are isolated to one class.
  • Increased Readability: Each class's purpose is clear.
  • Easier Testing: Smaller, focused classes are easier to test.

Example (Violation vs. Adherence):

Violation (Single class handles multiple responsibilities):

// Anti-pattern: UserProfile handles user data AND database operations
class UserProfile {
private String username;
private String email;

public UserProfile(String username, String email) {
this.username = username;
this.email = email;
}

// Responsibility 1: User Data Management
public String getUsername() { return username; }
public String getEmail() { return email; }
public void setUsername(String username) { this.username = username; }
public void setEmail(String email) { this.email = email; }

// Responsibility 2: Database Persistence
public void saveToDatabase() {
System.out.println("Saving " + username + " to database...");
// Logic to save user to DB
}

public void loadFromDatabase(String username) {
System.out.println("Loading " + username + " from database...");
// Logic to load user from DB and populate fields
}
}

Adherence (Responsibilities separated):

// Responsibility 1: User Data Management
class User {
private String username;
private String email;

public User(String username, String email) {
this.username = username;
this.email = email;
}

public String getUsername() { return username; }
public String getgetEmail() { return email; }
public void setUsername(String username) { this.username = username; }
public void setEmail(String email) { this.email = email; }
}

// Responsibility 2: User Persistence
class UserRepository {
public void save(User user) {
System.out.println("Saving " + user.getUsername() + " to database...");
// Logic to save user to DB
}

public User load(String username) {
System.out.println("Loading " + username + " from database...");
// Logic to load user from DB and return User object
return new User(username, "loaded@example.com"); // Dummy return
}
}

2. Open/Closed Principle (OCP)

Definition: Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification.

Explanation: This means you should be able to add new functionality to a system without altering existing, working code. When you modify existing code, you risk introducing new bugs into previously stable parts of the system. OCP is often achieved through the use of interfaces, abstract classes, and polymorphism.

Benefits:

  • Stability: Existing code remains untouched, reducing the risk of regressions.
  • Scalability: Easier to add new features without breaking old ones.
  • Maintainability: Less prone to "ripple effect" changes.

Example (Violation vs. Adherence):

Violation (Modifying existing code for new functionality):

// Anti-pattern: Need to modify Calculator every time a new operation is added
class Calculator {
public double calculate(char operation, double a, double b) {
switch (operation) {
case '+': return a + b;
case '-': return a - b;
// If we need a 'multiply' operation, we modify this class!
// case '*': return a * b; // This modifies existing code
default: throw new IllegalArgumentException("Invalid operation");
}
}
}

Adherence (Extending with new functionality):

// Open for extension: New operations can be added by implementing this interface
interface Operation {
double apply(double a, double b);
}

// Concrete implementations of operations
class AddOperation implements Operation {
@Override
public double apply(double a, double b) {
return a + b;
}
}

class SubtractOperation implements Operation {
@Override
public double apply(double a, double b) {
return a - b;
}
}

// New functionality added without modifying existing Calculator or Operation interfaces
class MultiplyOperation implements Operation {
@Override
public double apply(double a, double b) {
return a * b;
}
}

// Closed for modification: Calculator class doesn't need to change
class BetterCalculator {
public double calculate(Operation operation, double a, double b) {
return operation.apply(a, b);
}
}

// Usage:
// BetterCalculator calc = new BetterCalculator();
// System.out.println(calc.calculate(new AddOperation(), 5, 3)); // 8.0
// System.out.println(calc.calculate(new MultiplyOperation(), 5, 3)); // 15.0

3. Liskov Substitution Principle (LSP)

Definition: Subtypes must be substitutable for their base types without altering the correctness of the program.

Explanation: This principle means that if a program uses a base class, it should be able to use any of its subclasses interchangeably without causing errors or unexpected behavior. It primarily deals with behavioral subtyping, ensuring that derived classes extend the functionality of the base class without breaking its inherent contract. A common violation occurs when a subclass throws an unexpected exception or provides a no-op implementation for a method defined in the superclass, thus breaking the expected behavior.

Benefits:

  • Robustness: Ensures that polymorphism works reliably.
  • Maintainability: Prevents unexpected side effects when using subclasses.
  • Correctness: Guarantees that code using base types will function correctly with derived types.

Example (Violation vs. Adherence):

Violation (Square is not a perfect substitute for Rectangle):

// Anti-pattern: Square breaks Rectangle's contract
class Rectangle {
protected int width;
protected int height;

public void setWidth(int width) { this.width = width; }
public void setHeight(int height) { this.height = height; }
public int getArea() { return width * height; }
}

class Square extends Rectangle {
@Override
public void setWidth(int width) {
this.width = width;
this.height = width; // Changing width also changes height
}
@Override
public void setHeight(int height) {
this.width = height; // Changing height also changes width
this.height = height;
}
}

// Test method that expects Rectangle behavior
class TestLSP {
public static void printArea(Rectangle r) {
r.setWidth(5);
r.setHeight(4); // We expect area to be 20
System.out.println("Expected Area: " + 5 * 4 + ", Actual Area: " + r.getArea());
}

public static void main(String[] args) {
Rectangle rect = new Rectangle();
printArea(rect); // Expected: 20, Actual: 20 (Correct)

Rectangle square = new Square();
printArea(square); // Expected: 20, Actual: 16 (Violation! Square changed height to 5 after setWidth)
}
}

Adherence (Better design for shapes or using separate abstractions): Instead of forcing Square into an is-a Rectangle relationship when it breaks its contract, consider:

  1. Having an abstract Shape base class with an getArea() method. Rectangle and Square are both Shapes, but don't inherit from each other directly if their behaviors clash.
  2. If inheritance is desired, ensure Square doesn't override methods in a way that breaks Rectangle's contract. (Often, this means Square should not inherit from Rectangle directly if Rectangle has mutable width/height setters).

A simpler adherence example:

interface Flyable {
void fly();
}

class Bird implements Flyable {
@Override
public void fly() {
System.out.println("Bird is flying.");
}
}

class Eagle extends Bird { // Eagle is a Bird, and can fly
// Overrides fly() behavior but still fulfills Flyable contract
@Override
public void fly() {
System.out.println("Eagle is soaring.");
}
}

// Violation example (if Penguin implements Flyable but can't fly):
// class Penguin implements Flyable {
// @Override
// public void fly() {
// throw new UnsupportedOperationException("Penguins cannot fly!"); // Violates LSP
// }
// }

// Correct way for Penguin (don't force it to implement Flyable)
class Penguin {
public void swim() {
System.out.println("Penguin is swimming.");
}
}

LSP suggests that Penguin should not implement Flyable if it cannot truly fly, as it would break the expectation of any code expecting a Flyable object to be able to fly.


4. Interface Segregation Principle (ISP)

Definition: Clients should not be forced to depend on interfaces they do not use. Rather than one large interface, many smaller, client-specific interfaces are better.

Explanation: This principle is about breaking down "fat" interfaces into smaller, more specific ones. If an interface has too many methods, some classes implementing it might only need a subset of those methods, being forced to implement others they don't use (often as empty or throwing methods). Segregating interfaces ensures that a class only depends on the methods it actually needs.

Benefits:

  • Reduced Coupling: Classes are only coupled to the interfaces they genuinely use.
  • Improved Maintainability: Easier to change or refactor interfaces.
  • Increased Flexibility: Easier to implement only the necessary behaviors.

Example (Violation vs. Adherence):

Violation (A single, "fat" interface):

// Anti-pattern: Multi-purpose Worker interface
interface Worker {
void work();
void eat();
void sleep(); // Not all workers might need to 'sleep' in a computational context
void manage(); // Not all workers are managers
}

class HumanWorker implements Worker {
@Override public void work() { /* ... */ }
@Override public void eat() { /* ... */ }
@Override public void sleep() { /* ... */ }
@Override public void manage() { /* ... */ } // Human workers can manage
}

class RobotWorker implements Worker {
@Override public void work() { /* ... */ }
@Override public void eat() { /* Robot doesn't eat */ } // Forced to implement
@Override public void sleep() { /* Robot doesn't sleep */ } // Forced to implement
@Override public void manage() { /* Robot doesn't manage */ } // Forced to implement
}

Adherence (Segregated interfaces):

// Smaller, specific interfaces
interface Workable {
void work();
}

interface Eatable {
void eat();
}

interface Sleepable {
void sleep();
}

interface Manageable {
void manage();
}

class HumanWorker implements Workable, Eatable, Sleepable, Manageable {
@Override public void work() { System.out.println("Human working."); }
@Override public void eat() { System.out.println("Human eating."); }
@Override public void sleep() { System.out.println("Human sleeping."); }
@Override public void manage() { System.out.println("Human managing."); }
}

class RobotWorker implements Workable { // Only implements what it needs
@Override public void work() { System.out.println("Robot working."); }
}

// If a robot could also be manageable:
// class ManagerRobot implements Workable, Manageable {
// @Override public void work() { System.out.println("Manager robot working."); }
// @Override public void manage() { System.out.println("Manager robot managing."); }
// }

5. Dependency Inversion Principle (DIP)

Definition:

  1. High-level modules should not depend on low-level modules. Both should depend on abstractions.
  2. Abstractions should not depend on details. Details should depend on abstractions.

Explanation: This principle promotes decoupling software modules. Instead of a high-level module (which contains complex logic) depending directly on a low-level module (which handles specific tasks like database access or hardware control), both should depend on an abstraction (an interface or abstract class). This allows for easier swapping of implementations and makes the system more testable and flexible.

Benefits:

  • Loose Coupling: High-level and low-level modules are not directly connected.
  • Increased Flexibility: Easy to change low-level implementations without affecting high-level logic.
  • Easier Testing: Low-level dependencies can be mocked or stubbed for unit testing.

Example (Violation vs. Adherence):

Violation (High-level module depends on low-level concrete module):

// Anti-pattern: LightSwitch directly depends on a specific LightBulb implementation
class LightBulb { // Low-level module (detail)
public void turnOn() { System.out.println("LightBulb: On"); }
public void turnOff() { System.out.println("LightBulb: Off"); }
}

class LightSwitch { // High-level module (logic)
private LightBulb bulb; // Direct dependency on concrete class

public LightSwitch() {
this.bulb = new LightBulb(); // Tightly coupled: LightSwitch creates its own LightBulb
}

public void press() {
// Logic to decide whether to turn on or off
// For simplicity, let's just toggle
if (bulb.isOn()) { // Assuming an isOn() method
bulb.turnOff();
} else {
bulb.turnOn();
}
}
}

Adherence (Both depend on an abstraction):

// Abstraction (interface)
interface Switchable {
void turnOn();
void turnOff();
boolean isOn(); // Assume an isOn method for state
}

// Low-level module (details) depend on abstraction
class LightBulb implements Switchable {
private boolean on = false;
@Override public void turnOn() { System.out.println("LightBulb: On"); on = true; }
@Override public void turnOff() { System.out.println("LightBulb: Off"); on = false; }
@Override public boolean isOn() { return on; }
}

class Fan implements Switchable { // Another low-level detail
private boolean on = false;
@Override public void turnOn() { System.out.println("Fan: On"); on = true; }
@Override public void turnOff() { System.out.println("Fan: Off"); on = false; }
@Override public boolean isOn() { return on; }
}

// High-level module (logic) depends on abstraction
class Button {
private Switchable device; // Depends on abstraction

// Dependency Injection: The Switchable device is injected (provided from outside)
public Button(Switchable device) {
this.device = device;
}

public void press() {
if (device.isOn()) {
device.turnOff();
} else {
device.turnOn();
}
}
}

// Usage:
// Button lightButton = new Button(new LightBulb()); // Inject LightBulb
// lightButton.press(); // LightBulb: On
// lightButton.press(); // LightBulb: Off

// Button fanButton = new Button(new Fan()); // Inject Fan
// fanButton.press(); // Fan: On

In the adherence example, the Button (high-level) doesn't know or care about the concrete type of Switchable device it controls. It operates purely on the Switchable interface, making it highly flexible. You can connect a Button to a LightBulb, a Fan, or any other Switchable device without modifying the Button class. This is also the foundation for dependency injection frameworks.