SOLID Principles In Java

Ersin Yılmaz Aslan
6 min readSep 17, 2023

--

In this story, I will try my best to explain SOLID principles with Java code examples.

SOLID stands for below five design principles:

1. S — Single Responsibility Principle

2. O — Open Closed Principle

3. L — Liskov Substitution Principle

4. I — Interface Segregation Principle

5. D — Dependency Inversion Principle

Single Responsibility Principle

This principle states that a class should have only one reason to change, meaning it should have only one responsibility.

Let’s dive deeper into the Single Responsibility Principle (SRP) with a more detailed example. The SRP states that a class should have only one reason to change or, in other words, a single responsibility.

Let’s consider a real-world scenario involving employee management:

1-) Employee Class:

This class should be responsible for representing individual employees and managing their data.

class Employee {
private String id;
private String name;
private double salary;

// Constructors, getters, and setters for employee data

public double calculateSalary()
return salary;
}

public void saveToDatabase() {
// Save employee data to the database
}
}

2-) SalaryCalculator Class:

This class should be responsible for calculating salaries based on predefined rules or policies.

class SalaryCalculator {
public double calculateSalary(Employee employee) {
return employee.calculateSalary();
}
}

3-) EmployeeRepository Class:

This class should be responsible for handling database operations related to employees.

class EmployeeRepository {
public void save(Employee employee) {
// Save employee data to the database
}

public Employee findById(String id) {
// Retrieve employee data from the database by ID
return null;
}

public void delete(Employee employee) {
// Delete employee data from the database
}
}

By dividing the responsibilities into separate classes, we follow the SRP, ensuring that each class has a single reason to change.

Open Closed Principle

The Open-Closed Principle (OCP) states that software entities (classes, modules, functions) should be open for extension but closed for modification. It means that we should be able to add new functionality or behavior to a system without altering its existing source code.

Let me give you an example by designing a drawing application, and we have different shapes to draw, such as circles, rectangles, and triangles.

1-) Shape Interface:

Lets create simple interface called Shape that defines a method for drawing the shape.

public interface Shape { 
void draw();
}

Here is the implementation of this interface:

public class Circle implements Shape {
@Override
public void draw() {
System.out.println("Drawing a circle");
}
}

public class Rectangle implements Shape {
@Override
public void draw() {
System.out.println("Drawing a rectangle");
}
}

2-) Client Code:

This class will be open for extension so it lets us add new shapes without altering its structure as this principle works.

public class DrawingApp {

public void drawShapes(List<Shape> shapes) {
for (Shape shape : shapes) {
shape.draw();
}
}

public static void main(String[] args) {
DrawingApp app = new DrawingApp();

List<Shape> shapes = new ArrayList<>();
shapes.add(new Circle());
shapes.add(new Rectangle());

app.drawShapes(shapes);
}
}

If we decide to add more shapes later on, we can do this without modifying our interface.

Liskov Substitution Principle

The Liskov Substitution Principle (LSP) states that objects of a derived class should be able to replace objects of the base class without affecting the correctness of the program. In other words, if a class ‘B’ is a subclass of class ‘A’, we should be able to use an object of class B wherever an object of class A is expected, and the program should still behave correctly.

This means that objects of the subclass should behave in the same way as the superclass.

1-) Shape Class:

Create a base class Shape that defines common attributes and methods for all shapes.

public abstract class Shape {
public abstract double area();
}

2-) Rectangle Class:

Implement a Rectangle class that inherits from Shape. It should provide its own implementation of the area method.

public class Rectangle extends Shape {
private double width;
private double height;

public Rectangle(double width, double height) {
this.width = width;
this.height = height;
}

@Override
public double area() {
return width * height;
}
}

3-) Circle Class:

Implement a Circle class that also inherits from Shape and provides its own implementation of the area method.

public class Circle extends Shape {
private double radius;

public Circle(double radius) {
this.radius = radius;
}

@Override
public double area() {
return Math.PI * radius * radius;
}
}

Here is the implementation of these classes:

public class GeometryApp {
public static void printArea(Shape shape) {
System.out.println("Area: " + shape.area());
}

public static void main(String[] args) {
Shape rectangle = new Rectangle(5.0, 4.0);
Shape circle = new Circle(3.0);

printArea(rectangle);
printArea(circle);
}
}

In this example, the GeometryApp client code works with objects of the base class Shape, but it can use both Rectangle and Circle objects without any issues. The LSP is maintained because the derived classes (Rectangle and Circle) can be substituted for their base class (Shape) without affecting the correctness of the program.

Following the Liskov Substitution Principle ensures that our code is robust and extensible, allowing us to add new shape classes in the future without causing unexpected behavior in existing code that relies on the base class interface.

Interface Segregation Principle

The Interface Segregation Principle (ISP) states classes should not be required to implement methods they do not need.

Lets design an UI framework that handles various user interface elements like buttons, text fields, and checkboxes.

1-) Clickable Interface:

Create an interface called Clickable for UI elements that can be clicked.

public interface Clickable {
void click();
}

2-) Typable Interface:

Create another interface called Typable for UI elements that can accept text input.

public interface Typable {
void type();
}

3-) Button Class:

Implement a Button class that implements the Clickable interface.

public class Button implements Clickable {
@Override
public void click() {
System.out.println("Button clicked");
}
}

4-) TextField Class:

Implement a TextField class that implements the Typable interface.

public class TextField implements Typable {
@Override
public void type() {
System.out.println("Text typed in the text field");
}
}

5-) Checkbox Class:

Implement a Checkbox class that implements both Clickable and Typable interfaces.

public class Checkbox implements Clickable, Typable {
@Override
public void click() {
System.out.println("Checkbox clicked");
}

@Override
public void type() {
System.out.println("Text typed in the checkbox");
}
}

6-) Client Code:

Write client code that interacts with these UI elements without depending on methods they don’t need.

public class UIClient {
public void interact(Clickable clickableElement) {
clickableElement.click();
}

public void inputText(Typable typableElement) {
typableElement.type();
}

public static void main(String[] args) {
UIClient client = new UIClient();

Clickable button = new Button();
Typable textField = new TextField();
Clickable typableCheckbox = new Checkbox();

client.interact(button);
client.inputText(textField);
client.interact(typableCheckbox);
}
}

The ISP is followed because each UI element (class) implements only the interfaces it needs, and the client code doesn’t depend on methods it doesn’t use.

By adhering to the Interface Segregation Principle, we ensure that our classes and interfaces remain focused, cohesive, and easy to use.

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. Additionally, abstractions should not depend on details; details should depend on abstractions.

Lets suppose we are developing a notification system that sends messages via various channels (e.g., email, SMS). We want to follow the DIP by abstracting the message sending process and allowing different channels to be easily added without changing the core notification logic.

1-) MessageSender Interface:

Create an interface called MessageSender that defines the method for sending messages.

public interface MessageSender {
void send(String message);
}

2-) EmailSender Class:

Implement an EmailSender class that implements the MessageSender interface.

public class EmailSender implements MessageSender {
@Override
public void send(String message) {
System.out.println("Sending email: " + message);
}
}

3-) SMSMessageSender Class:

Implement an SMSMessageSender class that also implements the MessageSender interface.

public class SMSMessageSender implements MessageSender {
@Override
public void send(String message) {
System.out.println("Sending SMS: " + message);
}
}

4-) NotificationService Class:

Create a NotificationService class that depends on the MessageSender interface for sending messages.

public class NotificationService {
private final MessageSender sender;

public NotificationService(MessageSender sender) {
this.sender = sender;
}

public void sendNotification(String message) {
sender.send(message);
}
}

5-) Client Code:

Use the NotificationService in client code, injecting the appropriate MessageSender implementation.

public class NotificationApp {

public static void main(String[] args) {
MessageSender emailSender = new EmailSender();
MessageSender smsSender = new SMSMessageSender();

NotificationService emailNotification = new NotificationService(emailSender);
NotificationService smsNotification = new NotificationService(smsSender);

emailNotification.sendNotification("Hello via email");
smsNotification.sendNotification("Hello via SMS");
}
}

In this example, the NotificationService depends on the MessageSender interface, and different message sending implementations (EmailSender and SMSMessageSender) adhere to this interface.

The DIP is followed because high-level modules (e.g., NotificationService) do not depend on low-level modules (e.g., EmailSender or SMSMessageSender), and both depend on abstractions (the MessageSender interface).

We can easily add new message sending channels by implementing the MessageSender interface without changing the core NotificationService logic. This design principle promotes decoupling and reduces code fragility.

--

--