Understanding SOLID Principles in Software Development with Java Examples

Chaewonkong
5 min readApr 13, 2023

--

eat, sleep, code, repeat.

SOLID is an acronym that represents a set of five design principles for writing maintainable and scalable software. It was introduced by Robert C. Martin and has become a widely accepted standard in the world of software development. It is consist of 5 different principles, for better Object-oriented development:

  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)

In this blog post, we’ll dive into the SOLID principles, understand their importance in software development, and explore each principle with Java examples.

1. Single Responsibility Principle (SRP)

The Single Responsibility Principle states that a class should have only one reason to change, which means it should have only one responsibility. This principle promotes separation of concerns, which makes the code more maintainable and easier to understand.

Java Example:

Consider a class that handles user data and user authentication:

class User {
// User data related methods
// ...

// Authentication related methods
// ...
}

According to SRP, we should separate user data handling and authentication into two classes:

class User {
// User data related methods
// ...
}

class Authentication {
// Authentication related methods
// ...
}

2. Open/Closed Principle (OCP)

The Open/Closed Principle states that a software entity should be open for extension but closed for modification. This means that you should be able to add new functionality without modifying existing code.

Java Example:

Consider a simple shape area calculator:

class ShapeAreaCalculator {
public double calculateArea(Object shape) {
if (shape instanceof Rectangle) {
Rectangle rectangle = (Rectangle) shape;
return rectangle.getWidth() * rectangle.getHeight();
} else if (shape instanceof Circle) {
Circle circle = (Circle) shape;
return Math.PI * Math.pow(circle.getRadius(), 2);
}
// ...
}
}

The above code violates the OCP since we need to modify the calculateArea() method every time we add a new shape. A better approach is to use an interface:

interface Shape {
double calculateArea();
}

class Rectangle implements Shape {
// ...

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

class Circle implements Shape {
// ...

@Override
public double calculateArea() {
return Math.PI * Math.pow(radius, 2);
}
}

class ShapeAreaCalculator {
public double calculateArea(Shape shape) {
return shape.calculateArea();
}
}

3. Liskov Substitution Principle (LSP)

The Liskov Substitution Principle states that objects of a derived class should be able to replace objects of the base class without affecting the correctness of the program. This principle promotes using polymorphism and inheritance correctly, ensuring that a subclass can always be a proper substitute for its superclass.

Java Example:

Consider a class hierarchy with a base class Animal and two derived classes Dog and Fish. In this version, the Animal class has a breathe() method, which doesn't make sense for a Fish class:

class Animal {
void move() {
System.out.println("The animal is moving.");
}

void breathe() {
System.out.println("The animal is breathing air.");
}
}

class Dog extends Animal {
@Override
void move() {
System.out.println("The dog is running.");
}
}

class Fish extends Animal {
@Override
void move() {
System.out.println("The fish is swimming.");
}

@Override
void breathe() {
throw new UnsupportedOperationException("Fish don't breathe air.");
}
}

In this example, the Fish class violates the LSP because it overrides the breathe() method to throw an exception, which means it cannot be used as a substitute for the Animal class without affecting the correctness of the program.

Now let’s refactor the code to conform to the LSP. We can introduce an abstract class AirBreathingAnimal to handle the breathe() method:

abstract class Animal {
abstract void move();
}

abstract class AirBreathingAnimal extends Animal {
void breathe() {
System.out.println("The animal is breathing air.");
}
}

class Dog extends AirBreathingAnimal {
@Override
void move() {
System.out.println("The dog is running.");
}
}

class Fish extends Animal {
@Override
void move() {
System.out.println("The fish is swimming.");
}
}

In the refactored code, we’ve removed the breathe() method from the base Animal class and created a new abstract class AirBreathingAnimal that extends Animal and includes the breathe() method. Now, the Dog class extends AirBreathingAnimal, and the Fish class extends Animal. This new structure upholds the LSP since the Fish class no longer has to override the breathe() method, and it can be used as a proper substitute for the Animal class without affecting the program's correctness.

4. Interface Segregation Principle (ISP)

The Interface Segregation Principle states that clients should not be forced to depend on interfaces they do not use. This principle promotes creating smaller, more focused interfaces instead of large, monolithic ones. This makes the code more modular and easier to maintain.

Java Example:

Consider an interface for a MultimediaPlayer that combines audio and video playback methods:

interface MultimediaPlayer {
void playAudio();
void playVideo();
}

A class implementing this interface may only need to support audio playback:

class AudioPlayer implements MultimediaPlayer {
@Override
public void playAudio() {
// ...
}

@Override
public void playVideo() {
// This method is not needed for an audio player
throw new UnsupportedOperationException();
}
}

According to ISP, we should split the MultimediaPlayer interface into smaller interfaces:

interface AudioPlayer {
void playAudio();
}

interface VideoPlayer {
void playVideo();
}

class AudioPlayerImpl implements AudioPlayer {
@Override
public void playAudio() {
// ...
}
}

class VideoPlayerImpl implements VideoPlayer {
@Override
public void playVideo() {
// ...
}
}

5. Dependency Inversion Principle (DIP)

The Dependency Inversion Principle states that high-level modules should not depend on low-level modules; both should depend on abstractions. This principle promotes using interfaces or abstract classes to create flexible and decoupled systems.

Java Example:

Suppose we have a FileReader class that reads data from a file, and a DataProcessor class that processes the data:

class FileReader {
String readDataFromFile(String fileName) {
// Read data from file
}
}

class DataProcessor {
private FileReader fileReader;

public DataProcessor() {
this.fileReader = new FileReader();
}

void processData(String fileName) {
String data = fileReader.readDataFromFile(fileName);
// Process the data
}
}

In this example, the DataProcessor class directly depends on the FileReader class, which makes it difficult to replace the file reader with another data source. To apply the DIP, we can introduce an interface for the data reader:

interface DataReader {
String readData(String source);
}

class FileReader implements DataReader {
@Override
public String readData(String fileName) {
// Read data from file
}
}

class DataProcessor {
private DataReader dataReader;

public DataProcessor(DataReader dataReader) {
this.dataReader = dataReader;
}

void processData(String source) {
String data = dataReader.readData(source);
// Process the data
}
}

Now, the DataProcessor class depends on the DataReader interface instead of the concrete FileReader class, allowing us to easily switch to other data sources (e.g., a DatabaseReader or an APIReader) by implementing the DataReader interface.

Conclusion

The SOLID principles are essential guidelines for designing maintainable, scalable, and flexible software. By adhering to these principles, developers can create code that is easier to understand, modify, and extend. The Java examples provided in this blog post demonstrate how to apply SOLID principles in real-world scenarios, helping you improve your software design skills.

--

--