The SOLID principles are a set of guidelines in software design that help create robust, maintainable, and scalable applications. In this blog, we'll explore each principle with easy-to-understand examples.
1. Single Responsibility Principle (SRP)
Definition: A class should have only one reason to change, meaning it should have a single responsibility or job.
Summary: We'll see how separating responsibilities between a controller and a service class ensures adherence to SRP.
Example: Controller and Service
Violating SRP:
In the code below, the UserController handles both the HTTP requests and the business logic:
class UserController {
public String getUserDetails(int userId) {
// Fetch user details (business logic)
String user = "User: " + userId;
return user;
}
}
Following SRP:
We can separate the responsibilities by delegating the business logic to a UserService:
class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
public String getUserDetails(int userId) {
return userService.fetchUserDetails(userId);
}
}
class UserService {
public String fetchUserDetails(int userId) {
return "User: " + userId;
}
}
public class Main {
public static void main(String[] args) {
UserService userService = new UserService();
UserController userController = new UserController(userService);
System.out.println(userController.getUserDetails(1));
}
}
Now, UserController only handles HTTP requests, and UserService takes care of business logic.
2. Open/Closed Principle (OCP)
Definition: A class should be open for extension but closed for modification.
Summary: We'll demonstrate how to add new payment methods without altering the existing payment processing code.
Example: Payment System
Code:
interface PaymentMethod {
void pay(double amount);
}
class CreditCardPayment implements PaymentMethod {
@Override
public void pay(double amount) {
System.out.println("Paid $" + amount + " using Credit Card.");
}
}
class PayPalPayment implements PaymentMethod {
@Override
public void pay(double amount) {
System.out.println("Paid $" + amount + " using PayPal.");
}
}
class PaymentProcessor {
private final PaymentMethod paymentMethod;
public PaymentProcessor(PaymentMethod paymentMethod) {
this.paymentMethod = paymentMethod;
}
public void processPayment(double amount) {
paymentMethod.pay(amount);
}
}
// Adding a new payment method without modifying existing code
class BitcoinPayment implements PaymentMethod {
@Override
public void pay(double amount) {
System.out.println("Paid $" + amount + " using Bitcoin.");
}
}
public class Main {
public static void main(String[] args) {
PaymentProcessor creditCardProcessor = new PaymentProcessor(new CreditCardPayment());
creditCardProcessor.processPayment(100);
PaymentProcessor paypalProcessor = new PaymentProcessor(new PayPalPayment());
paypalProcessor.processPayment(200);
PaymentProcessor bitcoinProcessor = new PaymentProcessor(new BitcoinPayment());
bitcoinProcessor.processPayment(300);
}
}
The PaymentProcessor can now handle new payment methods (like BitcoinPayment) by extending the PaymentMethod interface without modifying existing classes.
3. Liskov Substitution Principle (LSP)
Definition: Subtypes must be substitutable for their base types without affecting the program’s correctness.
Summary: We'll use examples from the Java Collection Framework and a storage service hierarchy to illustrate LSP.
Example 1: List and ArrayList
import java.util.List;
import java.util.ArrayList;
public class LspCollectionExample {
public static void printList(List<String> list) {
list.add("Item 1");
list.add("Item 2");
System.out.println("List contents: " + list);
}
public static void main(String[] args) {
List<String> arrayList = new ArrayList<>(); // Subclass
printList(arrayList); // Works seamlessly
}
}
Here, ArrayList can replace List without any issues, adhering to LSP.
Example 2: Storage Services
Code:
class StorageService {
public void store(String data) {
System.out.println("Storing data: " + data);
}
}
class S3Service extends StorageService {
@Override
public void store(String data) {
System.out.println("Storing data in S3: " + data);
}
}
class MinioService extends StorageService {
@Override
public void store(String data) {
System.out.println("Storing data in Minio: " + data);
}
}
public class LspStorageExample {
public static void saveData(StorageService storageService, String data) {
storageService.store(data);
}
public static void main(String[] args) {
saveData(new S3Service(), "File1.txt"); // Storing data in S3
saveData(new MinioService(), "File2.txt"); // Storing data in Minio
}
}
Both S3Service and MinioService extend StorageService and can be used interchangeably without breaking functionality.
4. Interface Segregation Principle (ISP)
Definition: A class should not be forced to implement interfaces it does not use.
Summary: We'll see how splitting a notification interface into smaller ones ensures classes only implement what they need.
Example: Notification Service
Violating ISP:
interface NotificationService {
void sendEmail(String message);
void sendSMS(String message);
void sendPushNotification(String message);
}
class EmailNotification implements NotificationService {
@Override
public void sendEmail(String message) {
System.out.println("Sending email: " + message);
}
@Override
public void sendSMS(String message) {
// Not needed
}
@Override
public void sendPushNotification(String message) {
// Not needed
}
}
Following ISP:
Split the interface into smaller ones:
interface EmailService {
void sendEmail(String message);
}
interface SMSService {
void sendSMS(String message);
}
interface PushNotificationService {
void sendPushNotification(String message);
}
class EmailNotification implements EmailService {
@Override
public void sendEmail(String message) {
System.out.println("Sending email: " + message);
}
}
public class Main {
public static void main(String[] args) {
EmailNotification emailNotification = new EmailNotification();
emailNotification.sendEmail("Hello World!");
}
}
Now, EmailNotification only implements what it needs.
5. Dependency Inversion Principle (DIP)
Definition: High-level modules should not depend on low-level modules. Both should depend on abstractions.
Summary: We'll demonstrate how introducing an interface between a controller and a service class ensures flexibility and adherence to DIP.
Example: Controller and Service Interface
Violating DIP:
class UserService {
public String fetchUserDetails(int userId) {
return "User: " + userId;
}
}
class UserController {
private final UserService userService = new UserService(); // Direct dependency
public String getUserDetails(int userId) {
return userService.fetchUserDetails(userId);
}
}
public class Main {
public static void main(String[] args) {
UserController userController = new UserController();
System.out.println(userController.getUserDetails(1));
}
}
Following DIP:
Introduce an abstraction (interface):
interface UserService {
String fetchUserDetails(int userId);
}
class UserServiceImpl implements UserService {
@Override
public String fetchUserDetails(int userId) {
return "User: " + userId;
}
}
class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
public String getUserDetails(int userId) {
return userService.fetchUserDetails(userId);
}
}
public class Main {
public static void main(String[] args) {
UserService userService = new UserServiceImpl();
UserController userController = new UserController(userService);
System.out.println(userController.getUserDetails(1));
}
}
Now, UserController depends on the UserService interface, allowing flexibility in implementations.
By adhering to these SOLID principles, we can design systems that are easier to maintain, test, and extend while minimizing the risk of introducing bugs when adding new functionality.