SOLID: How to use it, Why and When

Posted by : on (Updated: )

Category : Architecture

This post is part of a nine post series about the SOLID principles and code architecture. You can check all the posts here:

Introduction

This is the last post about the SOLID principles. After describing the SOLID principles, it is good to have an understanding when they should be used and when they shouldn’t. As with everything, overusing the SOLID principles can have a bad effect, but on the other hand, never using them can be as bad.

In this post, I will try to describe how the choice should be made, so the reader can have some guidelines other than ‘depends on context’ or other rather abstract explanations like ‘when makes your code simpler’ or ‘better’. After all, concepts like ‘simpler’ or ‘better’ often have a subjective meaning.

Besides any explanation, as with all things, experience plays a great part in the decision. Especially in architectural decisions, like any application of the SOLID principles, where there are very few hard rules. And the SOLID principles, are not rules. They are guidelines that can help or hinder our productivity depending on when, where and how we choose to use them.

But before all that, let’s see the connection between the architecture of a codebase and the code.

Architecture and Code

The waterfall model of architecture, can have its uses in certain areas, but more often than not, any model of architecture that is being designed upfront, before any code has been written, becomes obsolete.

The codebase should not be dependent on the model. The architectural model should depend on the history of the codebase.

Designing the architecture of a program, writing UML models with classes and their connections before actually writing any code, usually doesn’t end well. There are many problems that in theory are solved in a UML diagram, but when we actually try to implement that diagram in code, these decisions cause more problems than the ones we try to prevent.

Besides that, there are many problems that become apparent when we actually write code, and we never could have thought these problems when we were designing our UML diagrams.

The code should come first. First we write code to solve a problem we have. After our code works, then we try to make it look good, not only by refactoring our code inside our classes, but also refactoring the dependencies between them. As I mentioned in my Software Architecture in Game Development post, Architecture is about investing time. and applying every SOLID principle whenever we can, is not a good time investment.

Eventually, by applying SOLID everywhere, not only we may lose time by writing more code, but we can also lose time in the future by creating a spaghetti of dependencies. To understand when to use the SOLID principles, we have to understand that the principles themselves, are not the important thing in SOLID.

The Important Thing in SOLID

The important thing in SOLID, are not the principles themselves. What is important is an understanding of the problem in the code architecture each principle tries to solve. When we have written a system, we have to take a step back and focus on a top view of the dependencies between our classes in that system. By knowing the problems that each of the SOLID principles tries to solve, means that we can easily recognize these problems in our own architecture.

Then comes the hard part. For each problem we recognize, we have to make a conscious decision if that problem will have consequences in the future.

If we decide that a problem in our architecture will cause us to spend time in the future refactoring many classes whenever a change is needed, then we should try to solve it. If we decide that we don’t want to depend on any external code, like third party libraries and frameworks for as much time as possible, until we have created a big piece of our program, then we should try to defer any dependencies to these, in the future.

Knowing SOLID, primarily means to know how to recognize these problems. The application of the SOLID principles is secondary. Here are the problems that each of the SOLID principles tries to solve:

The Single Responsibility Principle

The Single responsibility principle tries to solve the problem of fat classes.

When we code, many times we will find ourselves creating huge classes. Classes that we know will be difficult for anyone else or ourselves to understand in the future, because of their size. These classes are not only hard to understand but also have a lot of other classes depend on them, because of the sheer amount of things they do.

Each change in any part of a huge class, could cause changes in every class that has a dependency on it, and by extension in every class up the chain of dependencies.

Ideally we want to make as few changes as necessary and for that reason we need to have as few classes as possible depend on a single class.

How are we going to separate one big class to smaller ones, which methods and what data are we going to take away to create a new class, is a problem that its solution can be decided by applying The Single Responsibility Principle.

The Open Closed Principle

The Open Closed Principle tries to solve the amount of changes we have to do in a class that we have created.

Many times when a change is needed, we go to the relevant class and add more code, or even worse replace code already written. In a perfect world we would like to be able to add new behaviors in our systems, without changing or deleting already written code. After all, a change may be needed again, because the new functionality doesn’t solve the problem as well as we hoped it would.

When we see that a class has to change often, or we predict that this class will change in the future, perhaps then we should consider that instead of keep making changes to that class, we should try to apply the open closed principle.

The Liskov Substitution Principle

The problem that The Liskov Substitution Principle tries to solve is the hardest one to recognize, because its consequences become apparent much later into the future.

Essentially the Liskov Substitution Principle covers all the wrong uses of inheritance. Whenever we try to decide between inheritance and composition by using the common ‘Is A’ and ‘Has A’ way, we run the risk of violating the LSP.

Any time that we have used inheritance in our code, and after some time we find ourselves in an impossible situation where we have to spend a lot of time refactoring or doing something ‘hacky’ so that our code can keep working, is because we have violated the LSP.

The LSP solves the problem of using inheritance the wrong way, and for that reason whenever we decide to use inheritance we should first think if that would violate the LSP.

The Interface Segregation Principle

The Interface Segregation Principle tries to solve the problem of having an abstraction doing too many things.

As I mentioned in my conceptual meaning of interfaces post, an interface conceptually belongs to the class that is using it, not the class that implements it. An abstraction that is being used by many systems will create a lot of changes to those systems if a change is needed in that abstraction.

By applying the ISP, we can limit those changes by having systems depend only on things they care about. If a change is needed in a method declaration, this change will only affect the classes that use it and not all the classes that use the big interface.

The Dependency Inversion Principle

The Dependency Inversion Principle solves two problems.

The first problem is that any changes that can happen in our code, have a higher probability to be in concrete implementations than in abstractions. The reason is that it is more common the case that we need to change how something is done than the cases where we need to change what is being done by a class.

The second problem is that it allows us to defer decisions in the future. Because we don’t depend on concrete implementations, we can have an interface implemented by a class, that has a limited functionality of what we need. After we have written our code that depends on that interface, we can substitute that class with the class we need, or we can have an adapter that implements that interface and communicates with any external library or framework we want to use.

Not all problems need to be solved

These are the problems that the SOLID principles solve. Recognizing these problems in our architecture is important, but that doesn’t mean we need to solve them all.

We only need to solve a problem, if we think that it will have a negative effect in our productivity in the future. But how can we predict the future?

Trying to predict the future

Trying to predict the changes that will happen in our code in the future is hard. Experience plays a big part, but here are some things that can help with the accuracy of our predictions:

Talking

Talking to the people that give us the requirements is really important. We should not only learn what we have to do, but also we should try to understand how it is going to be used, who are the end users, why they need it and what problem our code will solve.

Usually, especially if the people giving us the requirements are not programmers themselves, they have a problem, but they don’t ask us to implement a solution to that problem. What is actually happening, is that they have a problem, they have thought of a solution to that problem, and they ask us to implement that solution.

The problem with that, is that usually the solution they have thought, is not the optimal for their current situation. The phrase “That’s what I asked/said, but that was not what I wanted/meant/imagined” is pretty common for this situation.

This obviously, will create the need for many changes in our program. Talking, not only can avoid this, by understanding the real problem and proposing a solution that is better in solving it, but can also identify pieces of functionality in our program, that the person who is giving the requirements is not sure about. The classes that implement that functionality should be considered volatile.

The way the program is going to be used can also identify volatile parts and even the platforms that our program is going to be deployed, may identify parts of our code that will need changes because of incompatibilities or performance issues.

Listening

Listening is also important, but I don’t mean listening when someone is giving us the requirements. In contrast to the common belief, that a programmer is something like a D&D necromancer: He isolates himself in a room for months until eventually comes out in the world saying “Behold my creation!”, a programmer should try at first to do the smallest working program possible.

This program should contain the minimum amount of features, it will be a minimum viable product. After that, it will be presented to the end user, not only so that he can express his opinion, but also if possible, to observe his interactions with it. From then on, this should happen as often as possible.

This doesn’t only help to have small changes implemented each time, but also helps with identifying volatile parts of our program. If there are parts that a change has not been asked after three or four presentations, there is a good chance to consider that these parts are not going to change. The classes that compose these parts should be considered less volatile, and our architecture should change to reflect that. The systems that a change has been asked, are more volatile and should depend on the systems that are less volatile and not the other way around.

Identifying the implementation details

Any part of our program that is not part of the logic that solves the problem, but supports the implementation of that logic, should be considered an implementation detail. Databases, UI, Input systems, Rendering systems etc. are all implementation details and our code should not depend on those.

Instead, our code should depend on abstractions that are implemented by those details, or by adapters that implement these abstractions and depend on the implementation details. Changes to those details are more likely to happen, and this should not cause changes to the core logic of our program.

Looking at the past

Architecture of a program, is not about a snapshot of our code in a specific point in time. It is about the history of how our code came to be in this form now.

By looking at the past, we can have a good indication about the future. Every change we made, every decision we took was because of a reason. If that reason still exists, that may mean more changes are going to be needed to parts of the code that we changed in the past.

By treating these parts as volatile, we can assure ourselves that even if our new code does not completely satisfy the reasons that were the cause of the change, any new change can happen safer and faster.

Conclusion

As with many things, too much of something is not good. The SOLID principles are not an exception to that. By understanding the problems they solve, we can learn to recognize these problems in our architecture. Then, we have a decision to make: Which of these problems are going to cause us problems in the future? For the problems that we decide that they are going to affect us, we can apply the relevant SOLID principles.

Trying to code a big program by applying every SOLID principle in every possible situation, will do more harm than good. We will spend unnecessary time, and we will create a web of dependencies that will make the code harder to understand. Architecture should not be about loosing time, it should be about investing time.

With this post, ends a series of eight posts about code architecture and the SOLID principles. Thank you for reading, and as always, if you have any questions or comments you can use the comments section or contact me directly via the contact form or by email. Also if you don’t want to miss any of the new blog posts, you can always subscribe to my newsletter or the RSS feed.


Follow me: