D
Dua Amjad
Guest
For the last decade, the software development community has been dogmatically indoctrinated that in order to build scalable applications, one has to utilize microservices as the default architectural style. Thought leaders in the space have preached about the benefits of breaking down applications into sets of decoupled, independently-deployable units. Software engineering bootcamps have conditioned the youth to think decoupling == scaling. Cloud vendors have made a killing promoting and monetizing the microservices movement. Startups have mindlessly jumped on the bandwagon.
But for a small team building an early-stage product, a microservices-based architecture represents an enormous distraction and overhead that detracts from their ability to deliver value to customers. The pendulum is now swinging back to the other extreme and we're witnessing the dawn of what is being called as the Monolith Renaissance.
The whole point of microservices is to allow large-scale organizations to decouple development and deployment of different services in order to accelerate time-to-market. It works exceptionally well for companies with over 100 people in their engineering orgs and product teams that are otherwise completely decoupled. But applying the same approach within a smaller team of 5 or 10 underwhelms the engineering team with enormous complexity and creates what I call the Microservice Tax.
Whenever you have to make a change that cuts across multiple services, you have to spend significantly more time and effort coordinating those changes than you would have in a monolith. Here are some concrete examples:
When you need to perform operations across services you lose the comfort and reliability of synchronous in-memory communication and instead have to implement distributed patterns such as retries on links or circuit-breakers.
To maintain distributed data consistency when you have multiple DBs per service, you either have to use distributed transactions or implement compensating transactions which adds enormous complexity to your system.
Instead of having one reliable process for deployment, your team now has to reason about multiple API gateways, Kubernetes clusters, and service meshes just to get a single feature working. Instead of having a single process that your team can reason about, you now have dozens of them to debug.
The alternative to the distributed tangle of microservices is not a single amorphous blob of spaghetti code but a properly architected modular monolith. In theory, a modular monolith shares some of the benefits of microservices while avoiding the downsides. A modular monolith is a single code base, that is deployed as a single binary, that has a shared backend database, but is internally broken up into distinct modules. It is the best of both worlds except for the minor detail that it is, in practice, extremely difficult to maintain.
The major players in the tech space have been reaping the benefits of the modular monolith for years. Companies such as Basecamp and Shopify have famously moved away from serverless and microservices back to the monolith and cut down their infrastructure costs by an order of magnitude while accelerating their development process. The Prime Video team at Amazon has been doing the same which is notable given that the company as a whole is otherwise a massive proponent of microservices.
If you want to get started with a modular monolith today, here's how you'd structure it.
It's important to separate tables for each of your modules even if you're using a single database instance. For example, have billing/invoices and identity/users rather than a single public schema. This separation creates natural code boundaries within your organization. And while modules can depend on other modules, you must forbid any cross-schema SQL JOINs. If the billing module needs access to the users table, you'd have to implement a database-level foreign key constraint rather than allowing it to perform a JOIN at the SQL layer directly.
Rather than having modules depend on each other directly, use an internal event bus for communication. Whenever the Identity module needs to signal to other modules that a new user has been created, rather than importing classes from the Notification module directly, it would publish an event to the internal event bus.
Then, the Notification module and other interested parties can subscribe to events published on the bus rather than having to directly import code from one another. This allows you to decouple modules at a later point if and when you need to extract them out into standalone processes.
If you need to access functionality exposed by another module, always go through its public facade rather than accessing its internals directly. That way, if you ever need to decouple modules, you can do so without having to rewrite all of your internal method calls.
Here's a quick reference matrix to help you decide which architecture to pick based on your situation.
Engineering team size: 1 to 20 developers
Product/market fit: Product is shifting; frequent MVP iterations
Different deployment cadence: Entire application is deployed on the same schedule
Critical engineering costs: Vertical scaling of standard infrastructure
Microservices architecture fits when:
Engineering team size: 50+ developers, split into feature teams
Product/market fit: Clear domain boundaries
Different deployment cadence: Different features require different infrastructure (e.g. Python for data science, Java for streaming)
Critical engineering costs: High DevOps headcount
Your end-users don't care whether you're using a microservices-based architecture or a modular monolith. They don't care if your application runs on Kubernetes or a single virtual machine. They don't care if you're using fancy distributed patterns or a simple request/response paradigm. All they care about is whether the application performs well, delivers features they care about, and protects their data.
Don't fall for the false dogma that microservices are inherently better. Avoid getting sucked into the hype around distributed systems. If you're a small team, you should prioritize building your product over building distributed infrastructure. Focus your scarce engineering resources on engineering your product, not your platform.
But for a small team building an early-stage product, a microservices-based architecture represents an enormous distraction and overhead that detracts from their ability to deliver value to customers. The pendulum is now swinging back to the other extreme and we're witnessing the dawn of what is being called as the Monolith Renaissance.
The Microservice Tax: What They Didn't Tell You
The whole point of microservices is to allow large-scale organizations to decouple development and deployment of different services in order to accelerate time-to-market. It works exceptionally well for companies with over 100 people in their engineering orgs and product teams that are otherwise completely decoupled. But applying the same approach within a smaller team of 5 or 10 underwhelms the engineering team with enormous complexity and creates what I call the Microservice Tax.
Whenever you have to make a change that cuts across multiple services, you have to spend significantly more time and effort coordinating those changes than you would have in a monolith. Here are some concrete examples:
1. Network Latency and Distributed Fallback
When you need to perform operations across services you lose the comfort and reliability of synchronous in-memory communication and instead have to implement distributed patterns such as retries on links or circuit-breakers.
2. Distributed Data Consistency
To maintain distributed data consistency when you have multiple DBs per service, you either have to use distributed transactions or implement compensating transactions which adds enormous complexity to your system.
3. Additional Operational and Observability Overhead
Instead of having one reliable process for deployment, your team now has to reason about multiple API gateways, Kubernetes clusters, and service meshes just to get a single feature working. Instead of having a single process that your team can reason about, you now have dozens of them to debug.
🏗 The Majestic Modular Monolith
The alternative to the distributed tangle of microservices is not a single amorphous blob of spaghetti code but a properly architected modular monolith. In theory, a modular monolith shares some of the benefits of microservices while avoiding the downsides. A modular monolith is a single code base, that is deployed as a single binary, that has a shared backend database, but is internally broken up into distinct modules. It is the best of both worlds except for the minor detail that it is, in practice, extremely difficult to maintain.
The major players in the tech space have been reaping the benefits of the modular monolith for years. Companies such as Basecamp and Shopify have famously moved away from serverless and microservices back to the monolith and cut down their infrastructure costs by an order of magnitude while accelerating their development process. The Prime Video team at Amazon has been doing the same which is notable given that the company as a whole is otherwise a massive proponent of microservices.
If you want to get started with a modular monolith today, here's how you'd structure it.
Schema separation for databases
It's important to separate tables for each of your modules even if you're using a single database instance. For example, have billing/invoices and identity/users rather than a single public schema. This separation creates natural code boundaries within your organization. And while modules can depend on other modules, you must forbid any cross-schema SQL JOINs. If the billing module needs access to the users table, you'd have to implement a database-level foreign key constraint rather than allowing it to perform a JOIN at the SQL layer directly.
Internal event bus for loose coupling
Rather than having modules depend on each other directly, use an internal event bus for communication. Whenever the Identity module needs to signal to other modules that a new user has been created, rather than importing classes from the Notification module directly, it would publish an event to the internal event bus.
Code:
# Event emission is entirely in-memory at this point: no Kafka or RabbitMQ required yet
event_bus.publish("user.registered", {"user_id": 42, "email": "[email protected]"})
Use code with caution
Then, the Notification module and other interested parties can subscribe to events published on the bus rather than having to directly import code from one another. This allows you to decouple modules at a later point if and when you need to extract them out into standalone processes.
Public facades for APIs
If you need to access functionality exposed by another module, always go through its public facade rather than accessing its internals directly. That way, if you ever need to decouple modules, you can do so without having to rewrite all of your internal method calls.
Architectural Decision Matrix
Here's a quick reference matrix to help you decide which architecture to pick based on your situation.
Engineering team size: 1 to 20 developers
Product/market fit: Product is shifting; frequent MVP iterations
Different deployment cadence: Entire application is deployed on the same schedule
Critical engineering costs: Vertical scaling of standard infrastructure
Microservices architecture fits when:
Engineering team size: 50+ developers, split into feature teams
Product/market fit: Clear domain boundaries
Different deployment cadence: Different features require different infrastructure (e.g. Python for data science, Java for streaming)
Critical engineering costs: High DevOps headcount
Final Thoughts
Your end-users don't care whether you're using a microservices-based architecture or a modular monolith. They don't care if your application runs on Kubernetes or a single virtual machine. They don't care if you're using fancy distributed patterns or a simple request/response paradigm. All they care about is whether the application performs well, delivers features they care about, and protects their data.
Don't fall for the false dogma that microservices are inherently better. Avoid getting sucked into the hype around distributed systems. If you're a small team, you should prioritize building your product over building distributed infrastructure. Focus your scarce engineering resources on engineering your product, not your platform.