Modular monoliths
By Bjørn Borud
The idea behind microservices is not entirely bad, but what can cause problems is when developers blindly adopt orthodoxy without thinking. A way to have it both ways is to adopt the idea of modular monoliths and to care about software plasticity.
Naive approach
Imagine you are designing a system. You have identified three distinct areas of responsibility and you have now decided that those map to three different microservices that use each other’s APIs. You start out by creating three projects, each implementing their own microservice. Each with its own API. In addition you create a frontend as it own project. It may be that only the frontend touches the microservice or that they speak to each other as well.
Since it is early in the project it isn’t entirely clear what the API of each service is going to look like. Your chief architect has drafted a set of APIs, but you soon discover that those APIs were, at best, an initial guess. The APIs have to evolve as you gain insight into exactly what each microservice has to do and how. You may even discover that key assumptions you started with were entirely wrong. Perhaps even making the division into areas of responsibility unclear or strained.
Every time you make a change to the API, you risk breaking the other three projects. So you spend some time to figuring out a versioning scheme for your APIs - or you don’t. Versioning APIs is a lot of work because not only does the API have to be versioned, your business logic has to be versioned as well. You can’t just drop old behaviors as long as there are clients that depend on the behavior, so now you get to maintain that too.
If you have several people working on each microservice, with code residing in different branches, and depending on various permutations of the APIs of the other two microservices. Managing tests becomes arduous. Do you create mocks or maintain a machinery for instantiating the right versions of services you depend on? How fast will you be able to perform those tests? (Lack of automation probably means you won’t run them on every build while developing - as you should. Which ends up impacting quality)
This approach has the potential to create a lot of busy-work as you fight to keep everything in sync and to create a lot of machinery to manage versions of services that have roughly interoperable APIs.
Modular monolith
Microservices dogma teaches you that monoliths are the root of all evil. This is, at best, misleading oversimplification. True, if you write software that has poor internal abstractions, turning a monolith into microservices when this makes sense, can be prohibitively costly.
If we take the above example, you may want to start with all three areas of responsibility in the same server application. However, you keep in mind that you want to separate the areas of responsibility. Your mental model is one where you think of each of the parts as a microservice - but you do not necessarily expose their interaction as external APIs (REST, gRPC, Websockets, MQTT etc)
Especially in the initial phase, this not only forces developers to work more closely, it also eliminates the need to manage running several instances of services to perform integration testing. If you can turn a slow or complex integration test into a fast-running unit test, that’s ideal.
Maintain plasticity as long as possible
Software tends to change dramatically during its initial development phase. This is because whatever you think you know before you start: you will know a lot more once you start implementing. A lot of initial assumptions will be wrong. That’s okay. It is normal. How you deal with it makes a difference. You want to ensure a low threshold for fundamental changes early on. You want to keep the cost of change down - to keep the plasticity of the project high.
If, for instance you discover that the initial delineation of responsibilities was wrong, and you need to move some responsibility from one microservice to another, that’s going to be a big deal if you do the whole microservices thing. You don’t want to do that unless you know that it is going to be worth changing 2-3 other services as well to accomodate the change.
Notice how you dread the idea of having to change lots of other services in separate source repositories, possibly being ruled over by a different clan? Do you want to? Of course not. Unless it is absolutely necessary. That’s a bad place to be early on in a project. If you didn’t feel that dread - perhaps you shouldn’t make decisions that affect other developers.
If you are just moving stuff around inside a monolith, and both ends of the API are in the same codebase, this may be just a simple refactoring exercise that can be done quickly in one branch - rather than being spread across branches in 4 repositories. If you have made good internal API design, it may not even manifest as an externally observable change to, for instance, the frontend.
Put another way: you want to maximize the plasicity of a project for as long as you can.
When to separate things out?
I don’t have any good answer to when you should split up your project into multiple microservices. Personally, I think you can actually do this even if maintaining a monolithic codebase. The way to do this is to make it possible to run the same binary, but in different modes.
Say you have a search engine and you want to have a query dispatch node at the top, and search nodes below the dispatch. You could start the search nodes with parameters that tell them to be search nodes. And the dispatch is started as a dispatch node. You don’t necessarily have to split a project into lots of binaries unless there are compelling technical reasons to do so.
Sometimes it can be beneficial to run multiple services within the same binary and just ignore the fact that it is the same process. For instance when you have a set of tightly coupled services that are going to be upgraded together anyway - you just saved yourself a bit of management work.
Usually you want to split things up whenever running the services as separate services has identifiable value. For instance if you need to have multiple instances of one service to achieve scaling (both horizontal and vertical) or when you need more redundancy.
If you need neither scaling, nor extra robustness and your services all get upgraded at the same time anyway, you had no need for microservices in the first place. This is surprisingly often the case.
There are a lot of projects I have worked on the past decade where the plan was always to eventually split a system up into a bunch of microservices, but where the need never actually materialized. That’s a good thing. It means we didn’t do a lot of work in vain.
How to separate things out
The internal APIs of your modular monoliths must be designed with microservices in mind. If you did a good job, you may only have to slap a network API on top, or separate out the one you already have, add some initialization, perhaps copy some common functionality, manage startup, and you should be done.
If you don’t have any experience with breaking up monoliths before, practice. You don’t become a good software designer by reading books or thinking about it. You become a good software designer by learning from practice. (Yeah, I know this kind of undermines people who make a living writing software architecture books, but they are usually what landed you in a mess where you have to learn about software from a grumpy old fart like me).
Make a hobby project, then break it into microservices. It may be hard the first few times as you learn what kinds of design decisions can come back to haunt you. Breaking out a well designed piece of your monolith into a microservice should ideally be a very small task. If it isn’t: practice, figure out what trips you up, keep it in mind next time you design a service.
If you know that one reason to split things into multiple microservices, or to just add support for running lots of instances of the same microservice, you need to think about how you are going to coordinate them from the beginning. Make sure the design allows you to coordinate them the way you want. Be it as services in a kubernetes cluster or by bringing your own discovery and consensus functionality.
Closing thoughts
A lot of people design things a certain way not because they have given it much thought, but because of peer pressure. Some programmers work as if there’s an imaginary panel of hip developers judging their work. The truth is that almost nobody cares about your work. Most of the time, only you do.
Yes, people will ask you why you do something a certain way if you do something differently. Assume it isn’t criticizm, but genuine interest. And if they do criticize you for not sticking to some orthodoxy they subscribe to, don’t be so sure their opinion actually matters.