SOLID code, the Silver Bullet
SOLID code, the Silver Bullet
It's ALL about dependencies
Join the DZone community and get the full member experience.Join For Free
SOLID is one of those words we developers throw around us, implying some deeper meaning, hopefully helping us to create better software systems. It should be second hand nature to all (OO) developers, but unfortunately is often misunderstood, or used to defend a decision, based upon flawed logic. Hence, in this article, I will try to "dumb it down" and use analogies for each of its 5 items, in an attempt at making it more easily understood, to avoid confusion.
Single Responsibility Principle
The "S" I'm solid implies that each class should only do one thing. It often helps to break down your flow into verbs to make sure you follow this principle. For instance, imagine you have a task scheduler, that should implement the ability to create tasks for execution at some point into the future. Maybe you want it to have the ability to persist tasks, in case the process is recycled, without dropping tasks. Imagine Hangfire here as an example. Well, ask yourself how many verbs you have in the above feature requirement, and then try to group them into related actions. I could find the following.
- Create task
- Execute task
- Save task
- Load task
The save and load tasks are obviously related, and can be combined into one responsibility. This would result in the following classes.
- DeleteTask (optionally)
At this point, the class hierarchy from an abstract view, becomes obvious - And arguably half your job is done. If you resolve your implementation classes as interfaces, using some sort of IoC container, it's easy to imagine how you would get the added benefit of easily creating alternative implementations, resulting in having the ability to create unit tests for your project as a whole. And in fact, already at this point, after creating your interfaces, you could arguably already start writing the unit tests, ending up with a Test Drive Approach (TDD) - Before having written as much as a single line of implementation logic.
It could be argued that this is just another phrase for "good encapsulation". What it means is that as much as your software as possible should be constructed using the "black box mentality", with the ability to extend it. If you create a class that others are to reuse, and others needs to understand the hidden details of how your class was implemented in order to understand how to use it, you are violating this principle.
For instance, imagine that the above "Load" and "Save" methods requires the caller to decorate the TaskPersister class with a stream instance through one of its properties, before being able to persist tasks. This results in bad cohesion, since I'd need to know the "recipe" for how to use your class, before I could use it. Good cohesion for the record translates into "Ohh, cool! A method, I can invoke it, without having to do anything else first!"
It's easy to imagine how another developer might at some parts of his code forget to decorate your above stream property, which would result in hard to track down bugs, probably only showing themselves after the system has been set into production.
To fix this problem, we could further sub-divide our TaskPersister into one additional class, encapsulating the actual storage, and require the TaskPersister class to being passed in an instance to your IStorage instance. This allows us to use the TaskPersister class as a whole, and yet still "extend it". Hence, the TaskPersister class is now "Open for modifications", since the underlaying storage is simply an argument to its constructor. While the class is still "Closed for modifications" - Implying you can modify its behaviour, without modifying the class itself.
This would allow you to use the same TaskScheduler class, for persisting tasks to both the file system, an SQL server, or any other type of persistence system you need to persist tasks to - Including mock objects during unit testing. At this point, the TaskScheduler's job, would be to simply take your actual task instance, and for instance transform it to some serialisable format (XML, JSON, "whatever"), and pass in this as a piece of text to the IStorage interface. And then reverse the process during de-serialisation. Hence, your task scheduler is "closed for modifications" and "open for extensions". We now have the following class hierarchy.
- TaskPersister (requires TaskStorage)
- SaveTask (requires object instance of task)
- LoadTask (returns objects instance of task)
- SaveTask (requires textual representation of task)
- LoadTask (returns textual representation of task)
This of course, makes our "S" above even stronger, since we have now separated the actual storage from the class responsible for creating a serialisable textual representation of our object. So hence, the responsibility of creating a textual representation from an object (Task), and/or a Task from a textual representation, is now separated from the class responsible for selecting our actual physical storage. And our classes are now "open for extensions" and "closed for modifications". This allows us to polymorphistically change the behaviour of our original persister class, without actually knowing anything about its internals, or change it in any way. At this point it should also be obvious that we need interfaces for each of our above implementation classes.
There are many other good examples of the Open/Closed Principle, but yes, this is one of the more difficult characters to explain in SOLID, due to that it doesn't have a single word we can attach our minds towards to understand, besides possibly "encapsulation and polymorphism". However, since it's also a good advice to avoid inheritance, and prefer composition, using words such as "polymorphism" often times tends to become counter productive when trying to teach good practices. Still, with the above example, we have accomplished what traditional polymorphism tries to give us, without creating a closely coupled class hierarchy, being a nightmare of inheritance to maintain.
Always prefer composition!
Liskov Substitution Principle
This is probably the least interesting character from the word SOLID, simply since another set of principles teaches us to "prefer composition over inheritance", but it speaks about having an "is a relationship". The classic examples can be illustrated with imagining a Rectangle class. A rectangle have width and height, hence we could imagine it having two methods "setWidth" and "setHeight". Later down the road, we need a Square class. At this point it is tempting to inherit Square from Rectangle, and simply setting both the width and height of our instance, if the caller invokes "setSize".
Needless to say probably, but this creates a whole range of problems, due to polymorphism, allowing us to "believe an instance is a rectangle, while it actually is a square", ending up with all sorts of misunderstood behaviour as we start passing our Square instance around to other methods, expecting a Rectangle. Hence other parts of our system, might change the width of our Square, happily assuming the height is whatever it previously was, while the height too was in fact changed - Resulting in all sorts of undefined behaviour in other parts of our system.
This is one of those places where even the titans of software development are messing up things. An example can be found in .Net Framework, where Microsoft chose to inherit their WebForm "Page" class from their "Control" class - While in fact a Page logically is a collection of Controls, and not a Control itself. Resulting in theory that you might end up rendering a Page, that has hundreds of other Pages in its Controls collection.
Needless to say, but this is not a goo thing
Hence, arguably, the simple thing to do, is to simply (almost) never use inheritance, but rather prefer composition - At which point LSP (Liskov Substitution Principle) becomes meaningless. However, sometimes you still need inheritance, but be careful with it please, and treat it the same way you would treat a loaded gun.
Interface Segregation Principle
Finally, something far simpler to explain then both the "O" and the "L". Basically, to sum it up, make sure your interfaces are tiny (oversimplification to get the point through). In a way this is the "interface version of the Single Responsibility Principle", and hence in some ways arguably redundant, since if you are following the "S" also when designing interfaces, you're intuitively already following this principle.
This principle translates into; "Prefer many small interfaces, instead of one, where each interface encapsulates a Single Responsibility."
Dependency Inversion Principle
If you want a simplified understanding of what this principle tries to communicate, then realise its goal, rather than getting stuck on its wording. The goal is to be able to exchange and extend any parts of your system without having to change the code of your original module, the same way the Open/Closed Principle helps you with the same. In a way what we did when we created the IStorage interface above, was to follow this principle, because we made the TaskPersister class dependent upon an abstraction to physically persist its tasks, instead of a concrete implementation. So by de-coupling our TaskPersister class from its physical storage through depending upon an IStorage interface instead of a stream, we followed the ideas of the Dependency Inversion Principle.
Since the TaskPersister is a "high-level module", we exchanged its dependency graph, by exchanging the way it was dependent upon a concrete class, to make it depend upon an interface instead. Its basic idea and goal, is to make everything exchangeable and extendible. This reduces your dependency graph between your modules, allowing you to create more dynamic and agile software, and exchange any parts of your system, without modifying its original modules. Hence, in an oversimplified way it's all about achieving the same goal as the Open Closed Principle.
If you're a seasoned architect, you could probably speak about any of the characters in SOLID for hours, and show hundreds of examples of each and every single principle it talks about. However, if you're just starting out as a developer, the things mentioned here might seem like "black magic". So let me simplify it all for you, by starting from the finishing line, and showing you an example of what you can achieve, if you choose to follow these principles to the extreme. And remember ...
It's all about reducing your dependencies!
If your dependency graph (References in Visual Studio) does not look like this basically, you're probably doing something wrong, and you need to rethink the way you create dependencies between your modules - Because in the end, that's what the purpose of SOLID is - To reduce dependencies between components and modules in your software system. So open your last solution (C# or Java, doesn't matter), and please have a look at your dependency graph - And if it doesn't resemble what you see in the following screenshot, feel free to click SHIFT+DELETE and start all over again ... ;)
In case you missed the point in the above screenshot, there are only two project dependencies in the above project. This screenshot comes from a solution file for Visual Studio, that has more than 30 projects. And all of these projects except one has ZERO dependencies between ALL projects except ONE. This single project with all of these references again, has one single responsibility, which is to wire up my IoC/dependency injection container, and that is its sole responsibility.
If this seems like "black magic" to you, then realise that the way I accomplished this extreme amounts of de-coupling, was by using one single design pattern, which allows me to completely de-couple everything, resulting in (arguably) the silver bullet to achieve 100% SOLID, without requiring knowing anything about what any of the above mentioned characters actually implies.
It's all about dependencies, or eliminating them to be accurate
Opinions expressed by DZone contributors are their own.