SOLID Principles explained with code example [#15]
SOLID is a mnemonic acronym for five object-oriented design principles. Michael Feathers introduced the acronym in 2004, but principles were already around. If you search for the SOLID principles in Google, you will find many results with explanations about what the principles are.
Have you wondered what should code that follows these principles look like? In this article, I will explain the principles with a code example.
The example
Suppose you are running a car park with different charging options depending on when the customer enters the park. The charge is calculated when they leave based on the length of their visit. These are the stay types:
- Business days: $1.5 per hour.
- Saturday: The first 2 hours are free, then the fee is $5.0 per hour.
- Sunday: The first 4 hours are free, then the fee is $6.0 per hour.
The code for this example is available in this GitHub repository.
The example is a Visual Studio solution that contains two folders: src and tests. The src folders contain a Console Application and a Class Library. From a Modules View perspective, it is a two-layer-based application.
S: Single Responsibility principle
Each class should have only one responsibility. Something that may help you to check if you are following this principle is asking yourself the following question: What are the reasons why this class may change in the future? If there is only one reason for the class to be changed, then we are good.
Let’s say we need to introduce a change for the business days calculation to charge the customers a different fee between 8 am and 6 pm. Implementing this feature will require the following steps:
- Modifying the BusinessDaysStayCalculatorService class.
No further changes should be needed. In our example:
- Each stay type is implemented in a different class.
- ChargeCalculatorService class: Has the logic to determine what type of charge should be used.
- Worker class: Reads user input and invokes the calculation service.
- Program Class: Initialize the console app (Wires up dependencies and starts the app).
O: Open/Closed principle
Software should be open for extension but closed to modifications. Let’s say that we need to introduce a new type of stay for holidays; implementing this feature will require the following steps:
- Add a new value to the StayTypeEnum
- Adding a new file and class for the calculation. Let’s call the class HolidayStayCalculatorService. This class will have to implement the interface IStayTypeChargeCalculatorService.
- Adding a new file and class for unit tests
- Wire up the implementation using DI in the Program class.
- Modify the ChargeCalculatorService class to check if the input day is a holiday.
The classes that implement the other stay types calculation will not be modified, nor their test classes. This approach will give us a few advantages:
- Implementation should be more straightforward. There is no need to reason about how the rest of the calculations were made or worry about breaking them.
- It will make the code review process easier for your teammates.
- No need to worry about breaking the calculation logic implemented in the other classes.
- Code commit will look cleaner and align with the task: Adding a new charge type.
L: Liskov Substitution principle
This principle states that objects of a superclass should be replaceable with objects of its subclasses without breaking them.
In our example, we have different stay types; each type follows different logic to calculate the charge, but all follow the same contract. They all receive as parameters the start date and the end date and return a decimal value. The implementation of the interface IStayTypeChargeCalculatorService enforces this.
If we need to add a new calculation type, It should be enough to add a new class that implements the IStayTypeChargeCalculatorService and modifies the ChargeCalculatorService class. As long as we implement the interface, we know we can inject the new class using DI without breaking anything.
I: Interface Segregation principle
This principle was defined by Robert C. Martin while consulting for Xerox. The principle states that clients should not depend on interfaces they do not use.
In our example, we only have two interfaces, and these are fined-grained interfaces.
- IStayTypeChargeCalculatorService: Exposes a calculate method for a stay type.
- ITotalChargeCalculatorService: Exposes a method to calculate the total amount charged to the user.
We could have used the same interface, but this would have broken the Single Responsibility Principle. Two interfaces are suited well to this use case.
Let’s imagine we have some configuration parameters stored in a class that implements the IConfiguration interface. The configuration object includes several parameters, such as the fee for each type of stay, the number of free hours for every kind of stay, and a list of holidays.
How would you pass the Business Days fee and number of free hours to the BusinessDaysStayCalculatorService class? The easiest thing may be injecting IConfiguration using constructor injection. Do we need to inject the whole object with parameters we will never use in this class?
Instead, we could have a new interface and class that only holds the parameters we need for this type of calculation. This approach will avoid confusion among people reading your code and make it easier to test.
D: Dependency Inversion principle
This principle states that high-level modules should not depend on low-level modules; both should depend on abstractions. Abstractions should not rely on details. Details should depend upon abstractions.
- The Worker class only depends on two interfaces (abstractions): ITotalChargeCalculatorService and ILogger. It doesn’t know how they are implemented or what implementation it is using.
- The ChargeCalculatorService has some degree of knowledge about the stay types but doesn’t know how they are implemented.