SOLID Principles for Better Software Design
An introduction to the SOLID principles of software design with practical examples in Java.
Introduction
SOLID principles are a set of guidelines for writing high-quality, maintainable, and scalable software. They were introduced by Robert C. Martin, a renowned software engineer, and consultant, in his 2000 paper “Design Principles and Design Patterns” to help developers write software that is easy to understand, modify, and extend. These concepts were later built upon by Michael Feathers, who introduced us to the SOLID acronym.
The SOLID acronym stands for:
Single Responsibility Principle (SRP)
Open-Closed Principle (OCP)
Liskov Substitution Principle (LSP)
Interface Segregation Principle (ISP)
Dependency Inversion Principle (DIP)
These principles provide a way for developers to organize their code and create software that is flexible, easy to change, and testable. Applying SOLID principles can lead to code that is more modular, maintainable, and extensible, and it can make it easier for developers to work collaboratively on a codebase.
In this tutorial, we will explore each of the SOLID principles in detail, explain why they are important, and provide examples of how they can be applied in practice. By the end of this tutorial, you should have a good understanding of the SOLID principles and how to apply them to your software development projects.
Single Responsibility Principle
The Single Responsibility Principle (SRP) states that a class should have only one reason to change, or in other words, it should have only one responsibility. This means that a class should have only one job to do, and it should do it well.
If a class has too many responsibilities, it can become hard to understand, maintain, and modify. Changes to one responsibility can inadvertently affect another responsibility, leading to unintended consequences and bugs. By following SRP, we can create code that is more modular, easier to understand, and less prone to errors.
Let's take an example that violates the SRP:
class Marker {
String name;
String color;
int price;
public Marker(String name, String color, int price) {
this.name = name;
this.color = color;
this.price = price;
}
}
The above code defines a simple Marker
class having three instance variables - name
, color
and price
.
class Invoice {
private Marker marker;
private int quantity;
public Invoice(Marker marker, int quantity) {
this.marker = marker;
this.quantity = quantity;
}
public int calculateTotal() {
return marker.price * this.quantity;
}
public void printInvoice() {
// printing implementation
}
public void saveToDb() {
// save to database implementation
}
}
The above Invoice
class violates SRP because it has multiple responsibilities - it is responsible for calculating the total amount, printing the invoice, and saving the invoice to the database. As a result, if the calculation logic changes, such as the addition of taxes, the calculateTotal()
method would require modification. Similarly, if the printing or database-saving implementation changes at any point, the class would need to be changed. Therefore, there are several reasons for the class to be modified, which could lead to increased maintenance costs and complexity.
Here's how you can modify the code to follow SRP:
class Invoice {
private Marker marker;
private int quantity;
public Invoice(Marker marker, int quantity) {
this.marker = marker;
this.quantity = quantity;
}
public int calculateTotal() {
return marker.price * this.quantity;
}
}
class InvoiceDao {
private Invoice invoice;
public InvoiceDao(Invoice invoice) {
this.invoice = invoice;
}
public void saveToDb() {
// save to database implementation
}
}
class InvoicePrinter {
private Invoice invoice;
public InvoicePrinter(Invoice invoice) {
this.invoice = invoice;
}
public void printInvoice() {
// printing implementation
}
}
In this refactored example, we have split the responsibilities of the Invoice
class into three separate classes - Invoice
, InvoiceDao
, and InvoicePrinter
. The Invoice
class is responsible only for calculating the total amount, and the printing and saving responsibilities have been delegated to separate classes. This makes the code more modular, easier to understand, and less prone to errors.
Open-Closed Principle
The Open-Closed Principle (OCP) states that software entities (classes, modules, functions, etc.) should be open for extension but closed for modification. This means that the behavior of a software entity can be extended without modifying its source code.
The OCP is essential because it promotes software extensibility and maintainability. By allowing software entities to be extended without modification, developers can add new functionality without the risk of breaking existing code. This results in code that is easier to maintain, extend, and reuse.
Let's take the previous example again.
class InvoiceDao {
private Invoice invoice;
public InvoiceDao(Invoice invoice) {
this.invoice = invoice;
}
public void saveToDb() {
// save to database implementation
}
}
The InvoiceDao
class has a single responsibility of saving the invoice to the database. But, suppose there's a new requirement to save the invoice to a file as well. One way to implement this requirement would be to modify the existing InvoiceDao
class by adding a saveToFile()
method. However, this violates the Open-Closed Principle because it modifies the existing code that has already been tested and is live in production.
To follow the OCP, a better solution would be to create an InvoiceDao
interface and implement it separately for database and file saving as shown below:
interface InvoiceDao {
public void save(Invoice invoice);
}
class DatabaseInvoiceDao implements InvoiceDao {
@Override
public void save(Invoice invoice) {
// save to database implementation
}
}
class FileInvoiceDao implements InvoiceDao {
@Override
public void save(Invoice invoice) {
// save to file implementation
}
}
This way, if there's a new requirement to save the invoice to another data store, you can implement a new InvoiceDao
implementation without modifying the existing code. Thus, the InvoiceDao
interface is open for extension and closed for modification, which follows the OCP.
Liskov Substitution Principle
The Liskov Substitution Principle (LSP) states that any instance of a derived class should be substitutable for an instance of its base class without affecting the correctness of the program. In other words, a derived class should behave like its base class in all contexts. In more simple terms, if class A is a subtype of class B, you should be able to replace B with A without breaking the behavior of your program.
The importance of LSP lies in its ability to ensure that the behavior of a program remains consistent and predictable when substituting objects of different classes. Violating LSP can lead to unexpected behavior, bugs, and maintainability issues.
Let's take an example.
interface Bike {
void turnOnEngine();
void accelerate();
}
In the given example, the interface Bike
has two methods, turnOnEngine()
and accelerate()
. Two classes implement this interface, Motorbike
and Bicycle
.
class Motorbike implements Bike {
boolean isEngineOn;
int speed;
@Override
public void turnOnEngine() {
isEngineOn = true;
}
@Override
public void accelerate() {
speed += 5;
}
}
Motorbike
correctly implements the turnOnEngine()
method, as it sets the isEngineOn
boolean to true. It also correctly implements the accelerate()
method by increasing the speed
by 5.
class Bicycle implements Bike {
boolean isEngineOn;
int speed;
@Override
public void turnOnEngine() {
throw new AssertionError("There is no engine!");
}
@Override
public void accelerate() {
speed += 5;
}
}
However, the Bicycle
class throws an AssertionError
in the turnOnEngine()
method because it has no engine. This means that an instance of Bicycle
cannot be substituted for an instance of Bike
without breaking the behavior of the program.
In other words, if the Bicycle
class is considered a subtype of the Bike
interface, then according to LSP, any instance of Bike
should be replaceable with an instance of Bicycle
without altering the correctness of the program. But in this case, it's not true because Bicycle
throws an AssertionError
while trying to turn on the engine. Therefore, the code violates the LSP.
Interface Segregation Principle
The Interface Segregation Principle (ISP) focuses on designing interfaces that are specific to their client's needs. It states that no client should be forced to depend on methods it does not use.
The principle suggests that instead of creating a large interface that covers all the possible methods, it's better to create smaller, more focused interfaces for specific use cases. This approach results in interfaces that are more cohesive and less coupled.
Consider a Vehicle
interface as below:
interface Vehicle {
void startEngine();
void stopEngine();
void drive();
void fly();
}
And then you have a class called Car
that implements the Vehicle
interface:
class Car implements Vehicle {
@Override
public void startEngine() {
// implementation
}
@Override
public void stopEngine() {
// implementation
}
@Override
public void drive() {
// implementation
}
@Override
public void fly() {
throw new UnsupportedOperationException("This vehicle cannot fly.");
}
}
In this example, the Vehicle
interface has too many methods. The Car
class is forced to implement all of them, even though they cannot fly. This violates the ISP because the Vehicle
interface is not properly segregated into smaller interfaces based on related functionality.
Let's understand how you can follow ISP here. Suppose you refactor the Vehicle
interface into smaller, more focused interfaces:
interface Drivable {
void startEngine();
void stopEngine();
void drive();
}
interface Flyable {
void fly();
}
Now, you can have a class called Car
that only implements the Drivable
interface:
class Car implements Drivable {
@Override
public void startEngine() {
// implementation
}
@Override
public void stopEngine() {
// implementation
}
@Override
public void drive() {
// implementation
}
}
And, thanks to interface segregation, you can have another class called Airplane
that implements both the Drivable
and Flyable
interfaces:
class Airplane implements Drivable, Flyable {
@Override
public void startEngine() {
// implementation
}
@Override
public void stopEngine() {
// implementation
}
@Override
public void drive() {
// implementation
}
@Override
public void fly() {
// implementation
}
}
In this example, you have properly segregated the Vehicle
interface into smaller interfaces based on related functionality. This adheres to the ISP and makes your code more flexible and maintainable.
Dependency Inversion Principle
The Dependency Inversion Principle (DIP) states that high-level modules should not depend on low-level modules, but both should depend on abstractions. Abstractions should not depend on details; details should depend on abstractions. This principle aims to reduce coupling between modules, increase modularity, and make the code easier to maintain, test, and extend.
For example, consider a scenario where you have a class that needs to use an instance of another class. In the traditional approach, the first class would directly create an instance of the second class, leading to a tight coupling between them. This makes it difficult to change the implementation of the second class or to test the first class independently. However, if you apply the DIP, the first class would depend on an abstraction of the second class instead of the implementation, making it possible to easily change the implementation and test the first class independently.
Here is an example that violates the DIP:
class WeatherTracker {
private String currentConditions;
private Emailer emailer;
public WeatherTracker() {
this.emailer = new Emailer();
}
public void setCurrentConditions(String weatherDescription) {
this.currentConditions = weatherDescription;
if (weatherDescription == "rainy") {
emailer.sendEmail("It is rainy");
}
}
}
class Emailer {
public void sendEmail(String message) {
System.out.println("Email sent: " + message);
}
}
In this example, the WeatherTracker
class directly creates an instance of the Emailer
class, making it tightly coupled to the implementation. This makes it difficult to change the implementation of the Emailer
class or to test the WeatherTracker
class independently.
Here is an example of how to apply the DIP to the above code:
interface Notifier {
public void alertWeatherConditions(String weatherDescription);
}
class WeatherTracker {
private String currentConditions;
private Notifier notifier;
public WeatherTracker(Notifier notifier) {
this.notifier = notifier;
}
public void setCurrentConditions(String weatherDescription) {
this.currentConditions = weatherDescription;
if (weatherDescription == "rainy") {
notifier.alertWeatherConditions("It is rainy");
}
}
}
class Emailer implements Notifier {
public void alertWeatherConditions(String weatherDescription) {
System.out.println("Email sent: " + weatherDescription);
}
}
class SMS implements Notifier {
public void alertWeatherConditions(String weatherDescription) {
System.out.println("SMS sent: " + weatherDescription);
}
}
In this example, you created a Notifier
interface that defines the alertWeatherConditions
method. The WeatherTracker
class now depends on this interface instead of the Emailer
class, making it possible to easily change the implementation and test the WeatherTracker
class independently. You also created two implementations of the Notifier
interface, Emailer
, and SMS
, to demonstrate how you can change the implementation of the WeatherTracker
class without affecting its behavior.
Conclusion
In this article, you learned about SOLID principles which is a very important part of Design Principles. By applying these principles in your software development projects, you can create code that is easier to maintain, extend, and modify, leading to more robust, flexible, and reusable software. This will also lead to better collaboration among team members, as the code becomes more modular and easier to work with.