Microservices vs Monolith: When to Split and When to Stay Together
A practical, anti-hype guide to microservices vs monolith architecture covering when each makes sense, the real costs of microservices, the modular monolith approach, and migration paths.
Around 2018, the entire industry collectively decided that monoliths were bad and microservices were the future. Conference talks, blog posts, and architecture diagrams all pointed the same direction: break everything into small, independent services. Companies that had no business running microservices tried to adopt them anyway.
Now the pendulum has swung back. "Monolith-first" is the new consensus. Amazon made headlines when they moved a service from microservices back to a monolith and reduced costs by 90%. Teams that split too early are merging services back together.
Here's the thing: both architectures work. The question was never "which is better?" It was always "which is better for your situation?" And most teams answered that question based on hype rather than analysis.
What a Monolith Actually Is
A monolith is a single deployable unit. Your entire application — user authentication, payment processing, email sending, report generation — lives in one codebase and deploys as one artifact. One process, one deployment pipeline, one thing to monitor.
This is not a bad word. A monolith does NOT mean:
- Spaghetti code (that's a code quality problem, not an architecture problem)
- Impossible to scale (monoliths can scale horizontally behind a load balancer)
- Outdated technology (Next.js, Rails, Django, Spring Boot — all produce monoliths by default)
- Hard to work on (if your monolith is hard to work on, microservices won't fix that)
Most successful products started as monoliths. Shopify is a monolith serving billions of dollars in transactions. Stack Overflow runs on a monolith. Basecamp is a monolith. These aren't small companies — they chose to stay monolithic because it made engineering sense.
What Microservices Actually Are
Microservices are independently deployable services that communicate over a network (usually HTTP/gRPC or message queues). Each service owns its own data, has its own codebase (or at least its own deployment), and can be developed and deployed independently.
A typical microservices setup:
[API Gateway]
|
├── [User Service] → [Users DB]
├── [Order Service] → [Orders DB]
├── [Payment Service] → [Payments DB]
├── [Email Service] → [Message Queue]
└── [Search Service] → [Elasticsearch]
Each service is a small application. The Order Service doesn't know or care how the User Service stores its data. They communicate through well-defined APIs.
When the Monolith Is Better
In my experience, a monolith is the right choice for the majority of teams and products. Specifically:
Startups and new products. You're still figuring out what to build. Domain boundaries are unclear. Features change weekly. A monolith lets you move fast, refactor aggressively, and change direction without coordinating across services. The last thing a three-person startup needs is inter-service communication overhead. Small teams (fewer than 10 developers). The primary benefit of microservices is team autonomy — different teams can deploy independently. If you only have one team, there's no one to be autonomous from. You're adding network complexity for zero organizational benefit. Products still finding product-market fit. If you're not sure your product will exist in six months, spending three months on microservices infrastructure is a bad investment. Build the thing. Get users. Refactor later. When you don't know your domain boundaries. Getting service boundaries wrong is expensive. If you split the User Service and Order Service, then realize they need to share a lot of data and logic, you've created a distributed monolith — all the downsides of both architectures. Simple function call:# Monolith: this is a function call. It's fast and simple.
def create_order(user_id, items):
user = user_service.get_user(user_id) # in-memory call
inventory = inventory_service.check(items) # in-memory call
payment = payment_service.charge(user, total) # in-memory call
return Order(user, items, payment)
This takes microseconds. It's type-safe. If anything fails, you get a stack trace. There's no network latency, no serialization, no retry logic.
When Microservices Make Sense
Microservices become genuinely valuable when:
Different services need to scale differently. Your search service needs 20 instances during peak hours while your user service needs 2. Scaling a monolith means scaling everything, which wastes resources. Different teams need to deploy independently. When you have 50 developers across 8 teams, a shared monolith becomes a coordination bottleneck. Every deployment requires sign-off from multiple teams. Merge conflicts are constant. Microservices let each team own their service and deploy on their own schedule. Different services need different tech stacks. Your ML pipeline needs Python, your real-time service needs Go, your web app needs Node.js. Microservices let each service use the best tool for its job. Fault isolation is critical. In a monolith, a memory leak in the reporting module can crash the entire application. In microservices, a failing service can be isolated while the rest of the system keeps running.The Real Costs of Microservices
This is the part most "microservices are great" articles gloss over. The costs are significant:
Network latency. That function call that took microseconds in the monolith? It's now an HTTP request taking 1-10ms. If one request touches 5 services, you've added 5-50ms of latency. Chain those calls and it compounds.# Microservices: same logic, now with network calls
async def create_order(user_id, items):
user = await http.get(f"http://user-service/users/{user_id}") # 2-5ms
inventory = await http.post("http://inventory-service/check", items) # 2-5ms
payment = await http.post("http://payment-service/charge", payload) # 5-10ms
# Total: 9-20ms just for network overhead
# Plus: what if user-service is down? Retry? Circuit break? Timeout?
Distributed debugging. When something goes wrong in a monolith, you get a stack trace. When something goes wrong across microservices, you need distributed tracing (Jaeger, Zipkin), correlated logs, and a prayer. Finding the root cause of a failure that spans 4 services is genuinely hard.
Data consistency. In a monolith, you wrap related operations in a database transaction. Done. In microservices, each service has its own database. There are no cross-service transactions. You need eventual consistency, saga patterns, or compensating transactions. This is complex to implement and even harder to debug when it goes wrong.
Operational overhead. Each service needs its own CI/CD pipeline, monitoring, alerting, logging, health checks, and deployment configuration. 10 services means 10x the infrastructure configuration. This is where the "Kubernetes tax" comes in — you practically need Kubernetes to manage microservices effectively, and Kubernetes itself requires significant expertise.
Testing complexity. Unit testing individual services is easy. Testing the interactions between services is hard. You need contract tests, integration tests, and end-to-end tests that spin up multiple services. Your test environment becomes a mini production cluster.
The Modular Monolith (The Best-Kept Secret)
There's a middle ground that gives you most of the benefits of microservices without the costs: the modular monolith.
A modular monolith is a single deployable application with well-defined internal module boundaries. Each module has its own directory, its own models, its own business logic, and communicates with other modules through explicit interfaces — not by reaching into each other's internals.
monolith/
├── modules/
│ ├── users/
│ │ ├── api.py # Public interface
│ │ ├── models.py # User-related models
│ │ ├── service.py # Business logic
│ │ └── repository.py # Data access
│ ├── orders/
│ │ ├── api.py
│ │ ├── models.py
│ │ ├── service.py
│ │ └── repository.py
│ └── payments/
│ ├── api.py
│ ├── models.py
│ ├── service.py
│ └── repository.py
├── shared/
│ └── events.py # Internal event bus
└── main.py
The key rule: modules only communicate through their public api.py interfaces or through an internal event bus. The orders module never imports directly from users/models.py — it calls users.api.get_user(id).
# modules/orders/service.py
from modules.users.api import get_user # Public interface only
from modules.payments.api import process_payment # Public interface only
class OrderService:
def create_order(self, user_id: str, items: list) -> Order:
user = get_user(user_id) # In-process call, not HTTP
payment = process_payment(user, total) # In-process call, not HTTP
order = Order(user_id=user.id, items=items, payment_id=payment.id)
self.repo.save(order)
self.events.emit("order_created", order) # Other modules can react
return order
This gives you:
- Clean boundaries (like microservices)
- Easy refactoring within modules (like microservices)
- Function call performance (like monoliths)
- Single deployment (like monoliths)
- Simple debugging with stack traces (like monoliths)
- A clear path to extract services later if needed
Shopify runs exactly this architecture. They call their modules "components" and enforce boundaries with tooling that prevents cross-module imports.
The Migration Path
If you're starting a new project, the recommended path is:
Phase 1: Monolith. Build fast. Get to market. Don't worry about service boundaries. Phase 2: Modular Monolith. As the codebase grows, organize code into well-defined modules with explicit boundaries. Enforce these boundaries with linting rules or architecture tests. Phase 3: Extract services when needed. When a specific module needs independent scaling, independent deployment, or a different tech stack, extract it into a service. The module boundaries you defined in Phase 2 make this extraction straightforward — you're replacing in-process calls with network calls.Most companies never need to go past Phase 2. And that's fine.
Real-World Examples
Amazon started as a monolith. They moved to microservices as they scaled to thousands of developers who needed to deploy independently. Then in 2023, the Prime Video team moved back from microservices to a monolith for their video monitoring pipeline — because the network overhead between services was their bottleneck. Shopify runs a modular monolith serving billions in commerce. They explicitly chose not to go microservices. Their reasoning: the monolith lets them refactor aggressively, and they haven't hit the organizational scaling problems that microservices solve. Netflix runs microservices. They have hundreds of developers, services that need radically different scaling profiles (encoding vs. recommendation vs. streaming), and the engineering team to manage the complexity.The pattern is clear: microservices solve organizational scaling problems (too many teams, too many deploys) more than technical ones. If your team is small, your monolith is fine.
The Bottom Line
Don't let conference talks and blog posts (including this one) decide your architecture. Ask yourself:
- How many developers work on this codebase?
- Do different parts need to scale independently?
- Do different teams need to deploy independently?
- Can we afford the operational overhead?
If you answered "not many," "not yet," "not yet," and "probably not" — stick with a monolith. Make it modular. Extract services when you feel the pain, not before.
The worst architecture is the one you chose based on hype. The best architecture is the one that lets your team ship features to users. Sometimes that's microservices. Usually, it's a well-organized monolith.
If you're learning backend development and system design, CodeUp covers both monolithic and distributed architectures with practical projects — so you understand the trade-offs from experience, not just from reading about them.