A Guide to Writing Maintainable Code: The SOLID Principles
CONCEPTS
5/12/20247 min read
The SOLID Principles: A Guide to Writing Maintainable Code
As developers, one of our main goals is to write code that is maintainable, scalable, and easy to understand. The SOLID principles provide a set of guidelines that help us achieve these goals. In this article, we will explore each of the SOLID principles and provide examples using the Java framework.
1. Single Responsibility Principle (SRP)
The Single Responsibility Principle states that a class should have only one reason to change. In other words, a class should have only one reason to change. This principle advocates for ensuring that each class or module should have a single responsibility and should encapsulate that responsibility entirely. This promotes high cohesion and reduces coupling.
Let's provide a practical scenario - Each room in your house has a single purpose. For example, the kitchen is for cooking, the bedroom is for sleeping, and the bathroom is for bathing. This ensures that if you need to make changes or repairs, you only have to focus on one area.
2. Open/Closed Principle (OCP)
The Open/Closed Principle states that classes should be open for extension but closed for modification. This means that we should be able to add new functionality to a class without modifying its existing code. This principle promotes code reuse and helps prevent introducing bugs in existing code.
Let's provide a practical scenario - Your house is designed with the future in mind. If you decide you want to add a new room, like a home office, you shouldn't have to tear down existing walls or reconfigure plumbing. The house should be open to expansion but closed to the need for major renovations.
3. Liskov Substitution Principle (LSP)
The Liskov Substitution Principle states that objects of a superclass should be replaceable with objects of its subclasses without affecting the correctness of the program. In simpler terms, this principle ensures that subclasses can be used interchangeably with their parent class.
Let's provide a practical scenario - Imagine your house has a pet door. Whether it's a small dog or a big dog, they should all be able to use the door without any issues. In software terms, this means that any derived classes (like different breeds of dogs) should be able to seamlessly replace the base class (the pet door).
4. Interface Segregation Principle (ISP)
Clients should not be forced to depend on interfaces they do not use. This principle emphasizes creating smaller, more specific interfaces that are tailored to the needs of the client, rather than having large, monolithic interfaces that cater to multiple clients.
Let's provide a practical scenario - When you invite guests over, you don't force them to use every room in your house. Similarly, interfaces should be tailored to specific needs. If someone only needs access to the living room, they shouldn't be burdened with access to the kitchen and bedroom as well.
5. Dependency Inversion Principle (DIP)
High-level modules should not depend on low-level modules. Both should depend on abstractions. Additionally, abstractions should not depend on details; rather, details should depend on abstractions. This principle encourages decoupling between modules by introducing an abstraction layer, usually through interfaces or abstract classes.
Let's provide a practical scenario - Think of your house's utilities, like electricity and water. Instead of directly connecting to the power grid or water supply, your house relies on standardized interfaces like electrical outlets and faucets. This allows you to easily switch providers or upgrade your systems without rewiring the entire house.
By following the SOLID principles, we can write code that is more maintainable, scalable, and easy to understand. These principles provide a solid foundation for building robust and flexible applications. Remember to always strive for code that is modular, reusable, and adheres to these principles.
Lets build some examples to showcase the 5 SOLID principles
1. Single Responsibility Principle (SRP)
// Bad Example
We have one class Phone, and methods - savePhoneDetails and savePhoneOwnerDetails.
This violates SCP as the Phone class is trying to save the user information also
//Correct Example
Lets add 2 class - Phone and User. Phone will have responsibility to save only phone details,
and User will have responsibility to save user related details
class Phone{
public void savePhoneDetails() {
// Saves phone details ( colour, memory, hdd, price )
}
public void savePhoneOwnerDetails() {
// Saves user who purchases the phone to database
}
}
class Phone{
public void savePhoneDetails() {
// Saves phone details ( colour, memory, hdd, price )
}
}
class User{
public void saveUserDetails() {
// Saves user who purchases the phone to database
}
}
2. Open/Closed Principle (OCP)
// Bad Example
Lets create two classes - Phone.java and Tablet.java
Let's add a client class - OCP.java to send message from phone/tablet
The above code is not OCP compliant, as if we need to add one more gadget ,
lets say TV or Camera, we need to add separate methods for playing video for both TV or Camera.
Lets fix this and make the code OCP compliant
//Correct Example
Lets add an interface - Gadget.java
Lets modify Phone and Tablet to implement the interface
Lets modify the OCP.java
The above code is now OCP compliant, as OCP class is closed for any modification, but open for extension means Additional gadget components can be added without modifying the OCP class as it will be able to handle any additional components with Gadget interface
public class OCP{
public int playVideo(Gadget gadget) {
return gadget.playVideo();
}
}
public class Phone implements Gadget{
@Override
public String playVideo() {
return "Video played from phone";
}
}
public class Tablet implements Gadget{
@Override
public String playVideo() {
return "Video played from tablet";
}
}
}
public interface Gadget {
String playVideo();
}
public class OCP{
public int playVideo(Phone phone) {
return phone.playVideo();
}
public int playVideo(Tablet tab) {
return tab.playVideo();
}
}
class Phone {
String playVideo() {
return "Video played from phone";
}
}
class Tablet {
String playVideo() {
return "Video played from tablet";
}
}
3. Liskov Substitution Principle (LSP)
// Bad Example
Lets use the above example and modify Gadget.java
Lets modify Phone and Tablet to add the new method
Lets try to add one more file called - TV which will extend Gadget.java
But TV cant send message right, so we should modify the return to something meaningful.
Lets modify to add appropriate message
Lets add the client file - LSP
As per LSP, any subclass of Gadget should behave in the same way as the superclass, and if any behavior changes, then it violates LSP. In this case, if we pass on Phone or Tablet, the behavior is same for playVideo and sendMessage, but when we pass on TV , the behavior changes as TV cant send messsage, thereby violating LSP.
//Correct Example
Lets add one more interface - GadgetMessageService.java
Lets modify Phone and Tablet to implement the new interface
Lets modify TV which will extend Gadget.java
Lets modify the client file
This fixes the LSP violation, as the subclass of Gadget will not call sendMessage, and only subclasses of GadgetMessageService will only call sendMessage
public class LSP{
public int playVideo(Gadget gadget) {
return gadget.playVideo();
}
public int sendMessage(GadgetMessageService gm) {
return gm.sendMessage();
}
}
public class TV implements Gadget{
@Override
public String playVideo() {
return "Video played from TV";
}
}
public class Phone implements Gadget, GadgetMessageService{
@Override
public String playVideo() {
return "Video played from phone";
}
@Override
public String sendMessage() {
return "Message sent from phone";
}
}
public class Tablet implements Gadget, GadgetMessageService{
@Override
public String playVideo() {
return "Video played from tablet";
}
@Override
public String sendMessage() {
return "Message sent from phone";
}
}
}
public interface GadgetMessageService{
String sendMessage();
}
public interface Gadget{
String playVideo();
}
public class LSP{
public int playVideo(Gadget gadget) {
return gadget.playVideo();
}
public int sendMessage(Gadget gadget) {
return gadget.sendMessage();
}
}
public class TV implements Gadget{
@Override
public String playVideo() {
return "Video played from TV";
}
@Override
public String sendMessage() {
return "Cant send message from TV";
}
}
public class TV implements Gadget{
@Override
public String playVideo() {
return "Video played from TV";
}
@Override
public String sendMessage() {
return "Message sent from TV";
}
}
public class Phone implements Gadget{
@Override
public String playVideo() {
return "Video played from phone";
}
@Override
public String sendMessage() {
return "Message sent from phone";
}
}
public class Tablet implements Gadget{
@Override
public String playVideo() {
return "Video played from tablet";
}
@Override
public String sendMessage() {
return "Message sent from phone";
}
}
}
public interface Gadget {
String playVideo();
String sendMessage();
}
4. Interface Segregation Principle (ISP)
//Bad Example
Lets use the above example to highlight the ISP violation. Lets create Gadget.java
Lets add Phone and Tablet which implements Gadget
Lets try to add one more file called - TV which will extend Gadget.java
If you see the above cases, there are gadgets which can either sendMessage or playVideo or do both, but we need to implement both the methods for every class that we create as subclass. This violates ISP, lets fix this by segregating the interfaces
//Correct Example
Lets add one more interface - GadgetMessageService.java
Lets modify Phone and Tablet to implement the new interface
Lets modify TV which will extend Gadget.java
This fixes the ISP violation where we created separate interfaces for separate functionalities and any component can implement interfaces suitable for them.
public class TV implements Gadget{
@Override
public String playVideo() {
return "Video played from TV";
}
}
public class Phone implements Gadget, GadgetMessageService{
@Override
public String playVideo() {
return "Video played from phone";
}
@Override
public String sendMessage() {
return "Message sent from phone";
}
}
public class Tablet implements Gadget, GadgetMessageService{
@Override
public String playVideo() {
return "Video played from tablet";
}
@Override
public String sendMessage() {
return "Message sent from phone";
}
}
}
public interface GadgetMessageService{
String sendMessage();
}
public interface Gadget{
String playVideo();
}
public class TV implements Gadget{
@Override
public String playVideo() {
return "Video played from TV";
}
@Override
public String sendMessage() {
return "Cant send message from TV";
}
}
public class Phone implements Gadget{
@Override
public String playVideo() {
return "Video played from phone";
}
@Override
public String sendMessage() {
return "Message sent from phone";
}
}
public class Tablet implements Gadget{
@Override
public String playVideo() {
return "Video played from tablet";
}
@Override
public String sendMessage() {
return "Message sent from phone";
}
}
}
public interface Gadget {
String playVideo();
String sendMessage();
}
5. Dependency Inversion Principle (DIP)
Lets use the example we used in OCP and LSP
//Bad example
Let's create Phone.java and Tablet.java
Lets create a client file which will use the Phone and Tablet class to playVideo or sendMessage.
Here the client class is depenedent on lower level classes like Phone and Tablet. This violates the DIP. Also it violates OCP also, as we need to modify this class for every new class we create. Let's fix this
//Correct example
Lets create a new interface Gadget.java
Lets modify Phone and Tablet to implement the new interface
Lets modify the client file to use the new interface
This solves the DIP violation, as the client is not dependent on lower level modules rather depends on abstraction
class DIP{
Gadget gadget;
constructGadget(Gadget g){
this.gadget= g
}
}
public class Phone implements Gadget{
@Override
public String playVideo() {
return "Video played from phone";
}
}
public class Tablet implements Gadget{
@Override
public String playVideo() {
return "Video played from tablet";
}
}
public interface Gadget{
String playVideo();
}
class DIP{
Phone phone;
constructPhone(Phone p){
this.phone = p
}
Tablet tablet;
constructTablet(Tablet t){
this.tablet = t
}
}
class Phone {
String playVideo() {
return "Video played from phone";
}
}
class Tablet {
String playVideo() {
return "Video played from tablet";
}
}