Skip to content
Codeloom
System Design

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.

·10 min read · By Codeloom
Intermediate 15 min read

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.

Microservices vs monolith architecture comparison

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:

  1. All teams expose their data and functionality through service interfaces
  2. Teams communicate with each other only through these interfaces
  3. No other form of inter-process communication is allowed
  4. It does not matter what technology they use
  5. 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.