OOP Good Practices: SOLID
By: Cecilia Pereyra
Tags: Oop, Design Patterns, Solid, Software Development

When developers first start programming, we all tend to write unmaintainable code:

we have methods with too many lines

we tend to write repeated logic in different parts of the application

we create all the functionality in one method and it doesn’t allow us to reuse the code

we don’t comment the code to be more understandable.

So, the question is:

How can we write readable and maintainable programs?

There are some principles to make software design more flexible, easy to read and maintain, with high cohesion and loose coupling.

Here we have the SOLID principles of object-oriented programming. It is an acronym that refers to a subset of principles to intend to achieve the aforementioned goals:

S ingle Responsibility Principle

ingle Responsibility Principle O pen/Closed Principle

pen/Closed Principle L iskov Substitution Principle

iskov Substitution Principle I nterface Segregation Principle

nterface Segregation Principle Dependency Inversion Principle

The theory of SOLID principles was introduced by Robert C. Martin in a paper called Design Principles and Design Patterns in 2000.

Single Responsibility Principle

This principle states that every class or module (software components or microservices) should have only one responsibility and that the class should entirely encapsulate it. There should never be more than one reason to make a change in the class.

1- Some tips to realize that the class has many responsibilities are:

if it is complicated to find a name for it

if you need to use ‘and’ more than 3 times to say what the class does

Keeping a class focused on a single concern makes it more robust.

2- This principle can also be applied on constructors, where its responsibility shoud be asigning dependencies. It helps to have simpler tests.

3- Organize your classes and methods according to the responsibilities they have in common. This is called Separation of Concerns (SoC) or Advanced Separation of Concerns (ASoC).

A very useful example of implementing separation of concerns is the design pattern MVC (Model-View-Controller).

Another way to apply this principle is creating a service. For example, you have to validate data at different points of an application. By using a validation service at those places, you would be encapsulating the validation logic in a common place and not adding extra responsibilities to those classes. Also, having this logic in one place makes it easier to maintain.

4- The workflow proposed by Test Driven Development (TDD) is based on this principle: which is the simplest code you can write to make something work?

Note: This principle promotes using Ubiquitous Language for variable, methods and classes names. It was introduced by Eric Evans in his book “DDD (Domain Driven Design)”. This type of names makes the source code be self-documented. This tip is not related to responsabilities, although it is a good practice.

Here we have an example where this principle is violated:

We notice that the class RunningRace manages its own data but also it tries to send confirmation emails to runners. The logic of sendConfirmationEmails() is very different to the main responsibility of the class. Then, the class has two different behaviours.

Let’s correct it as follows:

It has been resolved moving the method sendConfirmationEmails() to a class that manages the running races. Now, the class RunningRace has higher cohesion than before and only one responsibility.

Open/Closed Principle

This principle states that entities (classes, modules, functions, etc.) should be open for extension but closed for modification. It originates from book “Object Oriented Software Construction”, Bertrand Meyer, 1988. He proposed using an implementation inheritance.

The main goal of this principle is to avoid introducing errors in the current code when implementing a new functionality. A class should be changed only for correcting it, and not each time we add functionality because, by doing so, we could introduce errors on dependant modules.

This principle helps developers to reduce the risk of adding new bugs to existing code, loosing coupling and making code more maintainable.

Let’s see an example using the class RunningRace shown before:

In this code, adding a race depends on the distance. It violates the open/close principle in the way this implementation differs the behaviour on the running race distance. If we need to add a new type, this class should be modified with an extra ‘else if’ in the createRace() method.

Let’s make this code conform to the open/close principle by simply using inheritance.

Now we created an extended behaviour to the HalfMarathon object by overriding the createRace() method. In case we need to add another type of running race, we create a subclass with its corresponding behaviour. So, we extends without modification.

Liskov Substitution Principle

This principle was introduced by Barbara Liskov in her work “Data Abstraction and Hierarchy” and later Robert C. Martin in his book “Agile Principles, Patterns, and Practices in C#” populated the idea. It says that inherited classes must be substitutable for their parent class. It is a particular definition of a subtyping relation called behavioral subtyping.

When a class inherits from another (or implements an interface), their properties and methods have to behave internally in the same way, but also they cannot be restrained. The subtype must meet a number of behavioural conditions.

Here is an example:

This example violates the Liskov Substitution principle and also the Open/Close principle. For the getDistance() method, it has to know each type of running race and call the associated function. If there is a new race, this function has to be modified to accept the new race created.

Let’s correct this problem:

By refactoring the subclasses overriding the getDistance() method we no longer violate the Liskov principle. The getDistance() function cares less the type of RunningRace passed, it just calls the getDistance() method. All it knows is that the parameter must be of an RunningRace type, either the RunningRace class or its sub-class.

Interface Segregation Principle

This principle states that clients should not be forced to depend on methods it does not use. It was introduced by Robert C. Martin.

Since all the classes that implement an interface have to implement all the methods declared in it, do not add new functionality to an existant interface by adding new methods. Instead, create a new interface and let the class implement multiple interfaces if necessary.

This principle is related with the Single Responsibility Principle and promotes high cohesion among interfaces.

The following example shows an interface with many methods that deal with different distances.

If any class implements this interface, it has to use all methods:

To make this interface follow the principle, we need to segregate the results methods to different interfaces as follows:

This makes sure that any class (client) is only implementing the methods that it should use.

Dependency Inversion Principle

This is the last of the 5 principles. It says dependency should be on abstractions not on concretions, what it means:

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

Abstractions should not depend on details. Details should depend on abstractions.

This is a way to decouple modules: instead of using coupled classes, we should implement interfaces.

The dependency inversion pattern is needed to comply with this principle. It is solved by using dependecy injection: any dependency of a class is ‘injected’ through the class’ constructor as an input parameter.

This principle helps us to have loose coupling, testable and maintainable code. It has to be applied when classes need to be decoupled because they can be changed in the future or when code has high coupling.

Let’s see an example:

Here the RunningRace class is a high-level component whereas service is a low-level component. We create the RaceService instance from within the RunningRace class. If we wanted to use a different kind of service, we would have to modify the RunningRace class.

The following code adheres to the principle:

With this change by using dependency injection, we see that both the high-level and the low-level components depend on abstraction.

Conclusion

These are the 5 principles that every software developer has to adhere and apply to have maintainable, reusable, readable, scalable and easy testable code. Having these principles in mind every day will make a significant impact on the quality of our code.