Published on

SOLID for mere mortals

Authors

If you've worked with me for at least a few hours, then I've surely mentioned the SOLID principles. I treat them like some kind of Bible to follow to write good code.

But the question comes naturally: what is "good code"? How can I tell a good codebase from one that sucks ass? This seemingly simple question actually turns out to be hard to answer. The "beauty" and "readability" of a codebase are way too subjective to be used in a measure of this kind.

So how do I judge a codebase? Simple: the easier it is to develop new features without introducing regressions (meaning bugs caused by new additions and changes), the higher the level of the codebase.

Ideally, a team of developers should work exclusively on the product's features. Introducing new ones, changing the existing ones based on the evolution of the ever-malleable customer requirements, and so on... is one of the fundamental requirements for being AGILE.

In common parlance, this "cleanliness" parameter sometimes goes by the name of software maintainability, but in my opinion it's much more than that. "Maintenance" doesn't do the concept justice, because it almost sounds like an afterthought, something thought up later to improve the life of whoever has to deal with our code. But that's way too reductive. It is, instead, the very essence of how a software engineer reasons when faced with a problem.

But, you know, easier said than done... bugs are the order of the day, and most of them are, guess what, regressions. So how can we developers avoid these annoying problems as much as possible and focus on improving the product?

This is exactly where the SOLID principles come into play. And, on this super hot (really hot) July evening, I'm here to explain them to you in a simple way, the way I'd explain them to a colleague at the office. Let's begin.

How was SOLID born?

The SOLID principles are 5 guidelines to follow to improve code quality. They were born in the context of OOP (Object Oriented Programming) from Uncle Bob, a real giant of programming, and one of my points of reference together with Martin Fowler (I recommend reading his articles because they're fucking gold mines).

Let's dissect them one by one, so it's clearer:

S - Single Responsibility Principle 👨‍⚕️👨‍🔧👨‍🍳👨‍🏭

By far the most important and fundamental principle of all (it's the first one for a reason). It states that a class (or a function in a functional context) must have one and only one responsibility.

Whenever different features related to different contexts of the same domain show up, you should consider introducing a new class or function able to encapsulate both features, keeping each of them as separate as possible.

It might sound obvious, but this principle is incredibly hard to apply in practice. There is, however, a trick to figure out whether the code we're writing respects this principle or not. Just ask yourself the following question: "Does this class/function have one and only one reason to change?". If yes, we're respecting the principle, otherwise we're not.

Just following this principle alone immensely increases the flexibility of the codebase!

O - Open/Closed Principle 🔒🔓

This principle states that classes/functions must be open for extension (open) and closed for modification (closed). What does that mean?

It means that once a module or a feature is finished, we should try to avoid changing its source code directly to expand it. Instead, we should plan the code in a way that makes it easy to "extend" the features without turning everything upside down every time.

This is very important in production environments: to keep the obsessive dependence on tests to a minimum (tests which, you know, are unfortunately very hard to write effectively and to maintain) you need to keep changes to the original code to a minimum, except to fix obvious bugs.

However, this is only possible if the original code is set up for this kind of interaction, so it must make extensive use of interfaces (and consequently be architected following the composition over inheritance principle to simplify dependency management). Thinking purely in terms of input/output helps a lot to respect this principle.

If, instead, we really have to resort to inheritance, then it's fundamental to properly follow the next principle:

L - Liskov Substitution Principle 🧔👥

The Liskov substitution principle states that subclasses must be able to fully replace their parent classes, without altering the behavior of the program in any way.

This is another one of those incredibly hard principles to follow, because (you'll be asking) what's the point of inheritance if I can't expand the behavior of a superclass?

The keystone is thinking in terms of "contract". If a class respects a given contract, then all its subclasses must respect it too. Otherwise they must not be a subclass.

As you can see, the SOLID principles aren't just purely theoretical concepts, they're immediately applicable in practice! That's why I like them so much.

I - Interface Segregation Principle 📦⛓️🤝

The principle states that a class implementing an interface should never depend on methods it can't implement (or that it doesn't use). In a nutshell it means that writing huge interfaces with a ton of methods is forbidden (something domain Services usually do all the time), and it's better to introduce many different interfaces, each one fulfilling a specific task (back to the first principle). The goal is to keep methods from depending too much on each other, triggering a dangerous cycle of spaghettification of the codebase.

REMEMBER FOR LIFE: the goal is always decoupling! Divide, divide, divide! And always reasoning in terms of interfaces (input/output).

Reasoning this way also lets you split applications into microservices very easily. In practice, if you don't follow this principle it's impossible to "decouple" pieces of a monolith.

D - Dependency Inversion Principle 💉

This one is super important, and I only remember it by heart in English: "A class should always depend on abstractions rather than concrete implementations".

It means that a class must always depend on abstractions. Meaning? Simply put, the dependencies of a class (what the class needs in order to work) must be abstract interfaces, implemented elsewhere.

Pretty much every language has a way to achieve exactly this (in TypeScript/Java/Go through interfaces, in Rust through traits). In Spring there's an entire underlying dependency injection framework meant to "inject" class implementations into other classes. The basic idea is to make mocking easy in order to improve the testability of the codebase, but the advantages are many in other areas too, especially if all the other principles have been applied as well.

A bit like the pieces of Exodia or the seven Dragon Balls, if you manage to put all these principles together, you become super strong!

Wrapping up...

There are really tons of articles and discussions about these principles (the question of when not to apply them, for example, is still a very hot debate). A quick web search is all you need to dig deeper into them, and I strongly recommend doing it! Here are some resources to get started:

I suggest starting to apply the principles right away to internalize them, so you can dramatically improve the quality of your code!

See you next time!