SOLID Principles in 10 minutes with examples
Simple and short explanation with examples
The SOLID principles were introduced by Robert C. Martin, also known as Uncle Bob and it is a coding standard in object-oriented programming.
This principle is an acronym of the five principles which is given below:
- Single Responsibility Principle (SRP)
- Open/Closed Principle
- Liskov’s Substitution Principle (LSP)
- Interface Segregation Principle (ISP)
- Dependency Inversion Principle (DIP)
This five design principles are intended to make software designs more understandable, flexible and maintainable.
Single Responsibility Principle (SRP)
The Single Responsibility principle states that every module, class, or function should have responsibility over a single part of the functionality provided by the software, and that responsibility should be entirely encapsulated by the class, module or function.
A class should have only one reason to change
This means that every class should have a single responsibility or single job or single purpose.
Example
In this simple example, you can see a rectangle class that has two methods:
- area() has the responsibility to return the area of a rectangle
- draw() has the responsibility to draw the rectangle itself
In this design, it can be seen that, if there are changes in the GUI, we must modify the Rectangle class, then we are obliged to test again the other application that attacks the same class.
The solution to this problem is to divide the class in two, so that each class has a unique responsibility. One will be responsible for calculating and another one for painting:
Open/Closed Principle
The Open/Closed principle states that you should be able to extend a class behavior, without modifying it, that is we can extend the behavior of software entity without touching the source code of the entity.
Software entities … should be open for extension, but closed for modification
Open-Closed principle can be achieved using abstraction and inheritance.
Example
Now if you want to draw squares also, since we followed open-closed principle, we can add this functionality without touching existing modules.
Define a class Square that implements Shape class and that’s it, you are done.
Liskov’s Substitution Principle
The Liskov’s Substitution principle ensures that any class that is the child of a parent class should be usable in place of its parent without any unexpected behaviour.
Derived or child classes must be substitutable for their base or parent classes
Liskov’s notion of a behavioural subtype defines a notion of substitutability for objects; that is, if S is a subtype of T, then objects of type T in a program may be replaced with objects of type S without altering any of the desirable properties of that program.
You can achieve this by following a few rules:
- An overridden method of a subclass needs to accept the same input parameter values as the method of the superclass. That means you can implement less restrictive validation rules, but you are not allowed to enforce stricter ones in your subclass. Otherwise, any code that calls this method on an object of the superclass might cause an exception, if it gets called with an object of the subclass.
- The return value of a method of the subclass needs to comply with the same rules as the return value of the method of the superclass. You can only decide to apply even stricter rules by returning a specific subclass of the defined return value, or by returning a subset of the valid return values of the superclass.
Example
Interface Segregation Principle
The Interface Segregation principle states that no client should be forced to depend on methods it does not use. Interfaces that are very large should be splitted into smaller and more specific ones so that clients will only have to know about the methods that are of interest to them.
Objects in a program should be replaceable with instances of their subtypes without altering the correctness of that program
You should prefer many client interfaces rather than one general interface and each interface should have a specific responsibility.
Example
Implement an application that automatically brews a cup of coffee.
You can use several different coffee machines, for example:
- BasicCoffeeMachine: these machines transform one or two scoops of ground coffee and a cup of water into a nice cup of filter coffee
- EspressoMachine: these machines include a grinder to grind your coffee beans and you can use to brew different kinds of coffee
The only difference is the brewEspresso method, which the EspressoMachine class implements instead of the brewFilterCoffee method.
You might model these classes deciding that an EspressoMachine is just a different kind of coffee machine. So, it has to implement the CoffeeMachine interface.
However you will need to consider the following things:
- The EspressoMachine class implements the CoffeeMachine interface and its brewFilterCoffee method.
- You add the brewEspresso method to the CoffeeMachine interface so that the interface allows you to brew an espresso.
- You need to implement the brewEspresso method on the BasicCoffeeMachine class because it’s defined by the CoffeeMachine interface. You can also provide the same implementation as a default method on the CoffeeMachine interface.
The class diagram will look like this:
You should notice that the CoffeeMachine interface is not a good fit for these two coffee machines. The brewEspresso method of the BasicCoffeeMachine class and the brewFilterCoffee method of the EspressoMachine class throw a CoffeeException because these operations are not supported by these kinds of machines.
You only had to implement them because they are required by the CoffeeMachine interface.
But the implementation of these two methods isn’t the real issue. The problem is that the CoffeeMachine interface will change if the signature of the brewFilterCoffee method of the BasicCoffeeMachine method changes. That will also require a change in the EspressoMachine class and all other classes that use the EspressoMachine, even so, the brewFilterCoffee method doesn’t provide any functionality and they don’t call it.
Following the Interface Segregation principle you would need to split the CoffeeMachine interface into multiple interfaces for the different kinds of coffee machines. All known implementations of the interface implement the addGroundCoffee method.
By segregating the interfaces, the functionalities of the different coffee machines are independent of each other. As a result, the BasicCoffeeMachine and the EspressoMachine class no longer need to provide empty method implementations and are independent of each other.
Dependency Inversion Principle
The Dependency Inversion principle states that high-level modules, which provide complex logic, should be easily reusable and unaffected by changes in low-level modules, which provide utility features.
- High-level modules should not depend on low-level modules. Both should depend on abstractions (e.g. interfaces).
- Abstractions should not depend on details. Details (concrete implementations) should depend on abstractions.
The main motive of this principle is decoupling the dependencies so if class A changes the class B doesn’t need to care or know about the changes.
Example
In conventional application architecture, lower-level components (e.g. Utility Layer) are designed to be consumed by higher-level components (e.g. Policy Layer) which enable increasingly complex systems to be built.
In this composition, higher-level components depend directly upon lower-level components to achieve some task. This dependency upon lower-level components limits the reuse opportunities of the higher-level components.
With the addition of an abstract layer, both high- and lower-level layers reduce the traditional dependencies from top to bottom. Nevertheless, the “inversion” concept does not mean that lower-level layers depend on higher-level layers. Both layers should depend on abstractions that draw the behaviour needed by higher-level layers.
In a direct application of dependency inversion, the abstracts are owned by the upper/policy layers. This architecture groups the higher/policy components and the abstractions that define lower services together in the same package. The lower-level layers are created by inheritance/implementation of these abstract classes or interfaces.
The inversion of the dependencies and ownership encourages the re-usability of the higher/policy layers. Upper layers could use other implementations of the lower services. When the lower-level layer components are closed or when the application requires the reuse of existing services, it is common that an Adapter mediates between the services and the abstractions.