Monolith vs Microservices: Architecture Decision Guide
Compare monolithic and microservices architectures with real decision criteria, migration patterns, and code examples to help you choose the right approach for your project.
What you'll learn
- ✓The real tradeoffs between monoliths and microservices
- ✓When each architecture pattern actually makes sense
- ✓How to migrate from a monolith to microservices incrementally
- ✓Common pitfalls that teams hit with microservices adoption
Prerequisites
- •Basic backend development experience
- •Familiarity with REST APIs
- •Understanding of deployment basics
The monolith versus microservices debate has produced more blog posts than actual system migrations. Most of the discourse oversimplifies the choice into “monolith bad, microservices good.” Reality is messier. Both architectures have legitimate use cases, and the right answer depends on your team size, domain complexity, and operational maturity.
This guide walks through concrete decision criteria, shows what each architecture looks like in practice, and covers the migration path between them.
What a monolith actually looks like
A monolith is a single deployable unit containing all your application logic. That does not mean it has to be a tangled mess of spaghetti code. A well-structured monolith uses internal module boundaries.
# project structure for a well-organized monolith
# ecommerce/
# ├── orders/
# │ ├── models.py
# │ ├── services.py
# │ └── routes.py
# ├── inventory/
# │ ├── models.py
# │ ├── services.py
# │ └── routes.py
# ├── payments/
# │ ├── models.py
# │ ├── services.py
# │ └── routes.py
# └── main.py
# orders/services.py
from inventory.services import InventoryService
from payments.services import PaymentService
class OrderService:
def __init__(self):
self.inventory = InventoryService()
self.payments = PaymentService()
def place_order(self, user_id: int, items: list[dict]) -> dict:
# Direct function calls - no network overhead
for item in items:
if not self.inventory.check_availability(item["sku"], item["quantity"]):
raise ValueError(f"Item {item['sku']} is out of stock")
total = sum(item["price"] * item["quantity"] for item in items)
payment = self.payments.charge(user_id, total)
if not payment["success"]:
raise ValueError("Payment failed")
# All in one database transaction
order = Order.create(
user_id=user_id,
items=items,
payment_id=payment["id"],
total=total,
)
self.inventory.decrement_stock(items)
return order.to_dict()
The key advantage here is simplicity. One deployment, one database, direct function calls, and ACID transactions across all your data. Debugging is straightforward because everything runs in one process.
What microservices actually look like
Microservices split your application into independently deployable services that communicate over the network. Each service owns its data and can be developed, deployed, and scaled independently.
# order-service/app.py
import httpx
from fastapi import FastAPI, HTTPException
app = FastAPI()
INVENTORY_URL = "http://inventory-service:8001"
PAYMENT_URL = "http://payment-service:8002"
@app.post("/orders")
async def create_order(order_request: OrderRequest):
async with httpx.AsyncClient(timeout=5.0) as client:
# Network call to inventory service
for item in order_request.items:
resp = await client.get(
f"{INVENTORY_URL}/inventory/{item.sku}"
)
if resp.status_code != 200:
raise HTTPException(503, "Inventory service unavailable")
stock = resp.json()
if stock["available"] < item.quantity:
raise HTTPException(400, f"{item.sku} out of stock")
# Network call to payment service
resp = await client.post(
f"{PAYMENT_URL}/charges",
json={
"user_id": order_request.user_id,
"amount": order_request.total,
},
)
if resp.status_code != 200:
raise HTTPException(502, "Payment processing failed")
payment = resp.json()
# Save order to this service's own database
order = await OrderRepository.create(
user_id=order_request.user_id,
items=order_request.items,
payment_id=payment["id"],
)
return order
Notice what changed. Every inter-service call is now a network request that can fail, time out, or return unexpected results. You have lost ACID transactions across services. You need to handle partial failures. The same business logic now requires significantly more infrastructure.
The honest comparison
Here is a side-by-side comparison of what each architecture gives you in practice.
Deployment and operations
Monolith: One CI/CD pipeline, one artifact, one deployment. Rolling back means deploying the previous version. Simple.
Microservices: Each service has its own pipeline. You need container orchestration (Kubernetes or similar), service discovery, and distributed tracing. A “simple” deployment now involves coordinating multiple services.
# What your infrastructure looks like with microservices
# docker-compose.yml (simplified - production needs much more)
services:
order-service:
build: ./order-service
ports: ["8000:8000"]
depends_on: [order-db, rabbitmq]
inventory-service:
build: ./inventory-service
ports: ["8001:8001"]
depends_on: [inventory-db, rabbitmq]
payment-service:
build: ./payment-service
ports: ["8002:8002"]
depends_on: [payment-db]
order-db:
image: postgres:16
inventory-db:
image: postgres:16
payment-db:
image: postgres:16
rabbitmq:
image: rabbitmq:3-management
Data consistency
In a monolith, a database transaction guarantees consistency.
# Monolith: simple transaction
def place_order(self, user_id, items):
with db.transaction():
order = Order.create(user_id=user_id, items=items)
Inventory.decrement(items)
Payment.charge(user_id, order.total)
# If anything fails, everything rolls back
In microservices, you need the Saga pattern or eventual consistency.
# Microservices: saga pattern for distributed transactions
class OrderSaga:
async def execute(self, order_request):
steps_completed = []
try:
# Step 1: Reserve inventory
reservation = await self.inventory_client.reserve(order_request.items)
steps_completed.append(("inventory", reservation["id"]))
# Step 2: Process payment
payment = await self.payment_client.charge(
order_request.user_id, order_request.total
)
steps_completed.append(("payment", payment["id"]))
# Step 3: Create order
order = await self.order_repo.create(order_request)
return order
except Exception as e:
# Compensating transactions - undo everything
await self.compensate(steps_completed)
raise
async def compensate(self, steps):
for service, resource_id in reversed(steps):
if service == "inventory":
await self.inventory_client.release_reservation(resource_id)
elif service == "payment":
await self.payment_client.refund(resource_id)
Scaling
Monolith: You scale the entire application. If your payment processing is the bottleneck, you still deploy more instances of everything. This wastes resources but is operationally simple.
Microservices: You scale individual services. Payment service getting hammered? Add more instances of just that service. This is efficient but requires load balancing, service discovery, and health checks for each service.
# Kubernetes HPA - scale payment service independently
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: payment-service-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: payment-service
minReplicas: 2
maxReplicas: 20
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
Decision framework
Use these concrete criteria rather than following hype.
Choose a monolith when
- Your team is fewer than 15 engineers. The coordination overhead of microservices will slow you down more than any scaling benefit.
- You are building a new product. You do not know where your domain boundaries are yet. Getting them wrong in a microservices architecture is extremely expensive to fix.
- Your deployment pipeline works. If deploying a monolith takes 5 minutes and happens multiple times a day, you do not have a deployment problem.
- Your data model has tight coupling. If most operations touch multiple “domains,” microservices will just add network calls to what used to be function calls.
Choose microservices when
- Multiple teams need to deploy independently. If team A’s deploy blocks team B, and this happens frequently, service boundaries solve a real problem.
- Parts of your system have fundamentally different scaling needs. Your real-time chat requires websockets and lots of memory while your batch processing needs CPU. Different services let you optimize infrastructure per workload.
- You need technology diversity. The ML team wants Python, the API team wants Go, and the front-end team wants Node.js. Microservices let each team use the right tool.
- You have operational maturity. Your team can handle distributed tracing, service meshes, container orchestration, and on-call rotations for multiple services.
The modular monolith: the overlooked middle ground
Before jumping to microservices, consider a modular monolith. You get clean boundaries without the network overhead.
# Enforced module boundaries within a monolith
# Each module exposes only its public API
# inventory/api.py - the public interface
class InventoryAPI:
"""Other modules interact with inventory only through this class."""
def check_stock(self, sku: str) -> int:
return InventoryRepository().get_available_quantity(sku)
def reserve(self, sku: str, quantity: int) -> str:
return InventoryRepository().create_reservation(sku, quantity)
def release(self, reservation_id: str) -> None:
InventoryRepository().cancel_reservation(reservation_id)
# inventory/repository.py - internal, not imported by other modules
class InventoryRepository:
"""Internal - only used within the inventory module."""
def get_available_quantity(self, sku: str) -> int:
# Direct database query - no network call
result = db.execute(
"SELECT quantity - reserved FROM products WHERE sku = %s",
(sku,),
)
return result.scalar()
You can enforce these boundaries with linting rules or architectural tests.
# tests/test_architecture.py
import ast
import os
def test_no_cross_module_internal_imports():
"""Ensure modules only import each other's public API."""
violations = []
for root, _, files in os.walk("src"):
for f in files:
if not f.endswith(".py"):
continue
filepath = os.path.join(root, f)
module = filepath.split("/")[1] # e.g., "orders"
with open(filepath) as fh:
tree = ast.parse(fh.read())
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom) and node.module:
parts = node.module.split(".")
if len(parts) >= 2:
imported_module = parts[0]
imported_path = parts[1]
if (
imported_module != module
and imported_path != "api"
):
violations.append(
f"{filepath} imports {node.module} "
f"(should use {imported_module}.api)"
)
assert not violations, "\n".join(violations)
This gives you a clear extraction path. When a module genuinely needs to become its own service, the public API becomes the service’s HTTP interface, and the internal implementation stays the same.
Migrating from monolith to microservices
If you decide to migrate, do it incrementally. The Strangler Fig pattern is the safest approach.
# Step 1: Add a facade in the monolith that can route to either
# the local implementation or the new service
class InventoryFacade:
def __init__(self):
self.use_service = feature_flags.is_enabled("inventory_service")
self.local = InventoryModule()
self.remote = InventoryServiceClient("http://inventory-service:8001")
def check_stock(self, sku: str) -> int:
if self.use_service:
return self.remote.check_stock(sku)
return self.local.check_stock(sku)
# Step 2: Run both in parallel and compare results (dark launching)
class InventoryFacade:
async def check_stock(self, sku: str) -> int:
local_result = self.local.check_stock(sku)
if self.use_service:
try:
remote_result = await self.remote.check_stock(sku)
if remote_result != local_result:
logger.warning(
"Inventory mismatch",
sku=sku,
local=local_result,
remote=remote_result,
)
except Exception:
logger.exception("Inventory service call failed")
return local_result # Still using local as source of truth
# Step 3: Switch traffic to the service, keep monolith as fallback
class InventoryFacade:
async def check_stock(self, sku: str) -> int:
if self.use_service:
try:
return await self.remote.check_stock(sku)
except Exception:
logger.warning("Falling back to local inventory")
return self.local.check_stock(sku)
return self.local.check_stock(sku)
Common microservices mistakes
Distributed monolith. If every change requires coordinated deployments across multiple services, you have a distributed monolith. This is strictly worse than a regular monolith because you have all the complexity of microservices with none of the benefits.
Too many services too early. Starting with 20 microservices for a team of 5 is a recipe for burnout. Each service needs monitoring, alerting, CI/CD, and on-call coverage.
Shared databases. If two services read from the same database table, they are not independent. Schema changes in one break the other. Each service must own its data.
Synchronous chains. If service A calls B, which calls C, which calls D, your latency is the sum of all calls, and your availability is the product of all availabilities. Use asynchronous messaging for workflows that span multiple services.
Wrapping Up
The monolith versus microservices decision is not about technology sophistication. It is about matching your architecture to your organization’s size, operational capability, and domain complexity. Start with a well-structured monolith. Enforce module boundaries from day one. When you hit genuine scaling or organizational bottlenecks that a monolith cannot solve, extract services incrementally using the Strangler Fig pattern. The worst outcome is adopting microservices prematurely and spending all your engineering time on infrastructure instead of product features.
Related articles
- Backend Monolith vs Microservices: A Pragmatic Comparison
Compare monoliths and microservices across team size, deploy cadence, complexity, and operational overhead to choose the right architecture.
- System Design Event-Driven Architecture: The Pragmatic Introduction
What event-driven architecture really gives you, when to choose it, and the operational realities of running asynchronous systems at scale.
- Backend Caching Strategies: Write-Through, Write-Back, and TTL
Understand the major caching strategies for backend systems. Compare write-through, write-back, write-around, and cache-aside with real-world trade-offs.
- Backend CQRS vs Event Sourcing
CQRS and event sourcing are often mentioned together but solve different problems. This post separates them, shows how they combine, and when each is worth the complexity.