solid

The Ultimate Guide to SOLID Principles: Enhance Your Coding

The SOLID principles are five fundamental principles that make software development healthier, more sustainable, and more maintainable. They are a fundamental guide that every developer should follow. These principles can be listed as follows:

  1. Single Responsibility Principle
  2. Open/Closed Principle
  3. Liskov Substitution Principle
  4. Interface Segregation Principle
  5. Dependency Inversion Principle

Let’s examine these principles below with examples.

1. Single Responsibility Principle

Each class and method should have a single responsibility and serve a single purpose.

Let’s say we are handling user registration through an interface, and we direct the request from the interface to the appropriate class and method that create our user.

public class User {
private String username;
private LocalDate birthday;
private int age;
//getters and setters
}
public class UserService {

public User createUser(CreateUserRequest request) {
User newUser = new User();
newUser.setUsername(request.getUsername());
newUser.setBirthday(request.getBirthday();

int currentAge = Period.between(birthday, LocalDate.now()).getYears();
newUser.setAge(currentAge);

return newUser;
}
}

Our class and functions currently violate the Single Responsibility Principle. The createUser function should logically only create a user object and return it or send it to the database layer. However, it is currently also performing age calculation. Instead, we could move this task to a separate function:

public class UserService {

public User createUser(CreateUserRequest request) {
User newUser = new User();
newUser.setUsername(request.getUsername());
newUser.setBirthday(request.getBirthday();
newUser.setAge(calculateCurrentAge(birthday);

return newUser;
}

private int calculateCurrentAge(LocalDate birthday) {
return Period.between(birthday, LocalDate.now()).getYears();
}
}

This way, our code becomes more readable and maintainable. Now, the createUser method only creates the object and leaves it to the database layer, while the calculateCurrentAge function only calculates the difference between the given date and the current date and returns an integer value.

Although we used a very simple example here, in real-life scenarios, separating functions or classes like this contributes both to immediate development and to the maintenance and usability of the code in the future. These separated functions can also be used by new functions, which can speed up development.

2. Open/Closed Principle

Classes should be open for extension but closed for modification.

In other words, new features should be added without changing the existing code. For example, let’s say we have a class with a notification-sending function:

public class NotificationService {
private EmailNotification emailNotification;

public NotificationService() {
this.emailNotification = new EmailNotification();
}

public void send(String recipient, String message) {
emailNotification.send(recipient, message);
}
}

When we use the existing class, the send function will handle email sending, and everything works as expected. But what happens if we later want to add SMS sending functionality to the application?

public class NotificationService {
private EmailNotification emailNotification;
private SmsNotification smsNotification;

public NotificationService() {
this.emailNotification = new EmailNotification();
this.smsNotification = new SmsNotification();
}

public void send(NotificationRequest request) {
if ("email".equals(request.getType()) {
emailNotification.send(request.getRecipient(), request.getMessage());
} else if ("sms".equals(request.getType()) {
smsNotification.send(request.getPhoneNumber(), request.getMessage());
}
}
}

Changes were made to the existing code and new parts were added, increasing the risk and reducing quality.

The most effective solution to address this issue is abstraction. Let’s create an interface named Notification, and then have EmailNotification and SmsNotification classes implement and override the functions in this interface.

public interface Notification {
void send(NotificationRequest request);
}
public class EmailNotification implements Notification {

@Override
public void send(NotificationRequest request) {
//....
}
}

public class SmsNotification implements Notification {

@Override
public void send(NotificationRequest request) {
//....
}
}

This way, as shown below, both services can be used through the Notification interface. Since they implement the same interface, they will work correctly when provided to the NotificationService constructor.

public class NotificationService {
private Notification notification;

public NotificationService(Notification notification) {
this.notification= new Notification(notification);
}

public void send(NotificationRequest request) {
notification.send(request);
}
}

The separated classes are now both independent and continue to adhere to a common interface. When a WhatsApp message function is added in the future, the existing email and SMS sending functions will not change. Instead, a new class will simply be added to the Notification interface. This approach ensures modularity and increases confidence in the code.

3. Liskov Substitution Principle

A subclass should be able to perform everything that the superclass can do.

Instead of providing a concrete example from real life, let’s proceed with a more abstract example that has a memorable place in my mind.

public class Adult {
//some properties

public void eat() {
//code
}

public void walk() {
//code
}

public void drive() {
//code
}
//...
}
public class Child extends Adult {
//properties from superclass
//functions from superclass
}

In the example above, our Child class extends our Adult class. Both classes are the same in many ways. They can eat, drink, and perform basic actions. They appear the same in most functions. However, the drive function in the Adult class breaks the whole logic. Just as a child cannot drive a car in real life, the same logic should apply here. Future additions to the Adult class may also break this. The Child class will have functions and properties it will not use. So how do we solve this?

Let’s identify the functions that these two classes use in common. Although they were initially linked to establish a parent-child relationship, there are logical errors at some points. So, let’s separate these two classes and link them to a more general and logical class:

public abstract class Human {
public abstract void eat();
public abstract void walk();
public abstract void drink();
//some more common functions
}
public class Adult extends Human {
//properties or functions from superclass

public void drive() {
//code
}
}
public class Child extends Human {
//properties or functions from superclass
}

This way, the similar features of the two classes are combined in a superclass, and the subclasses can use all the functions and properties of the superclass. Additionally, they can easily add their own features and functions. This eliminates unnecessary function usage in the code and prevents potential issues and complications in the future.

4. Interface Segregation Principle

If a class implementing an interface does not use all the functions of that interface, the unused functions should be placed in a different interface.

We will use the Human class and the other classes we examined in the previous section for this example as well. However, this time, let’s convert the Human abstract class into an interface and re-add the drive function specific to the Adult class here.

public interface Human { //Child and Adult classes implement this interface
public void eat();
public void walk();
public void drink();
//added
public void drive();
}

The Child and Adult classes will implement this Human interface this time. As you might expect, the Child class will receive the drive function even though it won’t use it. In this case, we should put the drive function in a separate interface.

public interface AdultHuman extends Human {
public void drive();
}
public class Adult implements AdultHuman {
//Human and AdultHuman interfaces' functions
}
public class Child implements Human {
//Human interface functions
}

This way, classes implementing an interface will use all the functions of that interface. In future development, when functions are added that will be used by Adult but not Child, these will be added to AdultHuman. This eliminates unnecessary function usage, and each class will perform its intended tasks.

5. Dependency Inversion Principle

High-level modules should not depend on low-level modules. Both should depend on abstractions.

Let’s examine this principle using the NotificationService we discussed in previous sections.

public class EmailNotification {
public void sendNotification(String message) {
System.out.println("Email sent: " + message);
}
}

public class NotificationService {
private EmailNotification emailNotification ;

public NotificationService () {
this.emailNotification = new EmailNotification ();
}

public void sendNotification(String message) {
emailNotification.sendEmail(message);
}
}

According to this principle, NotificationService is directly dependent on the EmailNotification service because it relies on the objects and functions created internally.

Furthermore, when we add an SmsNotification service in the future, we will need to make changes to NotificationService. Instead, we should connect these two classes to a common interface and pass the object to be used from the outside.

public interface NotificationSender {
void sendNotification(String message);
}
//implementing an interface
public class EmailNotification implements NotificationSender {
@Override
public void sendNotification(String message) {
System.out.println("Email sent: " + message);
}
}

//implementing an interface
public class SMSNotification implements NotificationSender {
@Override
public void sendNotification(String message) {
System.out.println("SMS sent: " + message);
}
}
public class NotificationService {
private NotificationSender notificationSender;

//sending the object by parameters
public NotificationService(NotificationSender notificationSender) {
this.notificationSender = messageSender;
}

public void sendNotification(String message) {
notificationSender.sendNotification(message);
}
}

After these changes, the NotificationService class is no longer dependent on the low-level EmailNotification and SmsNotification classes. Instead of directly communicating with each other, these classes now interact through an interface, and NotificationService receives the object it will use from the outside. This way, when additional notification channels are added in the future, no changes will be needed in the NotificationService class. Flexibility and sustainability are increased.

Conclusion

Following these principles provides significant long-term benefits in software projects. It makes the code more understandable, extendable, and maintainable, which eases the developers’ work and increases the chances of project success. Implementing SOLID principles is an important step toward quality software development. Studying these examples and practicing with your own ideas, and trying to apply them in your projects, will help solidify these principles.

Contents