When developing software applications, it’s crucial to consider the foundational principles of good software design. These principles guide the way we write, structure, and maintain our code. This article focuses on Java, but the principles we discuss here are generally applicable to other languages as well.
The principles we’ll cover include:
- Single Responsibility Principle (SRP)
- Open-Closed Principle (OCP)
- Liskov Substitution Principle (LSP)
- Interface Segregation Principle (ISP)
- Dependency Inversion Principle (DIP)
These are often collectively known as SOLID principles. Let’s break them down and look at how they apply to Java code.
1. Single Responsibility Principle (SRP)
SRP dictates that a class should have only one reason to change. In other words, each class should have only one job or responsibility. This principle aims to enhance code readability and maintainability by separating concerns.
Let’s look at a simple Java code example:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Employee {
private String employeeName;
private String employeeAddress;
public void setEmployeeName(String employeeName){
this.employeeName = employeeName;
}
public void setEmployeeAddress(String employeeAddress){
this.employeeAddress = employeeAddress;
}
public void saveEmployeeDetails(){
//save details to database
}
}
In this example, the Employee
class is responsible for both holding employee data and saving it to the database. This violates the SRP. We can refactor this code to follow the SRP as follows:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Employee {
private String employeeName;
private String employeeAddress;
public void setEmployeeName(String employeeName){
this.employeeName = employeeName;
}
public void setEmployeeAddress(String employeeAddress){
this.employeeAddress = employeeAddress;
}
}
public class EmployeeDatabase {
public void saveEmployeeDetails(Employee employee){
//save details to database
}
}
After refactoring, the Employee
class’s sole responsibility is to hold employee data, while the EmployeeDatabase
class handles database operations.
2. Open-Closed Principle (OCP)
OCP states that software entities (classes, modules, functions, etc.) should be open for extension but closed for modification. This means you should be able to add new features or functionality without changing the existing code.
Here’s an example that violates the OCP:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Rectangle {
public double length;
public double width;
}
class Circle {
public double radius;
}
class AreaCalculator {
public double calculateArea(Object shape) {
if (shape instanceof Rectangle) {
Rectangle rectangle = (Rectangle) shape;
return rectangle.length * rectangle.width;
} else if (shape instanceof Circle) {
Circle circle = (Circle) shape;
return Math.PI * Math.pow(circle.radius, 2);
}
return 0;
}
}
In the example above, every time we add a new shape, we have to modify the AreaCalculator
class, which violates the OCP. Here’s a refactored version that follows the OCP:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
interface Shape {
double calculateArea();
}
class Rectangle implements Shape {
public double length;
public double width;
@Override
public double calculateArea() {
return length * width;
}
}
class Circle implements Shape {
public double radius;
@Override
public double calculateArea() {
return Math.PI * Math.pow(radius, 2);
}
}
class AreaCalculator {
public double calculateArea(Shape shape)
{
return shape.calculateArea();
}
}
3. Liskov Substitution Principle (LSP)
LSP states that subclasses must be substitutable for their base classes without causing issues. In other words, if class B is a subclass of class A, we should be able to replace A with B without disrupting the behavior of our program.
Consider the following example:
1
2
3
4
5
6
7
8
9
10
11
12
class Bird {
public void fly() {
System.out.println("Bird is flying");
}
}
class Ostrich extends Bird {
@Override
public void fly() {
throw new UnsupportedOperationException("Ostrich can't fly");
}
}
In the above code, even though an ostrich is technically a bird, it can’t fly. So, calling the fly()
method on an Ostrich
object would lead to a runtime error, which violates LSP. Here’s a better way to model this situation:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
interface Bird {
void eat();
}
interface FlyingBird extends Bird {
void fly();
}
class Sparrow implements FlyingBird {
@Override
public void eat() {
System.out.println("Sparrow is eating");
}
@Override
public void fly() {
System.out.println("Sparrow is flying");
}
}
class Ostrich implements Bird {
@Override
public void eat() {
System.out.println("Ostrich is eating");
}
}
4. Interface Segregation Principle (ISP)
ISP advises that clients should not be forced to depend on interfaces they do not use. Essentially, it’s better to have many small, specific interfaces than one large, general interface.
Here’s an example that violates ISP:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
interface Worker {
void work();
void eat();
}
class WorkerImpl implements Worker {
@Override
public void work() {
// work
}
@Override
public void eat() {
// eat
}
}
class Robot implements Worker {
@Override
public void work() {
// work
}
@Override
public void eat() {
throw new UnsupportedOperationException("Robot can't eat");
}
}
In the above code, the Robot
class is forced to implement an eat()
method that it doesn’t need, which violates the ISP. We can fix this by segregating the interface:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
interface Worker {
void work();
}
interface Eater {
void eat();
}
class WorkerImpl implements Worker, Eater {
@Override
public void work() {
// work
}
@Override
public void eat() {
// eat
}
}
class Robot implements Worker {
@Override
public void work() {
// work
}
}
5. Dependency Inversion Principle (DIP)
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.
Consider this code:
1
2
3
4
5
6
7
8
9
10
11
12
13
class EmailService {
void sendEmail(String message, String receiver){
// logic to send email
}
}
class Notification {
private EmailService emailService = new EmailService();
void promotionalNotification(String message, String receiver) {
this.emailService.sendEmail(message, receiver);
}
}
The Notification
class is highly dependent on the EmailService
class. This is a violation of the DIP. Instead, we should depend on an abstraction, like so:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
interface MessageService {
void sendMessage(String message, String receiver);
}
class EmailService
implements MessageService {
@Override
public void sendMessage(String message, String receiver) {
// logic to send email
}
}
class Notification {
private MessageService messageService;
Notification(MessageService messageService) {
this.messageService = messageService;
}
void promotionalNotification(String message, String receiver) {
this.messageService.sendMessage(message, receiver);
}
}
In the refactored code, both Notification
and EmailService
depend on the MessageService
interface, an abstraction. This allows us to easily substitute EmailService
with another service if needed.
Wrapping Up
By adhering to these SOLID principles, you can write software that’s easy to maintain, understand, and expand. While it might require a bit of extra effort up front, it’s well worth it in the long run. Remember, these principles aren’t hard rules, but rather guidelines to help you structure your code effectively. Use them wisely to improve your Java codebase.