There's a conversation I've had at least a dozen times. It usually happens at a meetup, or in a Slack community, or during the "what's your stack" small talk at a conference after-party. Someone asks about our backend architecture. I say it's a monolith. And there's this pause. A brief, polite silence, like I just told them I still use a flip phone.
"Oh," they say. "Are you planning to break it up into microservices?"
No. It works fine. It deploys in under two minutes. New engineers can run it locally on day one. We ship features every week. It's not broken, so we're not fixing it.
I used to feel like I needed to justify this. Like I had to explain why we hadn't "evolved" yet. Now I just find the whole thing kind of funny. The monolith works. It's been working. And I'm pretty confident it'll keep working for a long time.
Where the Pressure Comes From
If you've been to a backend-focused conference in the last five years, you've probably noticed a pattern. A lot of the talks are about microservices. Service meshes. Event-driven architectures. Distributed tracing. Container orchestration. The speakers are usually from companies like Netflix, Uber, or Google, and they're describing systems that handle millions of requests per second across thousands of services.
These talks are interesting. I enjoy them. But they create an implicit message: this is where you should be heading. If you're not splitting services, you're behind. If you don't have a service catalog, you're doing it wrong. If your deployment doesn't involve Kubernetes, are you even serious?
The problem is context. Netflix has around 2,000 engineers working on their backend. Uber operates in hundreds of cities with real-time pricing, matching, routing, plus payments, all happening simultaneously. Google processes billions of search queries a day. These companies adopted microservices because they had specific scaling and organizational problems that monoliths couldn't solve.
Most of us don't have those problems. Most of us have teams of 5 to 30 engineers building products that serve thousands or maybe hundreds of thousands of users. The bottleneck isn't that our services can't scale independently. The bottleneck is that we have twelve things on the roadmap and six engineers.
I watched this play out at a startup I was consulting with a couple of years ago. Twelve engineers, Series A, building a B2B SaaS product. They went microservices from the start because the CTO came from a large company where microservices were the standard. Within six months, they had nine services. Plus a RabbitMQ cluster, a shared API gateway, and a deployment pipeline that took 45 minutes to run end to end. Two of their twelve engineers spent most of their time on infrastructure. Not features. Infrastructure. They were debugging message queue configurations while their competitors were shipping.
They eventually consolidated back into a monolith. It took them three months to undo what took six months to build. The CTO told me, with a very tired expression, that he wished someone had pushed back earlier.
What a Well-Structured Monolith Looks Like
When people hear "monolith," they often picture a tangled mess. Spaghetti code where changing the login screen somehow breaks the payment flow. A single file with 10,000 lines. That's not a monolith. That's a mess. And messes can happen in any architecture, microservices included.
A well-structured monolith has clear internal boundaries. Think of it like a well-organized building. It's one structure, but the rooms are clearly separated, each with a defined purpose and a door you knock on before entering.
In practice, this means:
- Clear module or package boundaries. Your codebase is divided by business domain. Users, orders, notifications, billing. Each domain has its own package or module with a defined interface. In a Spring Boot app, this might be
com.app.user,com.app.order,com.app.notification, each with their own controllers, services, and repositories. - Separation of concerns within each module. Controllers handle HTTP. Services handle business logic. Repositories handle data access. This is basic layering, and it works just as well inside a monolith as it does in a microservice.
- Internal APIs between modules. The order module doesn't reach into the user module's database tables. It calls
UserService.getById(). The interaction happens through a defined interface, not through shared mutable state or direct table access. - Encapsulation. Implementation details stay internal. Other modules depend on interfaces, not on concrete classes or database schemas.
A monolith with this kind of structure is just as organized as a microservices architecture. The only difference is that the boundaries are enforced by conventions and access modifiers instead of network calls. And honestly, that's usually enough. If your team can't maintain discipline within a single codebase, splitting it into twenty repos won't magically fix that.
The Things Monoliths Make Easy
There are real, practical advantages to keeping everything in one deployable unit. These aren't theoretical. They're things I deal with every week.
Deployment
One artifact. One pipeline. One rollback button. When we ship a new version, we build the app, then run the tests, then deploy it. If something goes wrong, we roll back to the previous version. The whole process takes a couple of minutes.
Compare this to deploying a feature that spans three microservices. You need to coordinate the deployment order. Service A needs to deploy before Service B because B depends on A's new API. But Service C also changed, and it needs B's new endpoint. So the deployment order is A, then B, then C, and if any step fails, you need to figure out which combination of versions is compatible for a rollback. That's not deployment. That's a puzzle.
Debugging
Something breaks in production. In a monolith, you have one process and one log stream. You search the logs, find the error, look at the stack trace. It tells you exactly which line of code failed. You can reproduce it locally, set a breakpoint, and step through the execution.
In a microservices architecture, a single user request might touch five services. The error shows up in Service D, but the root cause is bad data that Service A sent to Service B, which passed it along to Service C, which transformed it and sent it to Service D. Now you're correlating logs across four services, each with its own log format, hoping the correlation ID was propagated correctly. I've spent full days tracing bugs through service chains. In a monolith, the same bug would have taken twenty minutes to find.
Refactoring
You want to rename a method. In a monolith, your IDE finds every caller, you rename it, you run the tests. Ship it in one PR. Done.
In microservices, that method might be part of a public API contract between services. Renaming it means updating the API spec, changing the client in Service B, making sure Service B is deployed before Service A stops supporting the old name, or running both names in parallel during a transition period. What was a five-minute refactor in a monolith becomes a multi-day coordination effort across teams.
Testing
Integration tests in a monolith run in one process. You spin up the app, hit the endpoints, and verify the behavior. The whole test suite runs in CI without any external dependencies beyond a test database.
Integration tests across microservices require you to spin up multiple services, often with Docker Compose. I've seen test environments with Docker Compose files that define fifteen services, three databases, two message queues, plus a Redis instance. The tests take twenty minutes to start up and break constantly because of port conflicts, timing issues, or one service failing its health check.
Onboarding
New engineer joins the team. In a monolith: clone the repo, run ./gradlew bootRun, open the app. They can see the entire codebase, search across all features, and understand how things connect. By the end of day one, they've submitted their first PR.
In a microservices setup: "Here's our service catalog. We have 40 repos. There's a wiki page that explains how they connect, but it's a bit outdated. You'll need to clone services A, B, and C to work on this feature. Oh, and you'll need to run the API gateway locally too. And the auth service. Here's the Docker Compose file, but it doesn't include the latest changes to Service F, so you'll need to build that one from source." By the end of day one, they're still setting up their environment.
Local Development
The monolith just runs. You start it, and you have the whole application. No service mesh simulator. No local Kubernetes cluster. No "which combination of service versions is compatible with my branch" spreadsheet. It just runs.
The Things Microservices Make Hard
Microservices don't just add complexity in theory. They introduce entire categories of problems that don't exist in monoliths.
Network Failures
In a monolith, when module A calls module B, it's a function call. It either works or it throws an exception. It takes microseconds. It never times out. It never returns a 503. The connection never drops halfway through.
In microservices, every inter-service call goes over the network. Networks are unreliable. So now you need retry logic. You need timeouts. You need circuit breakers so that one slow service doesn't cascade failures across the entire system. You need to decide what to do when a service is temporarily unavailable. Should the request fail? Should it wait? Should it use cached data? Every single inter-service call needs answers to these questions. These are real engineering problems, but they're problems that only exist because you introduced a network boundary.
Data Consistency
In a monolith with a single database, you can wrap multiple operations in a transaction. Deduct inventory and create the order and charge the customer. If any step fails, everything rolls back. ACID guarantees. Simple.
In microservices, each service owns its own database (if you're doing it right). So you can't use database transactions across services. Now you need sagas, or choreography, or some form of eventual consistency. "The order was created, but the inventory service hasn't confirmed the deduction yet. It will. Probably. Eventually." This is a valid architectural pattern, but it's much harder to reason about, harder to debug, and harder to test than a database transaction.
Distributed Tracing
Following a request through five services means you need distributed tracing infrastructure. Jaeger, Zipkin, or something similar. Every service needs to propagate trace IDs. Every service needs to instrument its HTTP clients and handlers. You need a dashboard to visualize the traces. You need to maintain all of this. It's an entire sub-project just to have the same level of observability that a monolith gives you for free with a stack trace.
Deployment Coordination
"Service A needs version 2.3 of Service B's API, but Service B hasn't deployed yet." This sentence should not exist in your workflow, but in microservices it comes up all the time. Backward-compatible API changes, API versioning strategies, contract testing between services. All of this is overhead that exists solely because you split the code into separate deployables.
Cognitive Overhead
Understanding a monolith means reading one codebase. Understanding a microservices architecture means understanding thirty codebases and how they interact. The system behavior isn't in any single repo. It's in the communication patterns between repos. It lives in the message queue configurations, the API contracts, the event schemas, plus the deployment order. That's a lot of context to keep in your head.
When Microservices Actually Make Sense
I'm not saying microservices are bad. They solve real problems. Just not the problems most teams have.
Microservices make sense when:
- Your team is large enough that people block each other. If you have 50+ engineers and pull requests in the same repo are constantly conflicting, separate repos with separate deployment pipelines can help teams move independently. This is an organizational problem, and microservices are an organizational solution.
- You have domains with very different scaling needs. Your image processing service needs GPU instances and scales based on upload volume. Your API server needs CPU and scales based on request count. Running these as separate services with separate infrastructure makes sense. Running your user service and your notification service on different scaling profiles probably doesn't.
- Teams need different tech stacks. The machine learning team works in Python. API folks work in Java. Real-time messaging happens in Go. If different parts of your system actually benefit from different languages or frameworks, microservices let each team use what works best.
- Deployment frequencies are vastly different. Your core API changes twice a week. Your billing integration changes once a quarter. If deploying the billing code forces you to test and release the entire application, separating it might save time. But only if the deployment overhead of managing a separate service is less than the testing overhead of deploying together.
Notice a pattern here. These are all problems of scale. Scale of team, scale of traffic, scale of complexity. If you don't have these problems, microservices are a solution looking for a question.
Your Architecture Should Match Your Problems
The best architecture isn't the one that looks the most impressive on a conference slide. It's the one that lets your team ship features reliably without spending half their time fighting infrastructure.
For a team of 15 engineers building a product that serves 100,000 users, a well-structured monolith is almost certainly the right call. It's easy to deploy, easy to debug, and new engineers can be productive on day one. Those properties matter more than theoretical scalability you might need in two years.
If you grow to a point where the monolith actually becomes a bottleneck, you can split it then. And if your monolith has clean module boundaries, splitting out a service is a mechanical job. You already have the interfaces defined. You just move a module into its own repo and put an HTTP layer in front of it.
Going the other direction is much harder. Merging microservices back into a monolith is painful, messy, and slow. I know this because I've done it, and because I watched that startup do it.
Nobody's users have ever said "I love this app because the backend uses microservices." They care about whether it's fast, whether it works, and whether the features they need are there. Pick the architecture that lets you deliver those things with the team you have, not the team you imagine having someday.
The boring choice that ships features will always beat the exciting choice that creates infrastructure problems. And there's nothing boring about a codebase that's clean, fast, and easy to work with. That's just good engineering.
Comments (0)