SOLID principles with examples

SOLID principles with examples

The SOLID principles are a set of five principles of object-oriented software design that were first defined by Robert C. Martin, also known as "Uncle Bob". These principles are intended to make software designs more understandable, flexible, and maintainable. They are:

  1. Single Responsibility Principle: A class should have only one reason to change.
  2. Open/Closed Principle: A class should be open for extension but closed for modification.
  3. Liskov Substitution Principle: Objects in a program should be replaceable with instances of their subtypes without altering the correctness of that program.
  4. Interface Segregation Principle: Many client-specific interfaces are better than one general-purpose interface.
  5. Dependency Inversion Principle: One should depend upon abstractions, not concretions. Together, these principles form the acronym SOLID, which can be a useful mnemonic for remembering them.

Here is an example of how the Single Responsibility Principle can be applied to a simple Java class:

public class BankAccount {
    private int balance;

    public BankAccount(int balance) {
        this.balance = balance;
    }

    public void deposit(int amount) {
        this.balance += amount;
    }

    public void withdraw(int amount) {
        this.balance -= amount;
    }

    public int getBalance() {
        return this.balance;
    }
}

Single Responsibility Principle

Here is an example of how the Single Responsibility Principle can be applied to a simple Java class:

Copy code public class BankAccount { private int balance;

public BankAccount(int balance) {
    this.balance = balance;
}

public void deposit(int amount) {
    this.balance += amount;
}

public void withdraw(int amount) {
    this.balance -= amount;
}

public int getBalance() {
    return this.balance;
}

} In this example, the BankAccount class has three responsibilities: storing the balance of a bank account, providing methods for depositing and withdrawing money, and providing a method for accessing the current balance. This violates the Single Responsibility Principle, because if we need to change the way that bank accounts are stored, or the way that deposits and withdrawals are handled, we would have to modify this class.

A better way to design this class would be to split its responsibilities into three separate classes, like this:

public class BankAccountBalance {
    private int balance;

    public BankAccountBalance(int balance) {
        this.balance = balance;
    }

    public int getBalance() {
        return this.balance;
    }
}

public class BankAccountDeposit {
    public void deposit(BankAccountBalance balance, int amount) {
        balance.balance += amount;
    }
}

public class BankAccountWithdrawal {
    public void withdraw(BankAccountBalance balance, int amount) {
        balance.balance -= amount;
    }
}

Now, each class has only one responsibility, and the design of the BankAccount class is more focused and easier to understand. Additionally

Open/Closed principle

Here is an example of the Open/Closed Principle applied to a simple banking system without Single Responsibility principle:

class Account {
  private double balance;

  public Account(double balance) {
    this.balance = balance;
  }

  public void deposit(double amount) {
    this.balance += amount;
  }

  public void withdraw(double amount) {
    this.balance -= amount;
  }

  public double getBalance() {
    return this.balance;
  }
}

class SavingsAccount extends Account {
  private double interestRate;

  public SavingsAccount(double balance, double interestRate) {
    super(balance);
    this.interestRate = interestRate;
  }

  @Override
  public void deposit(double amount) {
    // calculate interest and add it to the deposit amount
    amount += amount * this.interestRate;
    super.deposit(amount);
  }
}

class CheckingAccount extends Account {
  private double overdraftFee;

  public CheckingAccount(double balance, double overdraftFee) {
    super(balance);
    this.overdraftFee = overdraftFee;
  }

  @Override
  public void withdraw(double amount) {
    if (amount > this.getBalance()) {
      // charge the overdraft fee
      this.balance -= this.overdraftFee;
    }
    super.withdraw(amount);
  }
}

In this example, the Account class defines the basic behavior of a bank account, including methods for depositing and withdrawing money. The SavingsAccount and CheckingAccount classes inherit from Account and provide their own implementation of the deposit() and withdraw() methods, respectively, allowing them to be extended without modifying the Account class. This follows the Open/Closed Principle because the behavior of the Account class can be extended without modifying its internal implementation.

Liskov Substitution Principle

The Liskov Substitution Principle means that if a program is written to use a base class, it should be able to use any derived class without knowing it, and the derived class should behave in the same way as the base class.

This principle says child classes should not change the behavior and properties of the parent class and they should replacing without breaking the application.

Example: Wrong Code: the ReadonlyNote can break the application cause its child of Note but each time we use Save we are getting and exception instead of nothing happend.

class Note {

    public constructor(id) {
        // ...
    }

    public save(text): void {
        // save process
    }
}

class ReadonlyNote extends Note {
    public save(text): void {
        throw new Error("Can't update readonly notes");
    }
}
let note = new Note(429);
note.save("Let's do this!");
let note = new ReadonlyNote(429);
note.save("Let's do this!");

Valid Code with Liskov Principle:

We move the save method from Node to WritableNote to avoid applicaiton break.


class Note {
    public constructor(id) {
        // ...
    }
}

class WritableNote extends Note {
    public save(text): void {
        // save process
    }
}

Interface Segregation Principle

The Interface Segregation Principle (ISP) states that clients should not be forced to depend on methods they do not use. In other words, a class should not implement an interface if it only uses a few of the methods defined in that interface. Instead, the interface should be split into smaller, more specific interfaces, each of which contains only the methods that are relevant to the classes that implement them.

Here is an example of the Interface Segregation Principle in action:

// Bad example - violates the Interface Segregation Principle
public interface Animal {
    void eat();
    void sleep();
    void fly();
    void swim();
}

public class Bird implements Animal {
    public void eat() {...}
    public void sleep() {...}
    public void fly() {...}
    public void swim() {...} // Not all birds can swim
}

// Good example - follows the Interface Segregation Principle
public interface Eatable {
    void eat();
}

public interface Sleepable {
    void sleep();
}

public interface Flyable {
    void fly();
}

public interface Swimmable {
    void swim();
}

public class Bird implements Eatable, Sleepable, Flyable {
    public void eat() {...}
    public void sleep() {...}
    public void fly() {...}
}

n the bad example above, the Animal interface defines four methods, but not all animals are able to eat, sleep, fly, or swim. For instance, a bird can eat, sleep, and fly, but it cannot swim. However, since the Bird class implements the Animal interface, it must provide implementations for all four methods, even though the swim() method is irrelevant for a bird.

In the good example above, the Animal interface has been split into four smaller interfaces, each of which defines a single method. This allows classes to implement only the interfaces that are relevant to them, without being forced to implement unnecessary methods. For example, the Bird class only needs to implement the Eatable, Sleepable, and Flyable interfaces, because these are the only methods that are relevant to a bird. The swim() method, which is irrelevant for a bird, is defined in the Swimmable interface, and the Bird class does not implement this interface. This follows the Interface Segregation Principle, as it ensures that clients are not forced to depend on methods they do not use.

Dependency Inversion Principle

The Dependency Inversion Principle (DIP) states that high-level modules should not depend on low-level modules, but rather both should depend on abstractions. This means that the implementation details of low-level modules should be hidden behind abstractions, and those abstractions should be used by high-level modules. This helps to decouple the different parts of the system and make it more flexible and maintainable.

Here is an example of the Dependency Inversion Principle in action:

// Bad example - violates the Dependency Inversion Principle
public class Database {
    public void save(Object object) {...}
}

public class UserService {
    private final Database database;

    public UserService() {
        this.database = new Database();
    }

    public void saveUser(User user) {
        database.save(user);
    }
}

// Good example - follows the Dependency Inversion Principle
public interface DataSaver {
    void save(Object object);
}

public class Database implements DataSaver {
    public void save(Object object) {...}
}

public class FileStorage implements DataSaver {
    public void save(Object object) {...}
}

public class UserService {
    private final DataSaver dataSaver;

    public UserService(DataSaver dataSaver) {
        this.dataSaver = dataSaver;
    }

    public void saveUser(User user) {
        dataSaver.save(user);
    }
}

In the bad example above, the UserService class depends directly on the Database class. This means that the UserService class is tightly coupled to the Database class, and it cannot be used with any other data storage mechanism. If the underlying implementation of the Database class changes, or if a different data storage mechanism is needed, the UserService class will need to be modified, which can make the system difficult to maintain and adapt.

In the good example above, the UserService class depends on the DataSaver interface rather than the Database class. This allows the UserService class to be used with any class that implements the DataSaver interface, including the Database class. This decouples the UserService class from the Database class, and makes the system more flexible and maintainable. The Database class still depends on the Object class, but that dependency is at a lower level, and it is hidden behind the DataSaver interface, which follows the Dependency Inversion Principle.