Building a Strong Foundation: The SOLID Principles of Object-Oriented Design
The examples for SOLID will be displayed using JAVA
The SOLID principles are a set of five design principles for writing maintainable software. They were first introduced by Robert C. Martin in 2000, and they are widely used in object-oriented programming and software development. The five SOLID principles are:
- Single Responsibility Principle (SRP): A class should have only one reason to change. This means that a class should have a single, well-defined responsibility, and that responsibility should be encapsulated within the class.
- Open-Closed Principle (OCP): A class should be open for extension but closed for modification. This means that a class should be designed in such a way that it can be extended to add new functionality without modifying the existing code.
- Liskov Substitution Principle (LSP): Subtypes should be substitutable for their base types. This means that objects of a derived class should be able to replace objects of the base class without altering the correctness of the program.
- Interface Segregation Principle (ISP): A client should not be forced to implement interfaces it does not use. This means that a class should not be forced to implement interfaces that are not relevant to its functionality.
- Dependency Inversion Principle (DIP): High-level modules should not depend on low-level modules, but both should depend on abstractions. This means that a class should not depend on concrete implementations of other classes, but rather on abstract interfaces or contracts.
These principles are guidelines for designing flexible and maintainable software, by following these principles developers create a software that is easy to understand, easy to change and easy to test.
We’re going to go through each of the principles and provide simple to understand code examples using Java. The examples are going to be related to bank account and it’s operations.
Single Responsibility Principle (SRP)
Based on the Single Responsibility Principle we need to create a class that has only one job, one purpose and no additional redundant functionality other than what it needs to do. Below we have an example for Account
class:
class Account {
private double balance;
public void deposit(double amount) {
balance += amount;
}
public void withdraw(double amount) {
if (balance >= amount) {
balance -= amount;
} else {
throw new InsufficientFundsException();
}
}
public double getBalance() {
return balance;
}
}
In this example, the Account
class has only one responsibility, which is to manage the balance of the account.
Open-Closed Principle (OCP)
Based on the Open-Closed Principle, a class should be open for extension, but closed for modification. In the example below we provide the Bank
interface as SavingsAccount
and CurrentAccount
implement it and extend their functionality from the Bank
interface:
interface BankTransaction {
void deposit(double amount);
void withdraw(double amount);
double getBalance();
}
class SavingsAccount implements BankTransaction {
private double balance;
@Override
public void deposit(double amount) {
balance += amount;
}
@Override
public void withdraw(double amount) {
if (balance >= amount) {
balance -= amount;
} else {
throw new InsufficientFundsException();
}
}
@Override
public double getBalance() {
return balance;
}
}
class CheckingAccount implements BankTransaction {
private double balance;
@Override
public void deposit(double amount) {
balance += amount;
}
@Override
public void withdraw(double amount) {
if (balance >= amount) {
balance -= amount;
} else {
throw new InsufficientFundsException();
}
}
@Override
public double getBalance() {
return balance;
}
}
In this example, the Bank interface is open for extension but closed for modification, as new bank accounts can be added without modifying the existing code.
A quick note about
SavingsAccount
andCheckingAccount
. Notice both classes have the same methods our previous classAccount
had.SavingsAccount
andCheckingAccount
should have their own unique behaviors, meaning their own methods while also having similar behavior such as deposit, withdraw, and get balance. We could extend fromAccount
when we further implementSavingsAccount
andCheckingAccount
, but as of now the purpose is to help understand the principles with simplified examples.
Liskov Substitution Principle (LSP)
Based on the Liskov Substitution Principle is a principle in object-oriented programming that states that objects of a superclass should be able to be replaced with objects of a subclass without affecting the correctness of the program. This principle is closely related to polymorphism and inheritance. Also, it works really well with the Open-Closed Principle (OCP) since both rely on implementation. One allows to extend functionality which is OCP, while the other LSP allows to use objects of a superclass that derive from it without affecting the correctness of the program.
For example, we’ll take the same code we used above:
interface BankTransaction {
void deposit(double amount);
void withdraw(double amount);
double getBalance();
}
class SavingsAccount implements BankTransaction {
private double balance;
@Override
public void deposit(double amount) {
balance += amount;
}
@Override
public void withdraw(double amount) {
if (balance >= amount) {
balance -= amount;
} else {
throw new InsufficientFundsException();
}
}
@Override
public double getBalance() {
return balance;
}
}
class CheckingAccount implements BankTransaction {
private double balance;
@Override
public void deposit(double amount) {
balance += amount;
}
@Override
public void withdraw(double amount) {
if (balance >= amount) {
balance -= amount;
} else {
throw new InsufficientFundsException();
}
}
@Override
public double getBalance() {
return balance;
}
}
In this example, both SavingsAccount
and CheckingAccount
inherit from the BankTransaction
class and can be used interchangeably without affecting the program’s correctness. Meaning we can use the BankTransaction
interface to use either of the classes without affecting the correctness of the program and without knowing which class is used since we use abstract classes or interfaces which we’ll cover later in Dependency Inversion Principle (DIP).
The same could be with extends
rather than implements
:
abstract class BankTransaction {
abstract void deposit(double amount);
abstract void withdraw(double amount);
abstract double getBalance();
}
class SavingsAccount extends BankTransaction {
private double balance;
@Override
public void deposit(double amount) {
balance += amount;
}
@Override
public void withdraw(double amount) {
if (balance >= amount) {
balance -= amount;
} else {
throw new InsufficientFundsException();
}
}
@Override
public double getBalance() {
return balance;
}
}
class CheckingAccount extends BankTransaction {
private double balance;
@Override
public void deposit(double amount) {
balance += amount;
}
@Override
public void withdraw(double amount) {
if (balance >= amount) {
balance -= amount;
} else {
throw new InsufficientFundsException();
}
}
@Override
public double getBalance() {
return balance;
}
}
Just keep in mind using when using extends
you can only extend from one superclass while with implements
you have the option of implementing from multiple interfaces.
A quick note about abstract classes. Use them only if your children classes need concrete implementation from the superclass and not just override abstract methods, otherwise it’s more preferable to use an interface.
Interface Segregation Principle (ISP)
Based on the Interface Segregation Principle we should not be adding more methods inside interfaces that we have no use for. Meaning if we had out BankTransaction
interface with an additional method generateReport
, but we don’t use it, then we broke the Interface Segregation Principle.
For example:
interface BankTransaction {
void deposit(double amount);
void withdraw(double amount);
double getBalance();
void generateReport();
}
generateReport
doesn’t have any functionality in BankTransaction
interface, so it shouldn’t be there. Here’s how we ought to use ISP:
interface BankTransaction {
void deposit(double amount);
void withdraw(double amount);
double getBalance();
}
interface BankCustomer {
void addCustomer();
void removeCustomer();
void showCustomers();
}
interface BankReport {
void generateReport();
}
class Customer {
private String name;
private BankTransaction account;
public Customer(String name, BankTransaction account) {
this.name = name;
this.account = account;
}
public String getName() {
return name;
}
public BankTransaction getAccount() {
return account;
}
}
class BankService implements BankTransaction, BankCustomer, BankReport {
private SavingsAccount savingsAccount;
private CurrentAccount currentAccount;
private List<Customer> customers;
public BankService(SavingsAccount savingsAccount, CurrentAccount currentAccount) {
this.savingsAccount = savingsAccount;
this.currentAccount = currentAccount;
this.customers = new ArrayList<>();
}
@Override
public void deposit(double amount, AccountType accountType) {
if (accountType == AccountType.SAVINGS) {
savingsAccount.deposit(amount);
} else {
currentAccount.deposit(amount);
}
}
@Override
public void withdraw(double amount, AccountType accountType) {
if (accountType == AccountType.SAVINGS) {
savingsAccount.withdraw(amount);
} else {
currentAccount.withdraw(amount);
}
}
@Override
public double getBalance(AccountType accountType) {
if (accountType == AccountType.SAVINGS) {
return savingsAccount.getBalance();
} else {
return currentAccount.getBalance();
}
}
@Override
public void addCustomer(Customer customer) {
customers.add(customer);
}
@Override
public void removeCustomer(Customer customer) {
customers.remove(customer);
}
@Override
public void showCustomers() {
for (Customer customer : customers) {
System.out.println(customer);
}
}
@Override
public void generateReport() {
for (Customer customer : customers) {
BankTransaction account = customer.getAccount();
System.out.println(customer.getName() + ": " + account.getBalance());
}
}
}
In this example, the BankTransaction
interface is split into two separate interfaces, BankTransaction
and BankCustomer
which allows for a more granular implementation of the bankTransaction’s functionality and reduces the number of methods that a class must implement.
Dependency Inversion Principle (DIP)
Based on the Dependency Inversion Principle a class should not depend on concrete implementations of other classes, but rather on abstract interfaces or contracts. Meaning when using abstract interfaces or contracts, we don’t know which concrete implementation is being invoked.
For example:
class Bank {
private BankService service;
public Bank(BankService service) {
this.service = service;
}
public void deposit(double amount, AccountType accountType) {
service.deposit(amount, accountType);
}
public void withdraw(double amount, AccountType accountType) {
service.withdraw(amount, accountType);
}
public double getBalance(AccountType accountType) {
return service.getBalance(accountType);
}
public void addCustomer(Customer customer) {
service.addCustomer(customer);
}
public void removeCustomer(Customer customer) {
service.removeCustomer(customer);
}
public void showCustomers() {
service.showCustomers();
}
public void generateReport() {
service.generateReport();
}
}
In this example, the Bank
class depends on the abstraction of the BankService
class, rather than a concrete implementation. This allows for greater flexibility and ease of testing, as the concrete implementation of the BankService
class can be easily swapped out for a mock or test implementation.
SOLID is a set of five principles for software design that provide a foundation for building scalable and maintainable systems. By following SOLID principles, developers can create software that is easy to modify, extend, and test, which helps improve the overall quality and reliability of the code. SOLID principles also provide a clear framework for organizing and structuring code, making it easier for teams to collaborate and build complex systems. Whether you’re a seasoned software engineer or just starting out, understanding and applying the SOLID principles is an important step towards creating high-quality software.
Hope you’ve enjoyed the article and I hope I was able to provide for you a good insight about the SOLID principles.
Make sure to follow me and our publication “CodePubCast” for more programming content.