The microservices fallacy - Part 9
In this post we take a look at moduliths as an alternative to microservices.
In the previous post we started to make sense of what we have learned from this blog series so far. We discussed when microservices are a sensible choice and what other conditions must be met to harvest their benefits and not only experience their downsides.
We then started to discuss what to do if the required preconditions for microservices are not met, and I wrote that I will discuss two alternative architectural styles in more details. The first alternative are moduliths which are the topic of this post.
- do not need to move fast and/or do not have multiple teams (and exponential scaling is not in sight)
- and do not have any special runtime requirements in terms of very disparate NFRs,
a deployment monolith is the probably simplest runtime architecture possible.
They key problem of a deployment monolith is that its internal structure tends to deteriorate over time which makes evolution and maintenance a nightmare. Yet, this is not a “given” of a deployment monolith, even if we experience it so often. This is rather a question of good design and implementation discipline.
Hence, it would be an option to go for a well-structured monolith, a “modulith”, a monolith consisting of clearly defined and isolated modules. Still, “well-structured” does not come for free.
First, it requires decent design skills to find the right module boundaries and contents, to decide which functional concept to implement where and how to expose it to the outside.
Second, it requires that everyone adheres to the rules, not to violate module encapsulation, no matter if accidentally or deliberately. This especially means to access modules only through their official interfaces – also if you are in a hurry to deliver a feature and you could save time by cutting corners, i.e., by bypassing module interfaces.
These are the hard parts – getting the design right and then adhering to it over time. This is also the reason why we see so few well-structured monoliths. Good design and implementation rigor are so often sacrificed for some arbitrary minor short term goal.
For implementation rigor sometimes some “hacks” are useful that make it harder to violate the agreed upon design. E.g., in a Java world you could put every module in a separate JAR file that is developed in a different project and assemble the modulith from them at build time.
People might complain that this would make testing harder, would make code tracing harder, and so on – all the things that are so easy from within your IDE if you work with a weakly structured monolith.
Well, while code tracing across modules is still supported by most languages, just requiring some extra configuration work, remember that the proposed alternative are microservices. With microservices all this is impossible by default and nobody complains. Quite the contrary, most people yearn for them even if they do not need them.
Additionally, if it makes testing noticeably harder it is a sign of weak design. Good design results in weakly coupled modules, also from a functional stance. Thus, you should be able to test the modules in isolation and apply test techniques like contract-driven testing. If isolated testing is not possible, it is a sign that you should revisit your design.
Furthermore, the need to trace code across module boundaries usually is a sign of poor interface documentation. If I use some other functionality, no matter if provided as library, service, JAR or however, I do not want to read the code of it. I want to read the contract and then know how to use it and what to expect from it. Period. If usage or behavior are unclear, either the documentation or the interface design itself is insufficient. 1
This is also true if you need to debug code across module boundaries: The need to debug code across module boundaries is a sign that your interface documentation is either insufficient (the expected behavior and results do not become clear from it) or wrong (the behavior or results differ from the description). Thus, while still being possible, with proper interface documentation debugging should not require to cross module boundaries.
Technology updates with moduliths
We still have the issue of technology updates. Even if they are not needed often, they can become hard for a really big deployment monolith – but only if the functional design is bad.
The internal functional design controls how hard it is to replace a piece of software with a new technology. The more tightly interwoven the internal parts are, the harder it becomes, the weaker they are coupled, the easier it becomes.
If your internal design is driven by reusability, you typically end up with lots of modules (re-)using each other. If you do it well, you have an internal layered module structure where each module of a higher layer builds on the functionalities provided by the layer below. 2
If your new technology can call the old technology in such a setting, you can replace the application top down. If the old technology can call the new technology, you can replace it bottom up. If both directions work, you are free to decide.
Yet, if you do not create a good layering but an unstructured mess of tightly coupled modules, any technology update will become hard.
Still, be glad then to have a deployment monolith and not microservices. In the context of microservices unstructured tight coupling would mean a distributed ball of mud which is the worst of all worlds: Hard to maintain, hard to change, hard to deploy, brittle at runtime and hard to operate. With a weakly structured deployment monolith at least deployment, robustness and operations at runtime would be better.
The key point is technology updates do not become hard due to the monolithic runtime structure, but due to deficiencies of the internal functional design and implementation.
Another option is to drive the internal design by mutual independence. This leads to a use case driven modularization where the different modules encapsulate whole use cases or user interactions: They handle external requests end-to-end without having to call other modules. Visually speaking, instead of layers you have pillars. In such a design, you can replace the system module by module because the modules are mutually independent. 3
Still, even with a design driven by mutual independence, you will still have functionalities where it economically makes sense to create them as reusable parts instead of recreating them for every module. For these functionalities, we are back to design driven by reusability which we already discussed.
Summarizing, it can be said that technology updates will probably be a bit more complex with moduliths than with microservices. But a good functional design can keep the additional efforts small. Often it is worth to pay this little extra price because on the other side you avoid all the additional complexities that come with microservices.
Another potential concern regarding moduliths is team coordination. Does a modulith not require a lot more team coordination that microservices?
As I discussed in part 4 of this blog series, team autonomy is a common microservices fallacy. Microservices do not magically give you team autonomy. You first need team autonomy on an organizational and functional level that then is supported by microservices on a technical level. Additionally, you need a functional design that also supports team autonomy, i.e., weak functional dependencies between services. Otherwise, autonomy will remain a vague dream.
The same is basically true for moduliths. Without a proper organizational setup and functional module architecture, all autonomy expectations are vain.
As moduliths are a form of a deployment monolith, you additionally need some extra measures to free the teams from deployment coordination needs. With the right technical and tooling setup you can reduce the coordination effort basically to the same level as with microservices: The build pipeline simply triggers a deploy as soon as any team releases a new version of a module – for optimization purposes, some micro-batching (e.g., waiting for 10 min before triggering a build or alike to gather nearby releases in one build) is possible.
This way, e.g., Etsy had more than 50 releases of their deployment monolith to production per day without any special team coordination. Still, this means more effort than most companies usually spend in team independence when setting up a deployment monolith. So, it definitely does not come for free – and if not done, it will result in higher team coordination efforts. But that is not due to the modulith, but due to homework not done.
Another question that sometimes comes up is if moduliths work with a “You build it, you run it” approach (YBIYRI) as it often is advertised for microservices. 4
YBIYRI definitely is harder to implement with a modulith. I am not sure if it would make a lot of sense having many independent teams being responsible for separate parts of a deployment monolith. Instead, you could go for a SRE approach (Site Reliability Engineering) as developed initially by Google and meanwhile picked up by many companies around the world.
SRE is especially popular in companies that do not want to completely merge Dev and Ops in cross-functional teams, but keep some basic separation between Dev and Ops while sharing the respective responsibilities between both parties. SRE in its core helps to balance innovation speed and operational robustness. This way it has a similar goal like YBIYRI with dissolved Dev and Ops departments but achieves it in a different way.
So, while moduliths are not so well suited for the “pure” YBIYRI approach with autonomous teams, each incorporating all required Dev and Ops skills, they work well with a SRE approach that in its core implements the same goal.
Having written this, I think a modulith is especially a more sensible alternative for companies that still implement a strict Dev/Ops separation and introduce microservices for the wrong reasons. In such a setting, usually neither YBIYRI nor SRE are a topic: Dev is responsible and incentivised for innovation speed, Ops for production robustness. If you find such a separation of responsibilities, going for moduliths can save you from a lot of harm.
In a more advanced company culture which already works with actual cross-functional teams (including all required skills like,e.g., Biz, Dev, Sec, Ops, …), each team being responsible for a dedicated business capability and using approaches like YBIYRI, I think the microliths, being discussed in the next post are the more natural approach.
If you do not need to go fast and/or do not have multiple teams and do not have very disparate NFRs for different parts of your application, a modulith can be a sensible alternative to microservices. It offers most advantages of microservices like better maintainability and evolvability, team independence and more while not exhibiting the intricacies of microservices at runtime. A modulith is still a deployment monolith, i.e., one of the simplest runtimes architectures available.
A modulith requires advanced functional design skills and strict implementation rigor – two properties that unfortunately are not seen very often in most companies. If you do not have the two skills in your company, most likely you will up with the dreaded big-ball-of-mud-type monolith.
Yet, this is still better than a distributed big ball of mud that you would end up with if you were going for microservices while lacking the two aforementioned skills.
In the next post of this series, we will take a closer look at the second alternative I want to discuss: Microliths. Additionally, we will look at some problems that you might face if you decide for one of the discussed styles and what you can do about it. Stay tuned …
[Update] February 1, 2021: Added some more details regarding team coordination and “you build it, you run it” settings, based on a short discussion I had with Martin Grotzke on LinkedIn.
I will discuss interface design and documentation in more detail in future posts. Right here I only want to stress the fact that you waste a lot of time and money if you fail to document your interfaces properly. Every user of your module has to read and understand your code arduously trying to figure out the module contract because you failed to document it. Thus, the time and effort you saved will be paid a hundred times or more later by the poor people who have to use your module. ↩︎
This is not to be confused with the dreadful, widespread “layered architecture” anti-pattern where data access, business logic and presentation are considered “layers”. This rather resembles a layering concept like the ISO/OSI networking framework where each layer builds on the API of the layer below, adds functionality and provides a higher level API to the next layer above. I will discuss in a future post why the widespread implementation of “layered architecture” with data access, business logic and presentation layer (or alike) is an anti-pattern. ↩︎
“Advertised” is meant here in the way that “You build it, you run it” often is used as sort of an advantage of microservices. It does not imply any valuation regarding the approach. ↩︎