Quick guide to being solid with SOLID principles in Java

A beginner’s guide to better Java programming

Osanda Deshan Nimalarathna
Test Automation Master
6 min readAug 17, 2024

--

Introduction

In software development, creating maintainable and scalable code is essential. One way to achieve this is by adhering to SOLID principles. SOLID is an acronym for five design principles that make software designs more understandable, flexible, and maintainable. In this article, I am going to break down each principle with real-world examples using Java.

What is SOLID?

SOLID stands for:

  1. Single Responsibility Principle (SRP)
  2. Open/Closed Principle (OCP)
  3. Liskov Substitution Principle (LSP)
  4. Interface Segregation Principle (ISP)
  5. Dependency Inversion Principle (DIP)

1. Single Responsibility Principle (SRP)

Definition: A class should have only one reason to change. This means that a class should have only one responsibility or job.

Concept: Imagine a class that handles multiple unrelated responsibilities. If a change occurs in one responsibility, it might impact the other responsibilities, making the class harder to maintain.

Detailed Example: Consider a Report class that handles both report generation and formatting:

public class Report {
private String content;

public void generateReport() {
// Logic to generate the report content
this.content = "Report Content";
}

public void formatReport() {
// Logic to format the report content
System.out.println("Formatted Report: " + content);
}
}

Issue: The Report class has two responsibilities: generating content and formatting it. If formatting requirements change, you might need to modify the Report class, impacting both responsibilities.

Solution: Apply SRP by splitting the responsibilities into different classes:

public class Report {
private String content;

public void generateContent() {
// Logic to generate the report content
this.content = "Report Content";
}

public String getContent() {
return content;
}
}
public class ReportFormatter {
public void format(Report report) {
// Logic to format the report content
System.out.println("Formatted Report: " + report.getContent());
}
}

Explanation: Now, Report is only responsible for content generation, while ReportFormatter handles formatting. This makes each class easier to maintain and change independently.

2. Open/Closed Principle (OCP)

Definition: Software entities (classes, modules, functions, etc.) should be open for extension but closed for modification.

Concept: You should be able to add new functionality without altering existing code. This prevents the risk of introducing bugs when modifying existing, stable code.

Detailed Example: Consider a payment processing system that initially supports only credit card payments:

public class PaymentProcessor {
public void processCreditCardPayment(double amount) {
// Process credit card payment
}
}

Issue: If you want to add support for another payment method (e.g., PayPal), you would need to modify the PaymentProcessor class, which could introduce errors.

Solution: Use an interface and separate classes for each payment method:

public interface PaymentMethod {
void processPayment(double amount);
}
public class CreditCardPayment implements PaymentMethod {
public void processPayment(double amount) {
// Process credit card payment
}
}
public class PayPalPayment implements PaymentMethod {
public void processPayment(double amount) {
// Process PayPal payment
}
}
public class PaymentProcessor {
private PaymentMethod paymentMethod;

public PaymentProcessor(PaymentMethod paymentMethod) {
this.paymentMethod = paymentMethod;
}

public void processPayment(double amount) {
paymentMethod.processPayment(amount);
}
}

Explanation: PaymentProcessor no longer depends on specific payment methods but rather on the PaymentMethod interface. New payment methods can be added without changing PaymentProcessor.

3. Liskov Substitution Principle (LSP)

Definition: Subtypes must be substitutable for their base types without altering the correctness of the program.

Concept: If a class is a subtype of another, it should be able to replace the parent class without causing issues. This ensures that the derived class adheres to the behavior expected by the base class.

Detailed Example: Consider a base class Bird and a subclass Penguin:

public class Bird {
public void fly() {
// Fly implementation
}
}
public class Penguin extends Bird {
@Override
public void fly() {
// Penguins can't fly
throw new UnsupportedOperationException("Penguins can't fly");
}
}

Issue: Penguin violates LSP because it cannot substitute Bird without causing issues.

Solution: Refactor to ensure that subclasses adhere to the expected behavior:

public abstract class Bird {
public abstract void move();
}
public class Sparrow extends Bird {
@Override
public void move() {
// Flying implementation
}
}
public class Penguin extends Bird {
@Override
public void move() {
// Walking implementation
}
}

Explanation: Both Sparrow and Penguin correctly implement move, allowing them to be used interchangeably where Bird is expected.

4. Interface Segregation Principle (ISP)

Definition: A class should not be forced to implement interfaces it does not use. Clients should not depend on methods they do not use.

Concept: Large interfaces that contain methods unrelated to specific clients should be split into smaller, more specific interfaces. This ensures that implementing classes only need to be concerned with methods that are relevant to them.

Detailed Example: Consider a Worker interface with unrelated methods:

public interface Worker {
void work();
void eat();
}

Issue: A RobotWorker might not need the eat method:

public class RobotWorker implements Worker {
public void work() {
// Robot working logic
}

public void eat() {
// Unnecessary or throws exception
}
}

Solution: Split the interface into more specific ones:

public interface Workable {
void work();
}
public interface Eatable {
void eat();
}
public class HumanWorker implements Workable, Eatable {
public void work() {
// Human working logic
}

public void eat() {
// Human eating logic
}
}
public class RobotWorker implements Workable {
public void work() {
// Robot working logic
}
}

Explanation: Workable and Eatable interfaces are more specific, and RobotWorker implements only the Workable interface, adhering to ISP.

5. Dependency Inversion Principle (DIP)

Definition: High-level modules should not depend on low-level modules. Both should depend on abstractions. Abstractions should not depend on details. Details should depend on abstractions.

Concept: High-level modules should rely on interfaces or abstract classes rather than concrete implementations. This reduces the coupling between different parts of the system and enhances flexibility.

Detailed Example: Consider a UserService that directly depends on UserRepository:

public class UserRepository {
public void save(User user) {
// Save user logic
}
}
public class UserService {
private UserRepository userRepository = new UserRepository();

public void register(User user) {
userRepository.save(user);
}
}

Issue: UserService is tightly coupled with UserRepository, making it hard to change the repository implementation or to test UserService in isolation.

Solution: Use dependency injection and abstractions:

public interface UserRepository {
void save(User user);
}
public class JdbcUserRepository implements UserRepository {
public void save(User user) {
// Save user logic using JDBC
}
}
public class UserService {
private UserRepository userRepository;

public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}

public void register(User user) {
userRepository.save(user);
}
}

Explanation: UserService depends on the UserRepository interface rather than a concrete implementation. This allows for different implementations of UserRepository and simplifies testing and maintenance.

Conclusion

SOLID principles needs to be complied when developing software in order to develop code that is reliable and flexible. These SOLID principles Single Responsibility Principle (SRP), Open/Closed Principle (OCP), Liskov Substitution Principle (LSP), Interface Segregation Principle (ISP), and Dependency Inversion Principle (DIP) serve as guidelines to ensure that your code remains clean, maintainable, and scalable.

By following SRP, you ensure that each class has a single responsibility, which simplifies maintenance and reduces the risk of unintended side effects when changes are made. The OCP encourages you to design systems that can be extended with new functionality without altering existing code, thereby minimizing the risk of introducing bugs. LSP ensures that derived classes can be used interchangeably with their base classes without breaking the functionality, fostering code reliability and consistency. ISP advocates for creating smaller, more specific interfaces, which makes your code more modular and easier to understand. Finally, DIP promotes the use of abstractions rather than concrete implementations, which enhances flexibility and makes your codebase more adaptable to change.

Incorporating these principles into your Java projects not only helps in building high-quality software but also equips you with best practices that are recognized and valued across the software development industry. By continuously applying SOLID principles, you pave the way for developing software that is easier to test, extend, and maintain ultimately leading to more successful and sustainable software solutions.

Utilize these principles, practice them regularly, and observe how they transform your approach to software design, making your code more robust and adaptable to future changes.

Happy Programming !!!

--

--

Osanda Deshan Nimalarathna
Test Automation Master

Founder of MaxSoft | RPA Solution Architect | Open-source Contributor | Automation Framework Developer | Technical Specialist