SOLID Design Principles and how to use them in Java?

We have often come across the acronym S.O.L.I.D in the field of object oriented programming. In fact, these 5 principles form the foundation of any object oriented code design. But before we jump onto a clear understanding of these principles and learn how to use them, let us get revise some basic concepts of object-oriented design/programming.
What is object-oriented design?
Object-oriented design is a programming model which is based on basic building blocks called Objects. Each object has two features: attributes(data) & behavior (methods/functions). The attributes hold actual data of a type (e.g. a String, Integer, Floating point value or reference of another object). A method, on the other hand is responsible for changing the state of these attribute(s). Therefore, a method or a function is actually the behavior of an object, as it defines how the data behaves when a certain method is called.
Before the objects are used, a blueprint of a certain type of objects is to be defined. The definition of an object consists of type of attributes & function definition (behavior definition). These are defined into an entity called a Class.
Let us get a refresher on how a class and its objects look in Java:
class Employee {
String employeeId;
String name;
int age;
double salary; public promoteEmployee(double percentIncrement) {
salary += (salary * percentIncrement)/100;
}}
The above class Employee has defined few attributes like employeeId, name, age and salary. Each employee (or each object) will have its own set of values for this class. Also, a method promoteEmployee is defined, which changes the state of the object (and increases its salary by a certain percentage). This method defines the behavior of the attribute salary.
Now that the basic concepts of object oriented programming are refreshed in our minds, let’s revisit some basic terminology which will be used to define the S.O.L.I.D fundamentals.
Loose Coupling
Two classes are said to be loosely coupled, when their association is defined on abstraction instead of actual objects.
Let’s see a Java 8+ based code example to make everything clearer:
public interface AuthenticationService{
public User login(String username, String password);
default void logout(User user) {
// code to clear the session
// and log the user out
}
}
The above example shows an interface AuthenticationService with an abstract method to login a user using username and password. It also shows a default implementation of the logout functionality for a user (as we already know that Java 8 now allows default implementation of methods in interfaces 😎 & we don’t need to implement them. Although, we can if we wish)
Now, let’s say an application has two different implementations for handling the user authentication. One for general users, and other for admin users.
Let’s say, we define two classes which implement this interface: UserAuthenticationService & AdminAuthentication service. The code for these classes would look like.
class UserAuthenticationService implements AuthenticationService{
public User login(String username, String password) {
//general user specific code to login
}
}class AdminAuthenticationService implements AuthenticationService {
public User login(String username, String password) {
//admin user specific code to login
//this code may connect to a different table
//which holds only admin users
}
}
Now we have two service classes which implement one interface and have their own implementations for the login. Now, there should also be a class (A controller class), which would call login method by passing a username and password taken from the user.
public class LoginController {
@Inject
public AuthenticationService authService; public ResponseEntity loginUser (UserData userData) {
authService.login(userData.getUsername(),userData.getPwd());
// some other code to return the response
}
}
The above controller snippet has injected the AuthenticationService interface and not the actual implementing class. The framework figures out at which implementation to use at runtime. (It requires some very basic additional configuration, which is beyond the scope of this article).
This association is defined by abstraction instead of the actual implementation. This is called loose coupling.
If the actual implementation was wired, it would be called tight coupling, which is NOT a good design pattern.
High Cohesion
A class is said to be highly cohesive when it performs logically similar operations. Conversely, a class is said to have low cohesion if it does a lot of operations which are not logically similar.
For example, in an application, if a class is defined in such a way that it contains operations for login, logout, sending email, subscribe to newsletter all wrapped in a single class — it has low cohesion. It is bad design because the operations in the class are not logically similar.
A good design would be to split the class into multiple classes, such as one class AuthenticationService which takes care of login, logout, manage user data, another class called EmailService which is responsible for sending all emails like welcome email for new account, order summary email for a new order and such. In this case, each class performs its own set of operations. If a class needs to do some other operation outside of its domain, it uses the object of another class which has that functionality. This is called high cohesion. This design is widely accepted, as it improves readability of code, makes it easy to maintain & individual components can be re-used and injected into other services.
Now that the concepts are rushing back into the mind, let’s understand the 5 S.O.L.I.D. principles in object oriented design.
The 5 pillars of object oriented design
S — Single Responsibility Principle: Each class should have a single responsibility.
O — Open/Closed Principle: A class should be open for extension, but closed for modification.
L — Liskov’s Substitution Principle: Objects of a subclasses should be able to replace objects of a superclass.
I — Interface Segregation Principle: Multiple client facing interfaces should be created instead of one general purpose interface.
D — Dependency Inversion Principle: A class using an object of another class by association, should be coupled at runtime (loose coupling)
Let us deep-dive into these principles one-by-one:
Single Responsibility Principle
There is a famous quote by Steve Jobs:
Do not try to do everything, Do one thing well .
Single responsibility principle emphasizes on designing classes in such a way, that each class has its very own responsibility. If a class has multiple responsibilities, it has to be broken down into separate classes, each conforming to the Single Responsibility principle.
Let’s see the following code snippet, which creates an account and send a welcome email to the user when the account is successfully created.
class AccountService {
public User login(String username, String password) {
//some code to login
}
public void logout(User user) {
//some code to log the user out
}
public void createAccount(String... someParameters) {
//code to take information and persist in the database
sendEmail(name, email);
sendSMS(name, phoneNumber);
}
public void sendEmail(String name, String email) {
String message = "Hi " + name + ", thanks for creating an"
+ "account on our website";
//code to connect to the SMTP server and send the email
//with the above message
}
}
The above code is fully capable of creating an account and sending an email, but there are a few problems. There could be numerous places in the application, where an email is required to be sent to a user. For example, a forgot password email, an order summary email, notification email etc.
For each of these scenarios, it is not optimal to write a sendEmail method with the same code to connect to SMTP server and send a mail. The only variables that will change in an email, would be the message, subject and the email ID. The process of connection to SMTP server — its address, port and other details would remain the same. The above design would lead to a lot of code duplication. Copy-pasting the same code at every place also has its disadvantages. For example, if you switch to a new SMTP provider and have to change the way data goes to the SMTP server, you would have to change it in ALL the places, where the duplicate code is present. All this happens because the design of the application does not have classes, which have Single Responsibility.
How can we do it better? Let’s see
First, we create a class EmailService, for which the sole responsibility it to send an email to the recipients, with three variables: email (the recipient to which email is sent), subject & body (HTML content of the email, which may contain HTML text and images). This EmailService class can be used by any other services to send emails (like forgot password or notifications).
class EmailService {
public void sendEmail(String email, String subject, String body) {
//code to connect to SMTP Server and send email
}
// the class could have other helper methods too, but only the
// responsibilities related to sending email.
}
Next, we refactor the class AccountService to get rid of the email functionality, and hold the functionality only related to accounts. Since this class needs to send emails on the event of account creation, we INJECT the EmailService and use the sendEmail method as follows.
class AccountService { @Inject
private EmailService emailService; public User login(String username, String password) {
//some code to login
}
public void logout(User user) {
//some code to log the user out
}
public void createAccount(String... someParameters) {
String body= //some code to create email message
String subject = "Welcome "+name;
emailService.sendEmail(email, subject, body);
}
}
This way, we get two classes, each with their own responsibilities, easy to maintain, highly cohesive, loosely coupled and re-usable!
Open-Closed Principle
Open-Closed principle is quite simple. A class should be open for extension, but closed for modification. Let us see an example, to see what it means and WHY this principle is cool!
Let’s see the following code snippet, which shows an example of a code while generates a report in CSV & XML Format respectively.
class ReportGenerator{
public File generateReport(String reportType) {
if(reportType.equalsIgnoreCase("CSV")) {
//Code for generating CSV Report
} else if(reportType.equalsIgnoreCase("XML")) {
//Code for generating XML report
} else {
//Invalid Input
}
}
In the above class, if we would like to add a new report generation mechanism (say, an PDF file), we will have to modify the original file and add a new condition for PDF files in the same class.
Usually it is not a problem and it should work, but what if this class is to be exposed as a service package? In such a case, the implementing client will not have any code for this class and hence, will not be able to modify it. Therefore, the class become inextensible.
While considering open-closed principle, we redesign the class in the following way.
public interface ReportGenerator {
public File generateReport();
}class CSVReportGenerator implements ReportGenerator {
@Override
public File generateReport() {
// code for generating CSV Report
}
}class XMLReportGenerator implements ReportGenerator {
@Override
public File generateReport() {
// code for generating XML Report
}
}
In the above scenario, the interface ReportGenerator exposes a function generateReport() which is implemented by Specialized classes created for generating a type of report such as CSVReportGenerator & XMLReportGenerator. If we need to add a PDF type of report generator, we do not need to change any of the existing classes above.
We can simply implement the public interface and write our own code for PDF report generation. Example:
public class PDFReportGenerator implements ReportGenerator {
@Override
public File generateReport() {
//code for generating PDF report
}
}
Liskov’s Substitution principle
Liskov’s substitution principle states that an object of a Superclass must be substitutable by objects of its Subclass.
For example, there is a class called Super which has a subtype Sub, then objects of Super should be able to be replaced by objects of Sub. For example,
Super referenceVariable = new Sub();
In java, because of type safety, you cannot assign a random object to the reference variable of a class. It throws a compile-time error!
For example, Test is nowhere in the hierarchy of Super. Therefore, since the below statement violates Liskov’s substitution principle, it is a compile time error.
Super referenceVariable = new Test();
Interestingly, Liskov’s substitution principle does not end here. There are programming concerns which can violate this principle at a design level.
This principle is not just about type safety, but it also implies that the behavior of the subclass should also be similar as the behavior of the superclass.
Let’s see the following class structure:

Having a look at above structure, there is an Interface Card which defines certain operations. The classes CreditCard and DebitCard implement this interface, with their own implementation of validate(), authorize() and debit() methods. The odd one out is RewardsCard implementation. It does not require the authorize() method which is specific only to credit and debit cards. What to do in such a case? — We either leave the implementation blank OR throw an exception in the method.
Next, it adds a new public method addPoints() which adds points to the RewardsCard whenever a user shops. This method was not initially a part of the interface. Now, one method in the contract is unimplemented (and useless) and on the other hand, a new public method is exposed for use. While these class structures would compile, we would run into issues when we try to do:
Card card = new RewardsCard(.. some params ..); //compiles
card.validate(); //compiles
card.authorize();//compiles but changes expected behavior. Violation
card.addPoints(10); //compilation FAILS!
See line 4 & 5 in the above code. Although the hierarchy is correct and compiles, we run into issues when we call the non-used implementation at line (4) and use the superclass variable to call a method which was not in the interface contract at line (5). Both of these statements violate the Liskov’s principle.
In such a case, we redesign the classes in another way:

On redesigning the classes, we break the interface Card into two interfaces AuthorizableCard which requires authorization & re-uses Card interface’s basic validation and debit methods. The new contract for the CreditCard and DebitCard is now with AuthorizableCard. In this case, all the methods are properly implemented and no new public methods are added.
AuthorizableCard card = new CreditCard(... params ...);
card.validate(); //compiles & implemented
card.authorize(amount); //compiles and implemented
card.debit(amount); //compiles and properly implemented
Similarly, PointsCard interface also extends the basic functionality of Card. The RewardsCard class now has a contract with PointsCard, which holds the addPoints method. It fulfills the contract and retains the behavior of the PointsCard. It is also good for extensibility, as If we need to customize and add new types of PointsCard classes (like BronzePointsCard, SilverPointsCard and GoldPointsCard), we just implement the PointsCard interface, and update the implementation of the addPoints() in these classes. The behavior overall, remains the same: on calling addPoints(), some points are added to the card.
Interface Segregation Principle
Interface segregation principle states that a client should not be forced to depend on methods which are optional for implementation. Instead, multiple interfaces should be created for use.
This principle is pretty much straightforward. For example, you can have a look at the above Liskov’s substituition principle and how the Card Interface was split into RewardsCard and AuthorizableCard.
Dependency Inversion Principle
A class using an object of another class by association, should be coupled at runtime.
We are already aware of the concept of loose coupling (revisit the example of loose coupling mentioned above before proceeding), let’s see an example of how loose coupling is done in the context of Dependency Inversion:
@Controller
@RequestMapping("/admin")
public class AdminLoginController {
@Inject
private AuthenticationService authService;@PostMapping("/login")
public void loginAdminUser(UserDetails userDetails) {
authService.login(userDetails.getUsername(), userDetails.getPwd());
//return response
}
In the above case, we wrote a controller, which takes the username and password from a user request whenever a POST request is made to /admin/login. The AuthenticationService bean is resolved to AdminAuthenticationService.
Similarly, when a POST request is made to /user/login with username and password, the same bean is resolved to UserAuthenticationService.
@Controller
@RequestMapping("/user")
public class UserAccountController {
@Inject
private AuthenticationService authService;@PostMapping("/login")
public void loginAdminUser(UserDetails userDetails) {
authService.login(userDetails.getUsername(), userDetails.getPwd());
//return response
}
The question is, how do we configure which bean implementation is to be loaded at runtime and when? One of the many ways to do this is by using @ConditionalOnProperty annotation.
Let’s say you have a spring application, which runs in two production modes: admin & user. If the admin profile is running, you want to initialize the AdminAuthenticationService and if user profile is running you want to initialize the UserAuthenticationService. The spring property which corresponds with the running user profile is spring.profile.active.
Therefore, in the class AdminAuthenticationService, we define
@Service
@ConditionalOnBean(prefix="spring.profiles" name="active" havingValue="admin")
class AdminAuthenticationService {
..... code ....
}
and in UserAuthenticationService, we define:
@Service
@ConditionalOnBean(prefix="spring.profiles" name="active" havingValue="user")
class UserAuthenticationService {
..... code ....
}
There are numerous ways to conditionally initialize beans with minimal code and configuration. And this is how you invert the dependencies and provide them at runtime.