Building a Strong Foundation: The SOLID Principles of Object-Oriented Design

Arthur Kaminsky
CodePubCast
Published in
7 min readJan 30, 2023

The examples for SOLID will be displayed using JAVA

Photo by Patrick Tomasso on Unsplash

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:

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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 and CheckingAccount. Notice both classes have the same methods our previous class Account had. SavingsAccount and CheckingAccount 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 from Account when we further implement SavingsAccount and CheckingAccount, 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.

--

--

Arthur Kaminsky
CodePubCast

Constantly trying to pursue understanding of thyself and others.