SOLID Software Design Principles and How Fit in a Microservices Architecture Design
Build your foundation of SOLID software design principles with this useful SOLID guide. Here you'll find SOLID tutorials that help you learn and better execute its design principles.
Dec 15, 2018 • 22 Minute Read
Introduction
Design principles are good; they can guide us when making important decisions. Relying on proven and mature “advice”, when designing our software, is helpful - in particular, when considering the following facts:
-
The domain of computer-science is rather “new” to our world.
As such, new technologies, development languages, runtimes, and paradigms are rather common.
Thus, the “book” of computer-science is not yet (and may never be) complete. Chapters are being added to it constantly, as real-world requirements arise and the software industry evolves to meet them.
Relying on “best practices” when observing a newly discovered territory will prove beneficial (at least statistically).
-
Many markets are very competitive. Bringing your product to shelf can have both a tight budget and time constraints.
Under such circumstances, it is desirable to have as few “open” questions as possible.
Some questions have been asked plenty of times and we can rely on proven answers to them.
-
Writing great software is difficult. There are many simple things you can do that are, simply, not correct. Acknowledging this fact is actually a driving-force towards the development of better ways of doing things.
Unlike the previous bullet, here we are not talking about answers to known questions, but rather about the manner in which we approach answering our remaining open questions.
This article is about the “SOLID” software design principles in general and how they fit the sub-domain of microservices, in particular.
I hope you enjoy this read. Let’s get started!
SOLID Software Design Principles - Brief History
A little after object-oriented software design became popular in the 90’s (of the 20th century), it also became clear that object-oriented design is not protected from the “merits” of any human written content - it can be poorly written.
Huge classes and methods, code and/or configuration duplication, doing too many things in a single code block and much more - were still possible. And old habits are easy to get back to.
What I am trying to say here is that the shifting of paradigms into object-oriented software design did not solve the “mess” we were in.
It’s like todays “hype” over Agile software development. Some teams, who were forced to work under the SCRUM model, are non-enthusiastic participants of daily meetings (which they consider a waste of time) and simply want to get back to their chair and work (as they think is most productive). Their approach towards planning meetings is the same.
Please don’t misinterpret me. I have nothing (in particular) against SCRUM. I am merely stating that any paradigm/significant change in one's way of work should be fully adopted by that person - adopted wisely, with an understanding and commitment to the paradigm.
Ok, so we understand that it was possible to write “bad” software prior to the emergence of object-oriented software design and we also understand that it was possible to write “bad” software after the emergence of object-oriented software design.
So, why bother, then?
Well, apparently, it was easier to “bring order” and guidance to the world of object-oriented software design, due to the abstractions it presented. Objects, along with their methods and inheritance trees, presented additional abstractions to programming languages (such as interfaces as first-class citizens) - abstractions which required caution and order when handled.
As you will shortly see, most principles covered in this article relate to recommendations about a specific piece of code (e.g. it’s responsibility, how many logical things it should perform, what it should expose to the outside world, how many times can it be replicated) with regards to an observed idea.
These recommendations (along with others) were presented by Robert C. Martin and became known as the SOLID principles. Each letter in the word “SOLID” stands for a principle:
- Single Responsibility
- Open/Closed
- Liskov Substitution
- Interface Segregation
- Dependency Inversion
While even full compliance with these principals cannot promise that your software is bullet proof, these principles were and are demonstrated again and again in successful projects in the object-oriented software design world.
It even works for me.
Let’s perform an overview if the SOLID principles in the context of their creation. First: the context of object-oriented software design. Once we’ve done that - we will be able to observe these principles in the recently emerging paradigm of microservices.
The Single Responsibility Principle
The single responsibility principle states that a class should have a single reason to change.
Many people have added their own understanding of this principle, which translates to “a class should only perform a single task”.
While this addition is most beneficial, it is actually a side-effect of the “true” meaning:
- Classes should be highly coupled internally.
- A change to a specific area of the class should affect all dependent classes.
Now, while intuitively this might sound like a bad thing, it is actually a driving force towards “cutting” our classes to small pieces based on their functional responsibility in our system. Doing so allows us to isolate a specific “area of change” in our software, according to the dependencies of the class being changed. And if we “dissect” our code properly, we will mostly discover that each small class has less dependencies than the previous “larger” class.
The Open/Closed Principle
The “Open/Closed” principle states that software entities should be open for extension but closed for modification.
Yeah, I know. A bit confusing at first.
Let’s first clarify what entities are. In the world of object-oriented software design, an entity is a class, module, or a function. Some object-oriented programming languages vary in abstraction terms (e.g. some do not contain the module abstraction), but in general they all have something in common:
They all allow us to define instances of an object which has a unique state (unique, even if only by the address in memory it is stored at) and a common behavior which is shared by all other object instances.
The entities we are referring to are the code abstractions we’ve used to implement our objects (whether it’s classed for an object template or functions in which we’ve written a specific behavior of a given object).
Let’s look at an example which violates the principle:
The Car class has three acceleration methods - one for each car model.
If we were to introduce an additional car model to our system, we would have to modify the Car class itself!
That means that when extending our system we performed an undesired modification.
Let’s look at a design which does not violate the Open/Closed principle:
In this design, we can see that by using an interface, and passing on the responsibility for behavior implementation to the inheriting classes, we’ve “closed” our system for modifications, while keeping it “open” for extension (i.e. the addition of a new car model).
The Liskov Substitution Principle
The Liskov Substitution Principle states that if objects of type T are replaced with objects of type S, when S is a subtype of T, in a program, the program’s characteristics should not change (e.g. correctness).
While that sounds very intuitive to you, today, you need to understand that some programming languages actually allow a subtype to modify its super-type behavior!
So, this “extension” principle is basically a warning about avoiding that pitfall.
The Interface Segregation Principle
This principle sounds so simple, yet is so powerful:
An object should only depend on interfaces it requires and should not be enforced to implement any method (or property) it doesn’t require.
Have you ever encountered two classes which inherit the same interface yet one (or even both) of them do not use one or more of the methods in that interface?
Sure you have. We all did.
Please take a look at the following example:
interface IMatrixOperations
{
Matrix GetInverseMatrix();
Matrix Transpose ();
}
What we see here is the interface IMatrix which defines matrix operations.
We see that the interface contains the method GetInverseMatrix().
However, calculating the inverse of a matrix is only relevant to regular matrices. Why should this behavior be described in a general interface and not a separate interface, such as IRegularMatrix? No reason, really. Let’s do it:
internal interface IRegularMatrixOperations
{
Matrix GetInverseMatrix();
}
interface IMatrixOperations : IRegularMatrixOperations
{
Matrix Transpose ();
}
“Breaking down” interface described behaviors allows us to improve our codes readability, testability, and maintainability.
The Dependency Inversion Principle
For dependency inversion I will start with quoting the principle:
A. Higher-level modules should not depend on low-level modules. Both should depend on abstractions.
B. Abstractions should not depend on details. Details should depend on abstractions.
Interesting, right?
Again, it seems rather intuitive, nowadays, as we got used to “connecting” our components using interfaces.
However, many beginner-level developers make this mistake again and again, as (apparently) our human mind tends to think hierarchically.
The following “bad” example is not that uncommon:
internal class Gear
{
}
internal class Engine
{
Gear gear;
}
class Car
{
Engine engine;
}
See the “problem” there?
Our high-level class, Car, is tightly coupled to the Engine class. The Engine class, in-turn, is tightly coupled with the Gear class.
We’ve created a tightly coupled system. Every lower-level component is being used directly by the level above it.
How can we solve this situation? Well, rather simply, using interfaces between components.
internal interface IGear
{
}
internal class Gear : IGear
{
}
internal interface IEngine
{
}
internal class Engine : IEngine
{
IGear gear;
}
class Car
{
IEngine engine;
}
By extracting the “contract” between each of the dependent entities, we’ve decoupled each entity from its former dependencies. A contract is now being shared between each entity and its dependency.
Microservices Design Principles - Todays Hype
Nowadays, it seems everyone is talking about microservices. Many claim that their software platform is a microservices platform and (most of) the rest claim that they are on their way there.
What are they all talking about? And most of all, is there anything we’ve learned which we can apply in this new domain? I think so.
Let’s first explain the core principles of a microservices software architecture.
As with any abstract, whether new or not, highly talked about paradigm, technology, or concept, you might ask different people to explain it to you and get different answers.
However, most answers will relate to the following ideas and characteristics, which I will describe here.
Independent Deployment
To begin with, a microservice should be independently deployable. Your software system can be deployed as a whole but it is not mandatory. Services (microservice) can be added (and removed) from the system as it is running. A microservice should be deployed with its dependencies, if any (e.g. database, 3rd-Party components).
Modularity
Each microservice is a unique piece of code, running in its separate process, possibly hosted on a separate machine than other microservices. Being a part of a bigger “puzzle”, which serves the business goals - it is responsible to provide a single “piece” or “functionality” to the system.
Communication
Microservices, when required, should communicate with the “outside world” (e.g. other microservices) using a well-defined, standard, and light weight communication mechanism. I’ve seen (and written) systems were services expose their endpoints through HTTP/REST only (with Json) and systems in which microservice communicate through queues only. And a mix of these, as well. Whichever way you choose, maintaining “order” with regard to communication standards is going to be crucial to your success.
Size
I kept it for last, though you might have expected me to talk about the “size” first, as the name “microservice” suggests.
The size of a microservice is something which is controversial. I will provide several opinions and mention my own. Further on, when adding the SOLID principles to the discussion, you will have the tools to make your own opinion.
The first opinion is that a microservice should be as “large” as it needs in order to perform the task it was designed for but not “larger”.
However, some system designs might require a large number of microservices, when adhering to this recommendation. That translates into a large number of processes, which have their own overhead.
Now, if you are working on a system that has a requirement to be able to run as an AIO solution, you might be limited by an overly granular “break down” of your system into processes.
Just something to keep in mind.
A second opinion is that the size of microservices is related to your team sizes. According to this opinion, you should build microservices in a size such that a single development team should be able to maintain it.
Now, while there might be short-term management benefits to this approach, I disagree with it and prefer the first option as I believe in keeping software design clear of knowledge about the “outside world”.
I therefore recommend the first approach - make your microservice as large as they need to be in order to perform the task at hand. No larger.
Why and How It All Fits Together
At this point, you are familiar with the SOLID principles for object-oriented software design.
You also have an idea about what a microservices design of a software system looks like.
But how do these design paradigms relate to each other?
Well, they do not compare well. However, they stack well and please allow me to elaborate.
To begin with, when writing any concrete microservice, you can still enjoy the benefits of the mature and elaborate knowledge and experience gathered in the domain of object-oriented software design. That knowledge is not thrown away. It can be reused.
Microservices can be developed using any paradigm (e.g. functional programming, object-oriented, event driven) or flavor you choose, that's true.
Being practical, most modern programming languages and frameworks are object-oriented, making this article of interest to many of you.
Several ways to approach the consolidation of the two ideas (i.e. object-oriented software design and microservices software architecture):
A “One by one” Approach - Each service on its own
With this approach, we are developing each service separately. Completely. It will even have its own source control repository.
With this approach, there are, as always, pros and cons. One con, for example, is that sharing “common” utility functions is not allowed. And if, for some reason, you cannot extract that functionality into its own microservice, you will have functionality duplicated across your system.
However, this approach encourages choosing the right tool for the task at hand, as repositories are unbound from one another. Each repository can be created using any programming language, build technology, and third party dependencies.
SOLID Software Design Principles Implementation
With this extreme approach, were each service is completely isolated, even at the level of it’s codebase, we will apply the SOLID software design principles per service.
No shared codebase is desired, so we will treat each microservice as a whole application. This means that it will have its own interfaces, class hierarchy, and dependencies.
This approach is interesting, though it can certainly lead to logic duplication in services.
For example, each microservice will maintain its implementation for working with the inter-process messaging system.
In some scenarios this will be desirable (e.g. working with a lightweight version of an http client) but in most it will probably not.
A “Holistic” Approach - A system of (micro)services
Taken to the other “extreme”, a microservice is only thought of as a piece of the larger picture. Now, please don’t kill the messenger! I know most of you heard that this is not the “microservices way”. I know. But it is a “way” and has some pros (and cons) to it.
For example, one pro (which is also a con) is the ability to maintain a single repository for all services.
SOLID Software Design Principles Implementation
Code sharing and reusability is much easier with this approach. For example, you could share your previously duplicated messaging code, among services (if they are written with the same programming language).
Is it a “good” thing? Arguable. Will you encounter this approach in systems? Probably.
The largest benefit of this approach is that it is easier to maintain a single repository with a single build system and to “compile” and test your latest code together.
In turn, the largest pitfall of this approach is that versioning a single service becomes a nightmare. If you’ve made a change to a single service, you still need to compile and build the entire system and there is always the risk that you’ve “broken” something.
The scope in which we can maintain and enforce the SOLID principles when taking this approach is maximized when looking at the system from the “outside”. Unfortunately, it is reduced as we dive in to specific services.
For example: when looking at the system as a “whole”, we might have violated the Open/Closed principle. Each change to a service might require us to change code/rebuild our system.
A “Bridged” Approach
Life, as life goes, will present difficulties. No single approach you take, will guard you from tough choices.
Choosing the “by the book” approach, with each microservice managed in its own repository, having its own build and versioning process will certainly have benefits. But it will also have an overhead (e.g. repository management).
Then again, choosing the “holistic” approach, where all services are managed and built from the same codebase will have less overhead in some aspects (e.g. build time, code duplication). It will also present “problems” when considering others (e.g. service versioning).
Can we think of a “nice place in the middle” where the two approaches can converge?
I think that in most cases, we can.
For example, how about presenting an abstraction of a “microservice group”?
Now, a group of microservices might be defined by the technologies and dependencies shared by the services.
Or by the logical aspects they are managing.
The “grouping” of microservices should not be strict but rather agile. This means that if we’ve come to understand that grouping services according to specific parameters doesn’t work well for us, we can always change our grouping definition.
Using this approach, we could look “holistically” at a group of microservices and still have control over this group’s versioning, build process, dependencies, and codebase management.
SOLID Software Design Principles Implementation
No surprises here. We will eventually only be able to “keep an eye” on services which are bound together (e.g. by contracts, shared repository).
However, by introducing the concept of a “service group” we could take control of the tradeoffs between the two extremist approaches I’ve previously presented.
Within the bounds of a service group, we could share dependencies, build process, and, most importantly, a codebase which respects the SOLID software design principles.
In Conclusion
In this short journey, I’ve tried to do three things (yeah, I know, it violates the single responsibility principle).
The first thing was to explain the SOLID software design principles. In particular, I’ve tried to present them in a favorable manner. Why? Because they have proven beneficial time after time.
Then, I’ve presented the new kid on the block: the concept of a microservice.
I began by explaining that the terms are not comparable, but rather (optionally) composable. You can combine the two ideas into your system design.
Mostly, I’ve tried to emphasize the fact that working with microservices is interesting but can present an overhead in some aspects. Particularly when implemented in a “strict academical” manner.
Next, I’ve taken the “other side” and presented the other extreme point of view.
To conclude this article, I’ve presented with an idea of how to bridge the two points of view.
Fortunately, I’ve been around long enough to experience both “extreme” implementations and have come to understand that, for practical reasons, agility and an open mind is the key to success.
Therefore, whether or not you take my proposed idea for bridging the two points of view, or decide to go in a completely different way, I wish you success and remind you to always check where your overhead is - then, optimize.
Thank you very much for sticking with me.
Feel free to drop me a word in the comments section.
Thank you, Kobi