Monolith vs Microservices — 60% Spent on Service Calls
Developers spent 60% of time on service-to-service communication instead of business logic.
20+ years shipping large-scale distributed systems. Lessons pulled from things that broke in production.
- A monolith is one deployable unit; microservices are independent services that communicate over a network.
- Monoliths win when your team is small, your domain is well-understood, and you need speed of change.
- Microservices win when you need independent scaling, polyglot stacks, or separate deploy cycles per team.
- The wrong choice adds 10x operational complexity: network latency, distributed tracing, and eventual consistency headaches.
- Most production failures come from premature splitting – adding service boundaries before the domain boundaries are clear.
- The modular monolith is the pragmatic third option: enforced boundaries without network overhead.
Imagine a Swiss Army knife — one tool with every blade built in. That's a monolith. Now imagine a professional kitchen where a different chef handles every dish — the pastry chef, the grill chef, the saucier. Each expert does one thing brilliantly and they communicate through the head waiter. That's microservices. Neither is better. The Swiss Army knife is perfect for camping. The professional kitchen is perfect for a five-star restaurant. The mistake is using one when you need the other.
Every system design interview, every startup pitch deck, every 2am engineering debate eventually arrives at the same crossroads: should we build one big application or break it into small, independent services? This question isn't academic — the wrong answer has killed products, burned engineering teams, and wasted millions of dollars in cloud bills. Netflix famously spent years migrating from a monolith to microservices. Amazon did it in the early 2000s and invented AWS partly because of what they learned. The architecture you choose on day one will shape your team structure, your deployment pipeline, your on-call rotation, and your ability to hire. It matters enormously.
The core problem both architectures are trying to solve is the same: how do you build software that can grow without collapsing under its own weight? A monolith solves this by keeping everything in one place — simple to reason about, easy to test, one deployment. Microservices solve it by drawing hard boundaries — each service owns its own data, its own deployment, its own failure mode. The tension between them is really a tension between simplicity now and flexibility later.
By the end of this article you'll understand exactly how each architecture works under the hood, you'll see real code patterns that show the day-to-day differences, you'll have a concrete decision framework you can apply to your own project, and you'll be able to answer the tough system design interview questions that trip up even experienced engineers.
What Is Monolith vs Microservices?
Monolith and microservices are two ends of a spectrum. A monolith packages all logic into one deployable unit. Microservices split that logic into independent processes that talk over a network. But the real difference isn't the number of services – it's about how you manage dependencies.
In a monolith, modules communicate via method calls within the same process. That's fast – sub-microsecond latency. In microservices, they communicate over HTTP or message queues – milliseconds at best. That latency gap forces you to think differently about data, consistency, and failure.
Here's what that looks like in code. A monolith's order processing might call inventory directly:
```java // io.thecodeforge.monolith.OrderService public class OrderService { private final InventoryService inventory = new InventoryService();
public void placeOrder(Order order) { if (!inventory.hasStock(order.getProductId(), order.getQuantity())) { throw new InsufficientStockException(order.getProductId()); } // save order } } ```
In a microservices world, the same call becomes a network request:
```java // io.thecodeforge.microservices.order.OrderService public class OrderService { private final RestTemplate restTemplate = new RestTemplate();
public void placeOrder(Order order) { ResponseEntity<StockResponse> response = restTemplate.getForEntity( "http://inventory-service/api/stock/{productId}?quantity={qty}", StockResponse.class, order.getProductId(), order.getQuantity() ); if (!response.getBody().hasStock()) { throw new InsufficientStockException(order.getProductId()); } // save order } } ```
The monolith version is simpler, but the microservices version is more resilient – the inventory service can fail independently. That trade-off appears everywhere.
The Monolith: When One Box Is Enough
A monolith is a single deployable unit containing all the application's logic. It's the simplest architecture: you have one codebase, one build pipeline, one deployment. All modules – user management, inventory, payments – run in the same process and communicate via method calls.
This simplicity is its superpower. You don't need service discovery, distributed tracing, or eventual consistency. A single transaction spans the entire request without network overhead. For small teams and simple domains, nothing beats it.
But here's the catch: as the codebase grows, so does cognitive load. A 500,000-line monolith is hard to reason about. Deployment risk increases because any change touches the whole system. The monolith doesn't fail gracefully – a null pointer in the PDF generator can bring down the entire REST API.
Senior engineers know that monoliths are not a failure of vision. They're a strategic choice for the right phase of a product. Shopify ran a monolith for years. GitHub's core is still largely monolithic. What matters is that the monolith is well-structured: modules have explicit boundaries, shared code is minimal, and cross-module dependencies are enforced by convention or tooling.
Microservices: Independence at a Cost
Microservices decompose the application into independently deployable services that communicate over a network. Each service owns its own data, scales independently, and can be written in different languages. This buys you team autonomy, independent scaling, and fault isolation.
The cost is significant: network latency, distributed data consistency, complex observability, and the need for infrastructure like service meshes, API gateways, and message brokers. You trade simplicity for flexibility.
One hidden cost: you now need to manage inter-service contracts. Changing an API requires coordinating with downstream consumers. Without a robust versioning strategy and consumer-driven contracts, you'll break production more often than you'd like.
Also, debugging a request that touches five services means you need distributed tracing (Jaeger, Zipkin), structured logging across all services, and a centralised metrics pipeline. That's a lot of infrastructure before you write any business logic.
The Modular Monolith: The Third Option
A modular monolith keeps a single deployment but enforces strict module boundaries. Modules communicate through well-defined interfaces (Java modules, .NET assemblies, or simply package conventions). Data access is encapsulated within each module – no cross-module database queries.
This approach gives you many microservices benefits (code ownership, testability, concurrency) without the deployment complexity. Many successful systems (Shopify, GitHub, Stack Overflow) run this way. It's the perfect stepping stone: you can extract a module into a microservice later if needed.
Enforcing boundaries is the hard part. Without tooling, engineers will inevitably take shortcuts: a quick SELECT * FROM orders from the inventory module, a direct class import from a different bounded context. CI checks can prevent this – for example, a Gradle module that only exposes certain packages, or an ArchUnit test that verifies layer dependencies.
- Each module owns its own data – no peeking into neighbours' tables.
- Communication happens through defined corridors – interfaces, not direct SQL.
- You can still renovate one apartment without moving out of the building.
- You only split into separate buildings (microservices) when you need distinct fire codes (deployment lifecycle).
The Decision Framework: 5 Questions to Answer Before Splitting
Use these five questions to decide whether your organisation is ready for microservices:
- Can your team operate the infrastructure? Microservices require CI/CD pipelines, container orchestration, monitoring, and incident management per service. If your ops team is two people, you're not ready.
- Do you have clear domain boundaries? Stable bounded contexts (from Domain-Driven Design) are essential. If you can't define what each service owns, you're not ready.
- Is the monolith actually hurting? Measure concrete signals: deployment frequency, mean-time-to-recovery, developer onboarding time. If these aren't causing pain, don't split.
- Can you run multiple databases? Each microservice should own its data. If you can't manage multiple DB engines, you'll end up with a distributed monolith.
- Is your team aligned with the architecture? Conway's Law: a system's architecture mirrors the communication structure of the organisation. Split services along team boundaries, not technical ones.
Let's apply this to a real scenario. Say you're a 25-person startup with a Rails monolith that takes 10 minutes to build and 20 minutes to test. The team is organised into three squads: payments, user management, and analytics. The payments squad deploys every two weeks because the full test suite must pass. That's a measurable pain point – question 3 is a 'yes'. Payments has a clear domain boundary (question 2), and the team is three developers who can own the full lifecycle (question 1 is borderline). You start by extracting the payments module as a microservice, leaving the rest monolithic. Six months later, you evaluate whether the additional operational cost was worth the release velocity gain.
Migration Pattern: The Strangler Fig in Practice
The strangler fig pattern is the safest way to migrate from monolith to microservices. You intercept incoming requests and route them to either the monolith or the new service, based on the URL path, feature flag, or user segment. Over time, the new service handles more cases, and the monolith 'shrinks' until it can be decommissioned.
Here's a concrete example using an NGINX reverse proxy:
``nginx # nginx.conf – io.thecodeforge.strangler server { listen 80; location /api/payments { proxy_pass http://payment-service:8080; } location / { proxy_pass http://monolith:3000; } } ``
This allows incremental extraction. You don't need a big-bang rewrite. You extract one endpoint at a time, run both systems in parallel, and roll back by re-routing to the monolith if the new service fails.
But beware: the strangler fig creates an implicit dependency between the monolith and the new service if they share data. You must ensure idempotency on both sides – a request that hits the monolith first and then the new service on retry must not duplicate effects.
Why Your Monolith Is Actually an Omelette—and How to Cut It Safely
You’ve got a single Java WAR file. It’s been running for three years. New feature? Four-week deployment cycle. One JVM crash takes down billing, auth, and the user dashboard. That’s not a monolith. That’s an omelette: everything mixed together, and one bad egg ruins breakfast.
The problem isn't size—it's coupling. If your UserService directly calls BillingService.validatePayment() inside the same transaction, you can't extract anything without breaking both. That's why the modular monolith exists: enforce boundaries with OSGi, JPMS, or plain package conventions before you cut network boundaries. Your real goal is deployment independence, not physical separation.
Before you split, run a dependency analysis. Find the classes that change together most often. Those are your future service candidates. Everything else is noise.
The Database Is Not Your Friend—Monolith vs Microservices Data Patterns
You extracted the billing service. Great. Now it shares the same PostgreSQL instance as the monolith. One unoptimized query from billing locks a row that auth needs. All users get 503 errors for 30 seconds. Happy Friday.
Here’s the hard rule: microservices require database-per-service, or you don’t have microservices—you have a distributed monolith with extra network latency.
But don't run to separate databases immediately. Start with schema decomposition: give each service its own schema in the same database. Then replicate read-only data with change data capture (Debezium). Finally, when you truly decouple, you switch to independent databases.
The WHY: when billing needs to query user data, it calls an API, not a JOIN. That forces you to design proper APIs with caching, retries, and circuit breakers. It hurts now. It saves you when a bad deploy kills the user-db replica.
The Startup That Split Too Early
- Do not split until your team and domain are large enough that the monolith's friction is measurable.
- Start with a well-structured monolith. Extract services only when you feel the pain of the monolith in a specific area.
- Network calls are never zero cost. Every microservice boundary adds latency, failure modes, and debugging complexity.
- A modular monolith can give you many of the same benefits without the organizational debt.
Draw a dependency graph of your current codebase: jdeps -dotoutput graph.dot myapp.jarMeasure build time: mvn clean install -T 1C | grep 'BUILD'Key takeaways
Common mistakes to avoid
4 patternsSplitting into microservices before identifying domain boundaries
Sharing a single database across microservices
Adopting microservices for 'scalability' when the system handles 100 requests per second
Ignoring Conway's Law – splitting by technical layers instead of team boundaries
Interview Questions on This Topic
When would you choose a monolith over microservices for a new project?
Frequently Asked Questions
20+ years shipping large-scale distributed systems. Lessons pulled from things that broke in production.
That's Fundamentals. Mark it forged?
8 min read · try the examples if you haven't