Microservices vs Monolith: When to Split

Photo by Unsplash

Photo by Unsplash
The microservices vs monolith debate is one of the most consequential architectural decisions a team makes. Get it wrong and you're either drowning in distributed systems complexity before you have a product-market fit, or struggling to scale a monolith that's become a tangled ball of mud. This post cuts through the hype to give you a framework for making the right choice — and a migration strategy if you need to change course.
A monolith is a single deployable unit where all modules — users, orders, inventory, billing — share the same process and often the same database. A microservices architecture decomposes those modules into independently deployable services that communicate over a network. The difference sounds simple, but it has profound implications for your team, your tooling, and your operational burden.
Monoliths get a bad reputation, but a well-structured monolith with clear module boundaries is an excellent starting point for almost any product. You have a single codebase to navigate, transactions that span multiple modules are trivially handled by the database, debugging is straightforward (one process, one log stream), and deployment is a single artifact. The operational burden is minimal compared to microservices.
Microservices enable independent deployment and scaling of services, isolation of failures, and technology heterogeneity — different services can use different languages or databases. But each service boundary you add introduces network latency, the need for distributed tracing, eventual consistency challenges, and the complexity of coordinating deployments across multiple repositories and teams. You are trading development simplicity for operational flexibility.
// Monolith: single shared database, direct function calls
// src/orders/orderService.ts
import { UserRepository } from "../users/userRepository";
import { InventoryRepository } from "../inventory/inventoryRepository";
export class OrderService {
async placeOrder(userId: string, items: OrderItem[]): Promise<Order> {
const user = await UserRepository.findById(userId);
await InventoryRepository.reserveItems(items);
return OrderRepository.create({ userId, items, status: "pending" });
}
}
// Microservices: HTTP/message calls across service boundaries
// order-service/src/orderService.ts
export class OrderService {
async placeOrder(userId: string, items: OrderItem[]): Promise<Order> {
// Call user-service via HTTP
const user = await fetch(`http://user-service/users/${userId}`).then(r => r.json());
// Call inventory-service via message queue
await messageBroker.publish("inventory.reserve", { items, orderId: newId() });
return OrderRepository.create({ userId, items, status: "pending" });
}
}Sam Newman's 'Monolith to Microservices' recommends starting with a modular monolith — strong internal module boundaries enforced by code, not network calls. This gives you separation of concerns without network overhead, and makes a future migration significantly easier.
The right choice depends on your team size, deployment frequency, scaling requirements, and organizational structure. Conway's Law is real: your system's architecture will mirror your team's communication structure. Small teams building a single product almost always benefit from a monolith. Large organizations with multiple teams, distinct SLAs per service, and the ability to own independent deployment pipelines are the natural home of microservices.
You have a small team (under 10 engineers), you're still searching for product-market fit and need to iterate quickly, your modules share significant data and cross-cutting concerns, you don't have a dedicated DevOps or platform team to manage service infrastructure, or you're building an internal tool or MVP where simplicity is paramount. A monolith lets you move fast and pivot without distributed system overhead.
Your teams are independently sized and structured around services, you need to scale specific components independently (your recommendation engine has very different compute needs than your billing service), different services have different reliability or compliance requirements, you need to support multiple deployment environments independently, or different parts of your system are owned by teams that shouldn't share deployment cycles.
If you're moving from a monolith to microservices, the strangler fig pattern is the safest approach. Rather than a big-bang rewrite (which almost always fails), you incrementally extract services from the monolith while keeping it running. An API gateway routes requests to either the monolith or the new microservice based on the path or feature flag. Over time, the monolith shrinks as services are extracted.
Start by identifying the bounded context most worth extracting — ideally one with clear API boundaries, independent scaling needs, and no tight data coupling to the rest of the monolith. Extract the data first: give the new service its own database. Then route traffic through the API gateway. Validate the new service under production load before removing the monolith code. Repeat for the next bounded context.
The most dangerous anti-pattern is the distributed monolith: you've split your application into multiple services, but they're tightly coupled — they share a database, call each other synchronously in long chains, and must be deployed together. You've taken all the operational complexity of microservices with none of the benefits. Before extracting a service, ensure it can truly own its data and deploy independently.
The hardest part of microservices migration is the data. Use the database-per-service pattern: each service owns its data and exposes it only via its API. Use event sourcing or change data capture (CDC) to synchronize data between the old monolith and new service during the transition period. Never let two services share a database table — it recreates the tight coupling you're trying to escape.
# Strangler Fig pattern: route by feature flag
# api-gateway/nginx.conf
location /api/users {
# New microservice handles /users
proxy_pass http://user-service:3001;
}
location /api/orders {
# Still monolith for now
proxy_pass http://monolith:3000;
}
location /api/inventory {
# Migrated to microservice
proxy_pass http://inventory-service:3002;
}Microservices shift complexity from your code to your infrastructure. You need service discovery, distributed tracing, centralized logging, health checks, circuit breakers, and an API gateway before your microservices architecture is production-ready. Budget time for this infrastructure work — it is not optional.
In a monolith, you have one log stream. In microservices, a single user request may touch ten services. Distributed tracing (OpenTelemetry + Jaeger or Tempo) gives you end-to-end visibility. Structured logging with correlation IDs lets you filter logs across services for a single request. Metrics and alerting per service (Prometheus + Grafana) tells you which service is the bottleneck.
A service mesh like Istio or Linkerd handles mTLS between services, load balancing, retries, timeouts, and circuit breaking at the infrastructure level — without changing application code. An API gateway (Kong, AWS API Gateway) handles authentication, rate limiting, and routing at the edge. Both are force multipliers for a microservices architecture but add their own operational complexity.
Use health checks and readiness probes on every service from day one. A service that fails its readiness probe is removed from the load balancer automatically, preventing cascading failures before they reach users.
The monolith vs microservices decision is not permanent, and you shouldn't treat it as one. Start simple. Build a well-structured monolith. When a specific bottleneck or team ownership issue genuinely demands a service extraction, make it surgically, following the strangler fig pattern. Avoid microservices-by-default and premature decomposition — they are among the most common sources of unnecessary complexity in modern software teams.
Key architectural concepts covered here include monolith, microservices, strangler fig, service mesh, and bounded context.