Microservices Are Not for You
Microservices are one of those concepts in webdev that has gained a life all its own, and is now considered a best practice by the industry, the preferred way to build new apps. In my experience, this is especially true for NodeJS developers, but it’s definitely not limited to them.
I disagree with this position.
What is a Microservice?
To keep things simple, we’re going to use a simple definition of microservice:
A microservice is an independently-running piece of a larger software app that handles one specific domain of tasks.
This definition implies a few things:
- Independently-running means that it’s not tied directly into the rest of the app. It has its own hosting, it can exist and run on its own. It might not do much, it might be absolutely worthless on its own, but it can exist without the rest of the app.
- It has to be part of a larger app, it can’t be the entire app in and of itself.
- One specific domain of tasks means that it can do multiple things, but they all should be conceptually similar.
- The classic example of this is an authentication microservice; it would handle logins, but also password resets, and potentially registrations as well.
I will not be giving a detailed example of what a full microservice architecture looks like; utilize your Google-fu and read some of the plethora of articles that have already been written on the topic.
Why Do Microservices Exist?
Microservices exist to solve scaling problems. While the rough protoconcepts have been around since the late 90s, microservices didn’t become popular until the mid 2010s, when Netflix and other major tech companies started incorporating them into their toolkits and writing about it. Much like React, Hadoop, Chaos Engineering, and Functions as a Service, the major adopters were doing so because they were dealing with significant issues at the scale they were operating on.
Scaling problems here cover multiple different issues. Scaling developer efforts with massive codebases. Scaling ops effort for deployments, monitoring, and incident response. Scaling infrastructure costs. All of these are issues that a large company like Netflix experiences. And microservices help with that.
With microservices, you have a larger overall app/system that does things, but the individual pieces are broken out into separate components. This allows your developers to narrow their focus to a particular service when making changes and bugfixes. Since the different microservices communicate purely via API calls, there are natural boundaries that limit how far changes can ripple through the system as a whole.
This also allows operations to keep better track of what pieces are doing what, more easily trace where problems are originating from, more easily deploy updates to specific components of the overall system, and scale up the pieces that need more capability while avoiding spending extra on scaling pieces that don’t matter.
All of this sounds great! But as with most things, it comes at a cost. That cost is complexity.
Complexity Costs
A complex system is more difficult to reason about, more difficult to explain, and requires more engineering effort to build and maintain.
Properly implemented microservices will have independent codebases from each other. They’ll only communicate through an API interface layer. That immediately imposes context switching overhead on any developer that needs to move between microservices, because they don’t have any guarantees of what is where and which sections of code provide what functionality.
That interface layer also has code complexity costs, as well as efficiency costs.
If your authentication code lives in the same monolithic app as the rest of your code, user login is just a function call or two. Simple, straightforward, very efficient in terms of machine resource usage.
If your authentication code lives in a microservice, you now have to make an API call, typically REST, which means you have to setup a TLS connection, encode the provided credentials as a payload (typically JSON), send that over the wire, the auth service then has to decode the payload, validate the API call as a whole, then validate the credentials, encode the response (again, typically JSON), send that response, and the original client now has to decode that response, validate it, and then continue on with its logic.
That’s a whole lot more steps to get the same answer of “Y/N” to the question, “Is the user authenticated?”, and every part of it has to be implemented as code. Libraries can help, but that doesn’t get past the efficiency cost.
Operation Costs
Assuming you can get past the context switching, resource efficiency, and service implementation costs, you’re still not out of the woods. Your microservices have to live somewhere. Code is worthless if it’s not running on somebody’s hardware. And that means operation costs.
Setting up a service to run on a host requires effort. Docker Compose is helpful for getting a collection of services running together on someone’s machine without much trouble, but that doesn’t cut it for a production hosting setup. You need monitoring, backups, DNS entries, TLS termination, load balancing, service discovery, as well as the ability to scale up those services when needed.
For example, I’m currently looking at setting up Grafana Mimir for handling some monitoring needs in our Nomad cluster. They recommend that you run it as a collection of microservices in production, for maximum flexibility of scaling the individual components in the system. But they also have a monolith version of it that just runs everything together as a single container, because they recognize how much more complex setting up a collection of microservices can be. I intend to use that monolith version, and switch if we encounter scaling issues in the future (spoiler: we won’t).
Why? Because 1) setting up a jobspec for running ten different components is far more effort than setting up a jobspec for running 1 component (and imposes long-term maintenance costs when needing to modify the jobspec), and 2) because we just don’t need the scaling granularity that the microservices deployment provides.
And that’s the crux of it. The overwhelming majority of organizations simply do not need to worry about granular scalability. You’re not going to be that big. You’re not going to be handling that level of traffic. 90% of organizations would be perfectly fine with a 2 component architecture: the main app that handles your user requests, and is designed to be scaled out horizontally to deal with increased request load as-needed, and then a task worker component that handles asynchronous tasks and can be scaled up independently if your task load spikes.
Microservices were adopted at Netflix, AWS, and other tech giants because they needed that granularity. You and I do not.
Conclusion
Microservices, much like over-engineered frameworks, big data pipelines, and Jupyter notebook playbooks, have been adopted by the industry via cargo-cult architecture design. “If it’s good enough for Google, it’s good enough for us” completely ignores the reasons that Google chooses to do things, and imposes significant costs that most of us simply do not need to pay.
Microservices have a role to play in modern system design, and are a valid architecture, but you need to be conscious of the burdens and costs you’re taking on by doing things with microservices. It should never be your default, instead only being transitioned to when a particular component of your app is experiencing scaling difficulties. And that requires you to just build the app and instrument it before prematurely optimizing your architecture for problems that probably won’t arise.