SOLID Design Principles with JAVA

Kushan Madhusanka
8 min readJan 19, 2023

--

SOLID is simply an acronym for the first five object-oriented design principles by Robert C. Martin. Solid helps us to write better code. If you have a large code base that hasn't followed SOLID principles as an engineer you will suffer when you are going to add new features, fix bugs, and make changes. In this article, I will explain to you all principles using proper examples in the JAVA programming language.

We use SOLID principles to avoid “code rots”. Code rot is a process where the quality of your code degrades over time. There are some sign of code rots that we can identify.

  1. Rigidity — This means something that can’t change or fixed. If we have a rigid code base, that code base won’t be easy to change. If we make a small change that will lead to a chain of changes. This is not a problem at the moment.
  2. Fragility — Fragility means the code base can be easily broken. Even though we make a small change there will be a bug in somewhere.
  3. Immobility — If there are components in the code base that we can’t reuse, it indicates that the code base is immobility.
  4. Viscosity — Viscosity means resistant to change or hard to change.

Having these code rots in your code base will bring huge problems for you. That’s why we need to follow SOLID principles.

Single responsibility principle

Open-closed principle

Liskov substitution principle

Interface segregation principle

Dependency inversion principle

Single responsibility principle

It’s easy to understand. The name implies the meaning of this principle. We shouldn’t give more responsibilities to one class. The class should be specific to a certain task(High Cohesion). Having a large number of classes is not a problem. But having all methods in one class is a big problem. Many responsibilities for a class mean many bugs.

Let’s take a simple example. Think we have a class named Attendance and that class has several methods such as:

saveAttendance()

searchAttendance()

printAttendanceDocument()

emailAttendance()

sendNotificationToLecturer()

Someone who doesn’t follow the single responsibility principle will write the code like this:

public class Attendance {
public void saveAttendance() {
System.out.println("save attendance");
}
public void searchAttendance() {
System.out.println("search attendance");
}
public void printAttendanceDocument() {
System.out.println("print attendance document");
}
public void emailAttendance() {
System.out.println("email attendance");
}
public void sendNotificationToLecturer() {
System.out.println("send notifications to lecturer");
}
}

But this code will bring so many headaches for that person near future. So we can follow the single responsibility principle and rearrange that code by creating separate classes for each task.

public class Attendance {
public void saveAttendance() {
System.out.println("save attendance");
}
public void searchAttendance() {
System.out.println("search attendance");
}
}

AttendanceDocument class

public class AttendanceDocument{
public void printAttendanceDocument() {
System.out.println("print attendance document");
}
}

Email class

public class Email {
public void emailAttendance() {
System.out.println("email attendance");
}
}

Notification class

public class Notification {
public void sendNotificationToLecturer() {
System.out.println("send notifications to lecturer");
}
}

Open-closed principle

Classes or software entities should be open for extension and close to modification. This implies that a class must be extensible without requiring class modifications. Once a class has been written and tested, it must be maintained without the addition of new codes. (Except in extreme emergencies). If there is a modification that may lead to new bugs and have to write a test case again and test it.

Let’s think we are building a calculator. In the beginning, we have four classes.

Executor — execute what we want

Calculator

Addition

Subtraction

Someone who doesn’t know the Open-close principle will implement the scenario like this:

public class Addition {
public void execute() {
System.out.println("Numbers are added");
}
}
public class Subtraction  {
public void execute() {
System.out.println("Numbers are subtracted");
}
}
public class Calculator {
public void doAddition(Addition addition) {
addition.execute();
}
public void doSubtraction(Subtraction subtraction) {
subtraction.execute();
}
}
public class Executor {
public static void main(String[] args) {
new Calculator().doAddition(new Addition());
new Calculator().doSubtraction(new Subtraction());
}
}

Though initially, we create the abstraction and subtraction classes we have to create multiplication and division classes later. Then we have to modify the Calculator class.

public class Multiplication {
public void execute() {
System.out.println("Numbers are multiplied");
}
}
public class Calculator {
public void doAddition(Addition addition) {
addition.execute();
}
public void doSubtraction(Subtraction subtraction) {
subtraction.execute();
}
public void doMultiplication(Multiplication multiplication) {
multiplication.execute();
}
}

That violates the open-close principle. So, we have to create the calculator class that should be open for extension but closed for modification. Let’s do that.

For that, we need a separate Interface .

public interface Operation {
void execute();
}
public class Addition implements Operation{
@Override
public void execute() {
System.out.println("Numbers are added");
}
}
public class Subtraction implements Operation {
@Override
public void execute() {
System.out.println("Numbers are subtracted");
}
}
public class Multiplication implements Operation {
@Override
public void execute() {
System.out.println("Numbers are Multiplied");
}
}
public class Calculator {
public void calculate (Operation operation) {
if (operation == null) {
throw new InvalidParameterException("Operation is failed to execute");
}
operation.execute();
}
}

Now we don’t have to modify this class each time when we add a new operation.

public class Executor {
public static void main(String[] args) {
new Calculator().calculate(new Addition());
new Calculator().calculate(new Subtraction());
new Calculator().calculate(new Multiplication());
}
}

Liskov substitution principle

This is introduced by Barbara Liskov in her conference keynote “Data abstraction” in 1988. According to this principle, objects of a superclass must be replaceable with objects of its subclasses without the application being broken. As a result, the behavior of the objects in your subclasses must be the same as that of the objects in your superclass. You can accomplish that by adhering to a few guidelines that are quite similar to Bertrand Meyer’s definition of the design by contract concept.

A subclass’s overridden method must accept the same input parameter values as the method of the supper class. Therefore, you are permitted to implement less stringent validation criteria but not to enforce more stringent ones in your subclass. Otherwise, if this method is called with an object of the subclass instead of an object of the superclass, any code using it may result in an exception.

public class Animal {
public void fly() {
System.out.println("Animal is flying");
}
}
public class Parrot extends Animal {
@Override
public void fly() {
System.out.println("Parrot is flying");
}
}
public class Cat extends Animal {
@Override
public void fly() {
System.out.println("Cat is flying");
}
// Even though fly method can be implemented here, it doesn't make any sense.
// Cats don't fly
}

So, it’s clear that here Liskov substitution principle has been violated. So, we can solve this by :

  • creating animal class without fly method
  • creating another class as FlyingAnimal that can be included only flying animals.
  • Parrot class extend from FlyingAnimal
  • Cat extends from Animal
public class Animal {}
public class FlyingAnimal extends Animal{
public void fly() {
System.out.println("Animal is flying");
}
}
public class Parrot extends FlyingAnimal {
@Override
public void fly() {
System.out.println("Parrot is flying");
}
}
public class Cat extends Animal {}

Interface segregation principle

Interface segregation (separation of interface) means creating specialized interfaces to do certain tasks. This is related to the single responsibility principle. “Clients shouldn’t be made to rely on interfaces they don’t use,” the statement reads. No program should be made to rely on methods it does not employ. This idea divides application interfaces into smaller ones in order to minimize the negative effects of employing larger interfaces.

public interface Animal {
public void eat();

public void jump();

public void swim();

public void makingSound();

public void fly();
}

Let’s think we have an interface that contained several methods like this. If we implement Cat class from this interface eat() and makingSound() methods will be applicable for that. But other methods are not. If we get shark class swim() and makingSound() methods will be applicable. But other methods make no sense.

Therefore, we can solve this using the interface segregation principle. We can create several interfaces for that.

public interface Animal {
public void eat();
public void makingSound();
}
public interface FlyingAnimal extends Animal {
public void fly();
}
public interface TerrestialAnimal extends Animal {
public void jump();
}
public interface AquaticAnimal extends Animal {
public void swim();
}

Then we can create classes that are implemented by those interfaces.

public class Cat implements TerrestialAnimal {
@Override
public void eat() {
//method body
}
@Override
public void makingSound() {
//method body
}
@Override
public void jump() {
//method body
}
}
public class Shark implements AquaticAnimal {
@Override
public void eat() {
//method body
}
@Override
public void makingSound() {
//method body
}

@Override
public void swim() {
//method body
}
}

Dependency inversion principle

The Dependency Inversion Principle (DIP) states that high-level modules should not depend on low-level modules; both should depend on abstractions. Abstractions should not depend on details. Details should depend upon abstractions. This helps us to couple software modules loosely.

public class Company {
private Accountant accountant = new Accountant();
private HRManager hrManager = new HRManager();

public void work() {
accountant.makeRecords();
hrManager.managePeople();
}
}
public class Accountant {
public void makeRecords() {
System.out.println("Making financial records");
}
}
public class HRManager {
public void managePeople() {
System.out.println("Managing people");
}
}

Here high-level module is Company and low-level modules are HRManager and Accountant . Company is dependent on Accountant and HRManager classes. So, this violates the dependency inversion principle. Therefore we can implement a separate Employeeinterface and change the code as below.

public interface Employee {
public void doingJob();
}
public class HRManager implements Employee{
public void managePeople() {
System.out.println("Managing people");
}

@Override
public void doingJob() {
this.managePeople();
}
}
public class Accountant implements Employee{
public void makeRecords() {
System.out.println("Making financial records");
}

@Override
public void doingJob() {
this.makeRecords();
}
}
public class Company {
public void work(Employee employee) {
employee.doingJob();
}
}

Now both low-level and high-level modules are dependent on Employee interface.

Photo by Austin Distel on Unsplash

Other than those SOLID principles, there are several principles that you can follow when it comes to proper code base such as:

  • KISS — Keep It Simple, Stupid

This is a little bit related to the single responsibility principle. This principle encourages programmers to write small methods. Should not write lengthy code. Otherwise, it is hard to understand as well as manage.

  • DRY — Don’t Repeat Yourself

Sometimes, when you write codes you may have got a feeling that of déjà vu. You feel that you have written this code before in this application. That means you have. If you continuously write those codes, the scale of the project will increase unnecessarily and you will end up with a large hard-to-manage code base. Therefore, it’s better to follow this principle and don’t write the same code in different places.

  • Occam’s Razor

Always select the hypothesis that has few assumptions and the simplest solution. Don’t try to come up with complex solutions just to show off your skills.

  • YAGNI — You Aren’t Gonna Need It

You shouldn’t add new functionality to tackle requirements or issues that may arise in the future. Do not implement a feature or a piece of code today, even if you are positive you will require it later. Most likely, you won’t need anything after all, or what you do need is very different from what you initially thought you would need. Just implement the things that are required.

I strongly suggest you to follow all the facts and examples that have been presented. Learning those principles will make you a better programmer. I have mentioned several other principles as well for you to get familiar with.

See you in the next blog post. Bye Bye🍻🍸❤️❤️

--

--

Kushan Madhusanka

Undergraduate of University of Moratuwa | Faculty of Information Technology