SOLID Principles In Practice
I wanted to start my very first article with S.O.L.I.D. that every software developer must know inasmuch as they are accepted as the basic principles of software development.
SOLID principles is the set of principles put forward by Robert C. Martin, which ensures that the software is flexible, reusable, sustainable, understandable and has minimum code repetition.
The purpose of these principles :
- The software is purposed to be easily adaptable to the future requirements.
- New features are purposed to be easily added without the need to change the code.
- Minimal code changes are purposed to be needed despite new requirements.
- The loss of time caused by problems that require upgrading or rewriting in the code are purposed to be minimized.
By applying these principles, complexity will be prevented from growing as applications grow constantly. The more codes that complied with SOLID principles, the more cleanly coded software will be developed. So what does SOLID stand for?
S: Single-responsibility principle
A class (object) can only be changed for the sake of one purpose, it is the responsibility imposed on that class, so a class (can also be reduced to a function) needs only one job to do.
O: Open-Closed principle
A class or function must preserve existing properties and not allow modifications. In other words, it should not change its behavior and should be able to gain new features.
L: Liskov substitution principle
We should be able to use subclasses instead of the (parent) classes they derive from, without the need to make any changes in our codes.
I: Interface segregation principle
Instead of collecting all responsibilities into a single interface, we should create more customized interfaces.
D: Dependency Inversion Principle
Dependencies between classes should be as low as possible, especially upper classes should not be dependent on lower level classes.
Let’s deep dive in…
Single Responsibility Principle
The single responsibility principle states that our classes (not only for classes, but also for micro-services and other software components) must have a single well-defined responsibility. A class can only be changed for the sake of one purpose, it is the responsibility assigned to that class and one class has only one job to do.
If the class or function you are developing serves more than one purpose, it means that you are violating the SRP. When you realize this, you have to break it up according to the one main purpose.
First, let’s violate SRP.
class CoffeeShop {
constructor(name: string, city: string, zipCode: int){ }
getName() { }
changeAddress(city:string, zipCode: int) { }
}
SRP states that classes should have one responsibility, here, we can draw out two responsibilities: address information and CoffeeShop properties management. The constructor and getName manage the CoffeeShop properties while the changeAddress manages the Address information. Here, information related to the address should not be directly in the CoffeeShop class, since CoffeeShop class will have to be touched and recompiled to compensate for the new changes in Address. To make this conform to SRP, we create another class that will handle the sole responsibility of Address information. Additionally, Operations such as changeAddress are not under the responsibility of the Address class, no matter how much Address information they need. We should leave these functions under the responsibility of a separate class. Thus, we will remove the changeAddress operations from the Address class and we have arranged the AddressService for single responsibility.
How do we make it conform to SRP?
class CoffeeShop {
constructor(name: string, address: Address){ }
getName() { }
getAddress() { }
}class Address {
constructor(city: string, zipCode: int){ }
}public class AddressService{
public void changeAddress(city: string, zipCode: int) { }
}
With the proper application of SRP, the software becomes highly cohesive. By separating features changing for different reasons into each class effectively, a clear code structure will be established that will be easily integrated when updates are required.
Open-Closed Principle
OCP principle forms the basis of writing sustainable and reusable code. This principles indicates that, a class or a function should preserve existing properties by not changing its behavior but gaining new properties. Meaning that, software entities should be open for extension, not modification.
Open — Allows adding new behaviors for the entities. This is because when the requirements change, new or different behaviors can be added to a class so that new requirements can be met.
Closed — It should not be possible to change the basic properties of an entity.
First, let’s violate OCP with our CoffeeShop class. And suppose we are starting to write an Invoice generator program with Single Responsibility, but suppose we only need some CoffeeShop invoices. The function generateInvoice does not conform to the open-closed principle because it cannot be closed against new kinds of CoffeeShop. For every new company, a new logic is added to the generateInvoice function. When your application grows and becomes complex, you will see that the if
statement would be repeated over and over again in the generateInvoice function each time a new company is added, all over the application.
class CoffeeShop {
constructor(name: string, address: Address){ }
getName() { }
getAddress() { }
}class InvoiceService {
generateInvoice(shop: CoffeeShop): String{
let invoice = "";
if(company instanceOf A)
invoice = "some format of invoice";
if(company instanceOf B)
invoice = "some other format of invoice";
if(company instanceOf C)
invoice = "some another format of invoice";
return invoice;
}
}
How do we make generateInvoice conform to OCP?
class CoffeeShop {
getInvoice();
//...
}
class A extends CoffeeShop {
getInvoice() {
return "some format of invoice";
}
}
class B extends CoffeeShop {
getInvoice() {
return "some other format of invoice";
}
}
class C extends CoffeeShop {
getInvoice() {
return "some another format of invoice";
}
}class InvoiceService {
generateInvoice(coffeeShop: CoffeeShop): String{
return coffeeShop.getInvoice();
}
}
Now CoffeeShop has a virtual method getInvoice and each shop extend the CoffeeShop class implement the virtual getInvoice method, resulting every shop adds its own implementation on how it creates its invoice in the getInvoice function. The generateInvoice just calls shop’s getInvoice method. Now, if we add a new shop, generateInvoice doesn’t need to change, as we should be closed to change. All we need to do is create a new shop, thus we are open to expansions.
Liskov Substitution Principle
The aim of this principle is to ascertain that a sub-class can assume the place of its super-class without errors. If the code finds itself checking the type of class then, it must have violated this principle. We should be able to use subclasses instead of the (upper) classes from which they derive, without the need to make any changes in our code. The derivative class, that is, subclasses, should be able to use all the properties and methods of the main (upper) class in a way that performs the same function, and have new features of their own.
First, let’s violate LCP with our CoffeeShop class for takeaway.
class CoffeeShop {
takeaway();
//...
}
class A extends CoffeeShop {
takeaway() {
return "Delivery at most 30 minutes";
}
}
class B extends CoffeeShop {
takeaway() {
throw new Exception('We do not have takeaway service');
}
}
Some shops do not have takeaway service. Now, remember, objects that consist of subclasses should behave the same when they are replaced by objects of the parent class. Here, B class throws an exception.
How do we make this conform to LSP?
interface IServable {
takeaway();
}class CoffeeShop {
//...
}
class A extends CoffeeShop implements IServable {
takeaway() {
return "Delivery at most 30 minutes";
}
}
class B extends CoffeeShop {
}
Conformity to LSP is to develop class structures by creating a hierarchical order that can fully meet the behaviors expected from classes.
Interface Segregation Principle
It is the principle that tells us to prefer to create more customized interfaces rather than gathering all responsibilities in a single interface. In other words, each different responsibility must have a unique interface. If we have only one interface for multiple purposes, we are adding more methods or features than necessary, which means you are violating ISP. The principles of single responsibility and interface segregation are very similar and serve the same purpose with the only difference that the Interface Segregation deals with interfaces while the Single Responsibility deals with classes.
First, let’s violate ISP with our Coffee Shop classes.
interface ICoffeeShop{
//traditional shops
brewByEspressoMachine();
brewMachinePourOver();
//third wave shops
brewByHandHeldEspressoMaker();
brewManualPourOver();
//both
brewFilterCoffee();
}class Traditional implements ICoffeeShop {
brewByEspressoMachine() {
//...
}
brewMachinePourOver() {
//...
}
brewFilterCoffee() {
//...
}
brewByHandHeldEspressoMaker() {
throw new Exception('We don't brewByHandHeldEspressoMaker');
}
brewManualPourOver() {
throw new Exception('We don't brewManualPourOver');
}
}class ThirdWave implements ICoffeeShop {
brewByHandHeldEspressoMaker() {
//...
}
brewManualPourOver() {
//...
}
brewFilterCoffee() {
//...
}
brewByEspressoMachine() {
throw new Exception('We don't brewByEspressoMachine');
}
brewMachinePourOver() {
throw new Exception('We don't brewMachinePourOver');
}
}
Firstly, such structures reduces readability. Like here we have to implement unnecessary methods and also code maintenance becomes more difficult. class Traditional implements methods it has no use of, likewise ThirdWave implementing brewByEspressoMachine, and brewMachinePourOver.
If we add another method to the ICoffeeShop interface only ThirdWave should have, Traditional class must also implement the new method or error will be thrown. ISP frowns against the design of this ICoffeeShop interface. Classed should never be forced to depend on methods that they do not need or use. Also, ISP states that interfaces should perform only one job (just like the SRP principle) any extra grouping of behavior should be abstracted away to another interface.
To make ICoffeeShop interface conform to the ISP principle, the interface actions should be segregated to different interfaces:
interface ICoffeeShop {
brewFilterCoffee();
}
interface ITraditionalCoffeeShop extends ICoffeeShop{
brewByEspressoMachine();
brewMachinePourOver();
}
interface IThirdWaveCoffeeShop extends ICoffeeShop{
brewByHandHeldEspressoMaker();
brewManualPourOver();
}class Traditional implements ITraditionalCoffeeShop {
brewByEspressoMachine() {
//...
}
brewMachinePourOver() {
//...
}
brewFilterCoffee() {
//...
}
}class ThirdWave implements IThirdWaveCoffeeShop {
brewByHandHeldEspressoMaker() {
//...
}
brewManualPourOver() {
//...
}
brewFilterCoffee() {
//...
}
}
So 2 more interfaces is written with suitable features and they are applied them to their appropriate hosting classes. Thus, each class was stripped of features it has no need by using its own interface. As can be easily noticed, by conforming to the ISP principle, code becomes more readable and flexible.
Dependency Inversion Principle
Based on this idea, Robert C. Martin’s definition of the Dependency Inversion Principle consists of two parts:
- 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.
The dependence of a class, method, or property on other classes that use it should be minimized. When a behavior changes in the high-level classes, the low-level behaviors need to adapt to that change. Besides, when a behavior changes in lower-level classes, there should not be a difference in the behavior of higher-level classes.
In software development, always there comes a point to dispose of all these problems by clear things up using Dependency Injection. And the solution should be that both of lower and higher level classes will be managed through abstract concepts. We should create an abstraction layer between the high level and low level classes.
First, let’s violate DIP with our Coffee Shop classes.
class CoffeeShop {
getPayment() {
}
deliverCoffee() {
}}class Customer {
makePayment() {
}
receiveCoffee() {
}
}class Delivery {
constructor(customer: Customer, coffeeShop: CoffeeShop) { }
deliver() {
customer.makePayment
coffeeShop.getPayment
coffeeShop.deliverCoffee
customer.receiveCoffee
}
}
Although our Delivery class is a high level class, it is dependent on the lower Customer and CoffeeShop classes. Changes in Customer and CoffeeShop classes or methods will directly affect the Delivery class. When a new module is added, we will have to make changes in our notification class. In this case, we acted against the Dependency Inversion principle.
How do we make Delivery conform to DIP?
public interface IOrderCustomer {
void createOrder();
void receiveOrder();
}public interface IOrderCoffeeShop {
void deliverOrder();
}class CoffeeShop implements IOrderCoffeeShop {
deliverOrder() {
getPayment();
deliverCoffee();
}
getPayment() {
}
deliverCoffee() {
}}
class Customer implements IOrderCustomer{
createOrder() {
makePayment();
}
receiveOrder() {
receiveCoffee();
}
makePayment() {
}
receiveCoffee() {
}
}
class Delivery {
constructor(private orderCoffeeShop: IOrderCoffeeShop, private orderCustomer: IOrderCustomer) { }
deliver() {
orderCustomer.createOrder();
orderCoffeeShop.deliverOrder();
orderCustomer.receiveOrder();
}
//...
}
Now, we can see that both high-level modules and low-level modules depend on abstractions. Delivery class(high level module) depends on the IOrderCoffeeShop and IOrderCustomer interfaces(abstraction) and the Customer and CoffeeShop classes(low level modules) in turn, depends on the IOrderCoffeeShop and IOrderCustomer interfaces(abstraction). Thus, we will be able to use a new class implemented in the Customer and CoffeeShop classes without making any changes in the Delivery class. DIP is a simple but powerful programming principle that we can use to obtain well-designed classes, highly parsed dependencies (loosely coupled) and reusable code snippets. Also, this DIP forces not to violate the Liskov Substitution Principle.
Final Words
We’ve covered five principles that every software developer should follow. It may be intimidating to comply with all these principles at first, but with constant practice and devotion these will have became a part of our applications, providing more maintainability, flexibility, portability, reusability, readability, scalability and testability. Each principle is interrelated and should be considered as a whole in a software development process.
Thanks for following to the end. Like claps :)