These five letters become like a threshold that divides young developers from mature ones. I have visited a lot of tech interviews from both sides, and as a reviewer, I can say that question about SOLID could be a killing bullet for developers.
I want to show that it is pretty easy to understand solid. Let`s recall what does it state for, relying on the wiki.
SOLID is an acronym for five design principles intended to make software designs more understandable, flexible, and maintainable in software engineering.
- The Single-responsibility principle
- The Open–closed principle
- The Liskov substitution principle
- The Interface segregation principle
- The Dependency inversion principle
We are going to check each of the five principles one by one with examples in c#.
This one is used by many developers unintendedly because simple logic suggests how to improve our code. Single responsibility tells us to write small classes which do only a single part of logic.
Let`s take a look at some service implementation:
public class Entity
I want to highlight that this service violates SRP. Its responsibilities are validation, mapping, and actual business logic. This example illustrates that the service knows a lot about validation rules and how to map create request to entity and entity to the response.
Another thing that becomes much harder when you decide to abandon the SR principle is testing. Testing the above example will cause a lot of code to cover all possible cases, and eventually, it becomes unsupported and takes a lot of time.
public class CreateEntityValidator
Now, the code looks better, we moved validation and mapping to separate classes, and the service doesn`t know the internal rules of how these processes work. Now our service does not violate SRP, and in terms of testing, it is pretty easy to test 3 small classes which do a small part of logic.
This principle sounds quite tricky and could blow the mind
software entities (classes, modules, functions, etc.) should be open for extension but closed for modification
We`ll look at the situation when we work with a few data sources, and the later amount of external services may be increased.
// provided by external lib
Here is the classic example, you have to work with different clients that provide different contracts but logically, they do the same. The best way would be to use the Adapter pattern.
Take a look at Design Patterns — Adapter .NET
The adapter allows to make the same contract for different implementations, and it perfectly suits this example:
public class S3Client
As you can see IExternalSource interface was introduced, and proper wrappers were created for different sources. Service implementation was changed to accept a list of IExternalSource.
Let’s understand what does it means to open for extension.
In the example above, we can extend our service capabilities to work with additional sources, as, for example, an FTP source was added. This is the idea of an extension that we can add new functionality to our business logic without touching existing parts. In the real world, our service would be covered with unit tests, and new functionality doesn`t make us rewrite that tests. We just introduced a new source and new unit tests for that separate class.
At the end of this part, we must summarize what close for modification means. The example above demonstrates that to support a new source, we don`t need to touch Service implementation at all. In other words, the service is closed to any code modification.
This principle is closely related to the previous one:
Object and a sub-object (such as a class that extends the first class) must be interchangeable without breaking the program
The Definition states that we should write programs in such a way that it`s possible to use any descendant instead of a base class or interface. It is also very closely related to the interface segregation principle, as we can see a bit later:
internal class Service
Let`s take a look at the example from OCP. It works and adheres to LSP. Inside method GetAllBlobs, we do not care what kind of instance we actually get. We know a contract of interface and assume that all descendants who implement interface properly implement the GetBlobs method.
This principle forces us not to change child behavior drastically.
This one is also quite related to principles that we have already learned.
no code should be forced to depend on methods it does not use
In simple words, it means that we should never leave a method empty or throw NotImplementedException
public class Item
In the example above, you see that ServiceHistoryDecorator throws NotImplementedException because it allows to log only modification operations, but due to inheritance from common interface IService, it`s required to implement a contract with all methods.
public class Item
After small changes code looks better. We extracted modification operation into a separate interface and updated inheritance rules. Now we don`t need to implement read operations in ServiceHistoryDecorator.
Don`t miss it with Dependency injection, which is also pronounced a bit similar:
High-level modules should not import anything from low-level modules. Both should depend on abstractions (e.g., interfaces).
Abstractions should not depend on details. Details (concrete implementations) should depend on abstractions.
This principle only sounds unclear; indeed, it`s quite simple, and most .NET developers follow it implicitly.
Terrible example when High-Level component Service directly depends on Low-Level component Repository:
public class Entity
To follow the dependency inversion principle, we need to introduce an abstraction for our repository IRepository and make the service depend on abstraction instead of the concrete repository:
public class Entity
For .NET developers, it`s pretty easy to follow SOLID principles. Language helps us write code with best practices. As you notice, some principles are highly coupled, and if you violate one of the solid principles, there is a huge chance that another principle is also violated.
Good luck with writing a good code.