Microservices Architecture: Patterns, Trade-offs, and Pitfalls
An honest look at microservices vs monoliths. Learn service communication, API gateways, database-per-service, distributed tracing, and how Amazon's migration shaped the industry.
What you'll learn
- ✓Evaluate monolith vs microservices trade-offs honestly
- ✓Identify when to break a monolith and when to keep it
- ✓Compare synchronous and asynchronous service communication
- ✓Design service discovery, API gateways, and sidecar proxies
- ✓Apply the database-per-service pattern for data ownership
- ✓Implement distributed tracing with OpenTelemetry
Prerequisites
- •Experience building web applications
- •Basic understanding of APIs and HTTP
- •Familiarity with databases and deployment concepts
Microservices have become the default answer to “how should we architect this system?” — but the default answer is often wrong. This article gives you the honest trade-offs, the patterns that work, and the patterns that create more problems than they solve. The goal is to help you make an informed architectural decision rather than following hype.
The Monolith: Not a Dirty Word
A monolith is a single deployable unit where all your code runs in one process. All your features share the same database, the same deployment pipeline, and the same runtime.
┌─────────────────────────────────────┐
│ Monolith │
│ ┌──────┐ ┌──────┐ ┌─────────┐ │
│ │ Auth │ │Orders│ │Payments │ │
│ └──┬───┘ └──┬───┘ └────┬────┘ │
│ │ │ │ │
│ ┌──┴────────┴───────────┴──┐ │
│ │ Shared Database │ │
│ └──────────────────────────┘ │
└─────────────────────────────────────┘
Monolith advantages that people forget:
- Simple to develop, test, and deploy — one codebase, one CI/CD pipeline, one deployment
- Easy debugging — stack traces show the full call chain
- No network latency between function calls
- Straightforward refactoring — your IDE can rename a function across the entire codebase
- ACID transactions work naturally across all data
Shopify, a $100B+ company, runs on a monolithic Ruby on Rails application. Stack Overflow serves millions of developers from a monolith. GitHub ran as a monolith for years before selectively extracting services.
Microservices: What They Actually Are
Microservices decompose a system into independently deployable services, each owning a specific business capability and its data. Services communicate over the network via APIs or messages.
┌────────┐ ┌────────┐ ┌──────────┐
│ Auth │ │ Orders │ │ Payments │
│Service │ │Service │ │ Service │
└───┬────┘ └───┬────┘ └────┬─────┘
│ │ │
┌───┴──┐ ┌───┴──┐ ┌────┴───┐
│Auth │ │Orders│ │Payment │
│ DB │ │ DB │ │ DB │
└──────┘ └──────┘ └────────┘
The key principle is independence: each service can be developed by a different team, deployed on its own schedule, written in a different language, and scaled independently. This independence is both the main benefit and the source of most complexity.
When to Break the Monolith (and When Not To)
Break when:
Team scaling is the bottleneck. When you have 50+ engineers and they are constantly stepping on each other — merge conflicts, deployment queues, testing bottlenecks — service boundaries can give teams autonomy. This is the strongest argument for microservices.
Different components have drastically different scaling needs. If your image processing pipeline needs 100x the compute of your user profile service, scaling them independently saves money and reduces blast radius.
You need independent deployment velocity. When the payments team needs to deploy five times a day but is blocked by the monolith’s weekly release cycle.
Do NOT break when:
You are a small team. If you have fewer than 10-15 engineers, microservices will slow you down. The operational overhead of managing multiple services, databases, and network communication outweighs the benefits.
You do not understand your domain yet. Microservice boundaries should align with business domain boundaries. If you draw the wrong boundaries (and you will, early on), refactoring across service boundaries is orders of magnitude harder than refactoring within a monolith.
You are optimizing for technology, not people. Microservices are an organizational pattern disguised as an architectural one. They solve coordination problems between teams, not technical problems with code.
The industry’s hard-learned lesson: start with a monolith, extract services when you feel the pain, and only extract along well-understood domain boundaries.
Service Communication: Sync vs Async
Synchronous Communication (REST / gRPC)
Service A calls Service B and waits for a response. This is the simplest model and works well for request-response patterns.
# Order service calls Payment service synchronously
class OrderService:
def create_order(self, order_data):
order = self.db.create(order_data)
# Synchronous call — we wait for the response
payment_result = self.payment_client.charge(
amount=order.total,
customer_id=order.customer_id
)
if payment_result.success:
order.status = 'confirmed'
else:
order.status = 'payment_failed'
self.db.save(order)
return order
The problem: If the Payment service is slow or down, the Order service is also slow or down. This is temporal coupling — service A’s availability depends on service B’s availability. In a chain of synchronous calls (A → B → C → D), the system’s overall availability is the product of each service’s availability. If each is 99.9% available, four in a chain give you 99.6% — adding over three hours of downtime per year.
Asynchronous Communication (Message Queues)
Service A publishes an event or message to a queue, and Service B processes it whenever it is ready. The services are decoupled in time.
# Order service publishes event asynchronously
class OrderService:
def create_order(self, order_data):
order = self.db.create(order_data)
order.status = 'pending_payment'
self.db.save(order)
# Asynchronous — fire and forget
self.message_queue.publish('order.created', {
'order_id': order.id,
'amount': order.total,
'customer_id': order.customer_id
})
return order # Returns immediately
# Payment service consumes event independently
class PaymentConsumer:
def handle_order_created(self, event):
result = self.payment_gateway.charge(
amount=event['amount'],
customer_id=event['customer_id']
)
self.message_queue.publish('payment.completed', {
'order_id': event['order_id'],
'success': result.success
})
Pros: Services are decoupled. If the Payment service is down, messages queue up and are processed when it recovers. Each service can scale independently based on its queue depth.
Cons: Harder to reason about. No immediate response — the client gets “order created” but does not immediately know if payment succeeded. Debugging asynchronous flows requires good tooling (distributed tracing, dead letter queues).
Service Discovery and API Gateways
Service Discovery
In a microservices architecture, services need to find each other. Hard-coding IP addresses does not work when services are deployed dynamically across containers.
Client-side discovery: The client queries a service registry (like Consul or etcd) to find available instances, then connects directly. The client handles load balancing.
Server-side discovery: The client sends requests to a load balancer or DNS name, which handles the routing. Kubernetes Services work this way — you call http://payment-service:8080 and Kubernetes routes to a healthy pod.
# Kubernetes Service — server-side discovery
apiVersion: v1
kind: Service
metadata:
name: payment-service
spec:
selector:
app: payment
ports:
- port: 8080
targetPort: 8080
API Gateway
An API gateway sits between external clients and your microservices, providing a single entry point. It handles cross-cutting concerns so individual services do not have to.
External Clients
│
▼
┌──────────────────┐
│ API Gateway │ Authentication, rate limiting,
│ (Kong, Envoy) │ routing, protocol translation
└───┬──────┬───┬───┘
│ │ │
▼ ▼ ▼
Auth Orders Payments
Responsibilities of an API gateway:
- Request routing: Route
/api/orders/*to the Orders service,/api/payments/*to the Payments service - Authentication: Verify JWT tokens once at the gateway instead of in every service
- Rate limiting: Enforce per-client rate limits before requests reach backend services
- Protocol translation: Accept REST from external clients, convert to gRPC for internal services
- Response aggregation: Combine responses from multiple services into a single response
Sidecar Proxies and Service Mesh
A service mesh (like Istio or Linkerd) deploys a sidecar proxy alongside every service instance. The proxy handles networking concerns — TLS, retries, circuit breaking, observability — so the application code does not have to.
┌──────────────────────────────────┐
│ Pod │
│ ┌──────────┐ ┌──────────────┐ │
│ │ App │──│ Sidecar │ │
│ │ Code │ │ Proxy │ │
│ │ │ │ (Envoy) │ │
│ └──────────┘ └──────────────┘ │
└──────────────────────────────────┘
The application sends plain HTTP to localhost, and the sidecar proxy handles mTLS, load balancing, circuit breaking, and telemetry collection. This separates business logic from infrastructure concerns.
Database-Per-Service: Data Ownership
The database-per-service pattern gives each microservice its own database that only it can access directly. Other services must go through the API.
This is arguably the most important microservices pattern and the hardest to implement well. It means:
- No shared tables between services
- No cross-service database joins
- No cross-service transactions (you need sagas instead)
- Each service chooses the database technology that fits its needs
Order Service → Orders DB (PostgreSQL)
Product Service → Products DB (MongoDB)
Search Service → Search Index (Elasticsearch)
Analytics Service → Analytics DB (ClickHouse)
The pain is real: data that was trivially joinable in a monolith now requires API calls or event-driven data replication. But the benefit is equally real: services can evolve their data models independently, choose appropriate storage technologies, and scale their databases independently.
Data Replication Between Services
When Service A needs data owned by Service B, you have two options:
API calls at query time: Service A calls Service B’s API whenever it needs the data. Simple but creates runtime coupling and adds latency.
Event-driven replication: Service B publishes events when its data changes, and Service A maintains a local read-only copy. More complex but eliminates runtime coupling.
# Service B publishes changes
class ProductService:
def update_product(self, product_id, data):
product = self.db.update(product_id, data)
self.events.publish('product.updated', {
'id': product.id,
'name': product.name,
'price': product.price
})
# Service A maintains a local copy
class OrderService:
def handle_product_updated(self, event):
self.product_cache.upsert(
event['id'],
{'name': event['name'], 'price': event['price']}
)
Distributed Tracing with OpenTelemetry
In a monolith, a stack trace tells you exactly what happened. In microservices, a single user request might touch ten services. Distributed tracing stitches together the journey of a request across all services.
from opentelemetry import trace
tracer = trace.get_tracer("order-service")
def create_order(request):
with tracer.start_as_current_span("create_order") as span:
span.set_attribute("user.id", request.user_id)
# This span is automatically linked to the parent
with tracer.start_as_current_span("validate_inventory"):
inventory_client.check(request.items)
with tracer.start_as_current_span("process_payment"):
payment_client.charge(request.total)
with tracer.start_as_current_span("send_confirmation"):
notification_client.send_email(request.user_id)
A trace ID propagates through HTTP headers (traceparent) across service boundaries, allowing tools like Jaeger, Zipkin, or Datadog to reconstruct the complete request flow with timing for each service.
How Amazon Moved from Monolith to Microservices
Amazon’s migration is the canonical microservices story. In the early 2000s, amazon.com was a monolithic C++ application. As the team grew, they hit coordination bottlenecks: dozens of teams trying to deploy to the same codebase, a single database becoming a contention point.
Jeff Bezos’ famous mandate (circa 2002) required:
- All teams expose their data and functionality through service interfaces
- Teams communicate with each other only through these interfaces
- No other form of inter-process communication is allowed
- It does not matter what technology they use
- All service interfaces must be designed to be externalizable
This was not a technology decision — it was an organizational one. By forcing service boundaries, Amazon gave each team full ownership of their domain. Teams could deploy independently, choose their own technology stack, and scale their services without coordinating with other teams.
The result: Amazon went from deploying every 11.6 seconds (already fast for 2011) to building the infrastructure that became AWS. The internal services they built for themselves — storage (S3), compute (EC2), queues (SQS) — turned out to be products other companies wanted too.
The lesson is often misinterpreted. Amazon did not succeed because microservices are technically superior. They succeeded because service boundaries gave autonomous teams the independence they needed to move fast at scale. If you do not have the team-size problem, you do not need the team-size solution.
Wrapping Up
Microservices are a tool for organizational scaling, not a silver bullet for technical problems. Start with a monolith, modularize it well, and extract services only when team coordination becomes the bottleneck. When you do extract, invest heavily in the supporting infrastructure: API gateways, message queues, distributed tracing, and service discovery. The services themselves are the easy part — the hard part is everything between them.
Related articles
- System Design Event Sourcing Explained: Architecture and Trade-offs
Understand event sourcing fundamentals, how it differs from CRUD, when to use it, and the real-world trade-offs you need to consider before adopting it.
- 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 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.
- System Design Distributed Systems Fundamentals: Consensus, Clocks, and Failure
Understand what makes distributed systems hard. Learn consensus algorithms (Paxos, Raft), leader election, distributed locking, vector clocks, gossip protocols, and how CockroachDB achieves distributed SQL.