Microservices architecture is how most large-scale software is built today — Netflix, Amazon, Uber, and Spotify all rely on it. But it's not the right choice for every team or every project. This guide explains exactly what microservices are, how they work, the key patterns you need to know, and when to use (or avoid) them.
At a glance
| Monolith | Microservices | |
|---|---|---|
| Structure | Single deployable unit | Independently deployable services |
| Codebase | One shared codebase | Separate repo per service (usually) |
| Scaling | Scale everything together | Scale individual services |
| Deployment | Deploy whole app at once | Deploy services independently |
| Tech stack | Single stack | Polyglot (each service chooses) |
| Data | Shared database | Database per service |
| Communication | In-process function calls | Network calls (HTTP/gRPC/messages) |
| Failure scope | Bug can take down whole app | Failures can be isolated |
| Complexity | Lower operational complexity | Higher operational complexity |
| Team size | Works well for small teams | Better for large, multiple teams |
| Best for | Startups, simple apps | Large orgs, high-scale products |
What are microservices?
Microservices is an architectural style where an application is structured as a collection of small, independently deployable services. Each service:
- Does one thing well (single responsibility)
- Runs in its own process
- Communicates over a network (HTTP, gRPC, message queues)
- Owns its own data (database per service)
- Can be deployed independently without touching other services
┌─────────────────────────────────────────┐
│ Client (Web / Mobile) │
└──────────────────┬──────────────────────┘
│
┌──────────────────▼──────────────────────┐
│ API Gateway │
│ (routing, auth, rate limiting, SSL) │
└────┬──────────┬──────────┬──────────────┘
│ │ │
┌───────────▼──┐ ┌───▼──────┐ ┌▼──────────────┐
│ User Service │ │ Order │ │ Product │
│ :8001 │ │ Service │ │ Service │
│ │ │ :8002 │ │ :8003 │
└───────┬───────┘ └───┬──────┘ └──────┬────────┘
│ │ │
┌───────▼──┐ ┌───────▼──┐ ┌───────▼──┐
│ Users DB │ │ Orders DB│ │ Products │
│ (Postgres)│ │ (MySQL) │ │ DB (Mongo│
└───────────┘ └──────────┘ └──────────┘
The "micro" in microservices
The size of a service is less important than its boundaries. A service should map to a bounded context — a coherent business capability. Common guidelines:
- Can be rewritten in 2 weeks by a small team
- Follows the "two-pizza team" rule (team small enough to be fed by two pizzas)
- Has a single, clear responsibility
Microservices vs Monolith vs SOA
| Dimension | Monolith | SOA | Microservices |
|---|---|---|---|
| Granularity | One large app | Large enterprise services | Small, focused services |
| Communication | In-process | ESB (Enterprise Service Bus) | Lightweight (HTTP/gRPC/MQ) |
| Data | Shared DB | Often shared DB | Database per service |
| Deployment | Deploy whole app | Often deploy whole suite | Deploy services independently |
| Team ownership | Shared codebase | Multiple teams, loose | "You build it, you run it" |
| Coupling | Tight | Medium (ESB creates coupling) | Loose |
| Technology | Single stack | Mostly uniform | Polyglot |
| When to use | Small teams, early stage | Legacy enterprise | Large scale, multiple teams |
Monolith first (Martin Fowler's advice)
Most experts recommend starting with a monolith:
- Start monolith — faster to develop, easier to deploy, simpler operations
- Identify pain points — where does the monolith struggle? (scaling, deployment velocity, team conflicts)
- Extract services — peel off services where it provides clear value
"Don't start with microservices. Start with a monolith, identify the seams, and extract where it makes sense." — Martin Fowler
Core concepts
Bounded Context (Domain-Driven Design)
Each service owns a bounded context — a specific subdomain of the business:
E-commerce Platform
├── User Context → User Service (profiles, preferences, auth)
├── Catalog Context → Product Service (products, categories, search)
├── Order Context → Order Service (cart, checkout, order lifecycle)
├── Payment Context → Payment Service (billing, invoicing)
├── Shipping Context → Shipping Service (logistics, tracking)
└── Notification Context→ Notification Service (email, SMS, push)
Service ownership
The "You build it, you run it" principle from Amazon means:
- The team that builds a service operates it in production
- This drives quality and accountability
- Each service has a clear owner
API contracts
Services communicate via stable API contracts. Breaking changes require versioning:
/api/v1/users/{id} ← stable, v1 clients still work
/api/v2/users/{id} ← new version, new response shape
Communication patterns
Synchronous: REST
The most common pattern. Service A calls Service B and waits for a response.
// Order Service calling Product Service
async function getProductDetails(productId) {
const response = await fetch(
`http://product-service/api/products/${productId}`
);
if (!response.ok) throw new Error('Product not found');
return response.json();
}
Pros: Simple, familiar, immediate feedback Cons: Temporal coupling — if Product Service is down, Order Service fails too
Synchronous: gRPC
Google's Remote Procedure Call framework. Uses Protocol Buffers (binary) instead of JSON.
// product.proto
service ProductService {
rpc GetProduct (ProductRequest) returns (ProductResponse);
rpc ListProducts (ListRequest) returns (stream ProductResponse);
}
message ProductRequest {
string product_id = 1;
}
message ProductResponse {
string product_id = 1;
string name = 2;
double price = 3;
bool in_stock = 4;
}
Pros: 3-10x faster than REST, strongly typed, streaming support Cons: Less human-readable, harder to debug, browser support limited
Asynchronous: Message Queues
Services communicate via messages. Producer publishes, consumer subscribes.
Order Service Message Queue Notification Service
│ (Kafka/RabbitMQ) │
│──── OrderPlaced ────►│ │──── OrderPlaced ─►│
│ │ │ │ (send email)
│ │ │ │
│ │ │◄──────────────────│
# Order Service — publish event
import json
from kafka import KafkaProducer
producer = KafkaProducer(bootstrap_servers='kafka:9092')
def place_order(order_data):
# save to DB...
order = save_order(order_data)
# publish event
producer.send('orders', json.dumps({
'event': 'OrderPlaced',
'order_id': order.id,
'user_id': order.user_id,
'total': order.total,
}).encode())
return order
# Notification Service — consume event
from kafka import KafkaConsumer
consumer = KafkaConsumer('orders', bootstrap_servers='kafka:9092')
for message in consumer:
event = json.loads(message.value)
if event['event'] == 'OrderPlaced':
send_confirmation_email(event['user_id'], event['order_id'])
Pros: Decoupled, resilient, handles traffic spikes, enables event sourcing Cons: Eventual consistency, harder to debug, requires message broker infrastructure
Communication pattern comparison
| Pattern | Protocol | Coupling | Latency | Reliability | Complexity |
|---|---|---|---|---|---|
| REST | HTTP/JSON | Medium | Low–medium | Caller handles retry | Low |
| gRPC | HTTP/2 + Protobuf | Medium | Low | Caller handles retry | Medium |
| Message Queue | Kafka/RabbitMQ | Loose | Medium (async) | At-least-once delivery | High |
| Event Streaming | Kafka | Very loose | Medium | Replay possible | High |
| GraphQL Federation | HTTP/JSON | Medium | Low–medium | Caller handles retry | Medium |
API Gateway
Every microservices system needs an API Gateway — the single entry point for all clients.
Clients API Gateway Services
┌─────────────┐
Web App ──────────►│ • Routing │──────────────► User Service
Mobile App ────────│ • Auth/JWT │──────────────► Order Service
3rd Party API ─────│ • Rate limit│──────────────► Product Service
│ • SSL term │──────────────► Payment Service
│ • Logging │──────────────► Search Service
│ • Caching │
└─────────────┘
Popular API Gateways:
| Gateway | Type | Best for |
|---|---|---|
| Kong | Open source / Enterprise | High performance, plugin ecosystem |
| AWS API Gateway | Cloud managed | AWS workloads |
| Nginx | Open source | Simple routing, high performance |
| Traefik | Open source | Kubernetes-native, auto-discovery |
| Istio | Service mesh | Advanced traffic management, mTLS |
| Envoy | Open source proxy | Service mesh data plane |
| Azure API Management | Cloud managed | Azure workloads |
Design patterns
1. Strangler Fig Pattern
Gradually replace a monolith by extracting services piece by piece:
Phase 1: Monolith handles everything
Client → Monolith (all features)
Phase 2: Extract User Service (strangler proxy in front)
Client → Proxy → User Service (user routes)
→ Monolith (everything else)
Phase 3: Extract Order Service
Client → Proxy → User Service
→ Order Service
→ Monolith (remaining features)
Phase N: Monolith retired
Client → Proxy → User Service
→ Order Service
→ Product Service
→ ... (all extracted)
2. Saga Pattern (Distributed Transactions)
Microservices can't use traditional ACID transactions across services. Sagas solve this with a sequence of local transactions, each publishing events.
Choreography Saga (event-driven):
Order Service Payment Service Inventory Service
│ │ │
│──OrderCreated──────►│ │
│ │──PaymentProcessed──►│
│ │ │──InventoryReserved
│◄───────────────────────────────────────── (saga complete)
On failure:
│ │──PaymentFailed──────►│
│◄──OrderCompensated──│ │
Orchestration Saga (central coordinator):
Saga Orchestrator
│
├──1. CreateOrder──────────► Order Service
│◄──OrderCreated────────────│
│
├──2. ProcessPayment────────► Payment Service
│◄──PaymentProcessed────────│
│
├──3. ReserveInventory──────► Inventory Service
│◄──InventoryReserved───────│
│
└── Saga Complete
3. Circuit Breaker Pattern
Prevent cascade failures when a service is down:
import time
from enum import Enum
class CircuitState(Enum):
CLOSED = "closed" # normal operation
OPEN = "open" # failing, reject requests
HALF_OPEN = "half_open" # testing recovery
class CircuitBreaker:
def __init__(self, failure_threshold=5, recovery_timeout=30):
self.failure_threshold = failure_threshold
self.recovery_timeout = recovery_timeout
self.failure_count = 0
self.last_failure_time = None
self.state = CircuitState.CLOSED
def call(self, func, *args, **kwargs):
if self.state == CircuitState.OPEN:
if time.time() - self.last_failure_time > self.recovery_timeout:
self.state = CircuitState.HALF_OPEN
else:
raise Exception("Circuit is OPEN — service unavailable")
try:
result = func(*args, **kwargs)
self._on_success()
return result
except Exception as e:
self._on_failure()
raise e
def _on_success(self):
self.failure_count = 0
self.state = CircuitState.CLOSED
def _on_failure(self):
self.failure_count += 1
self.last_failure_time = time.time()
if self.failure_count >= self.failure_threshold:
self.state = CircuitState.OPEN
4. Service Discovery
Services register themselves; clients look them up dynamically (no hardcoded IPs).
┌─────────────────┐
Service A ──────►│ Service Registry │◄────── Service B
(register) │ (Consul/Eureka) │ (discover A)
└─────────────────┘
Types:
| Type | How it works | Examples |
|---|---|---|
| Client-side | Service queries registry, load-balances itself | Netflix Eureka + Ribbon |
| Server-side | Load balancer queries registry | AWS ALB + ECS |
| DNS-based | Service registered as DNS entry | Kubernetes DNS |
5. CQRS (Command Query Responsibility Segregation)
Separate the read model from the write model:
Write side (Commands) Read side (Queries)
│ │
Create Order ─────► Orders DB ──────► Materialised View
Update Order (denormalised, fast reads)
Cancel Order Event Stream ──► Read DB (optimised for queries)
When to use: High-read-to-write ratio, complex domain, reporting requirements
6. Event Sourcing
Store state as a sequence of events, not current state:
Traditional DB: Event Store:
┌─────────────┐ ┌─────────────────────────────────────────┐
│ orders │ │ events │
│─────────────│ │─────────────────────────────────────────│
│ id: 123 │ vs │ OrderCreated {id:123, user:1, ...} │
│ status: PAID│ │ PaymentAdded {id:123, amount:99.99} │
│ total: 99.99│ │ OrderShipped {id:123, tracking:XYZ} │
└─────────────┘ └─────────────────────────────────────────┘
Current state = replay of all events
Benefits: Full audit trail, time travel queries, easy event publishing
Data management
Database per service
Each service owns its data exclusively. Other services must call the API — never the DB directly.
✅ CORRECT:
Order Service ──HTTP──► User Service ──► Users DB
❌ WRONG:
Order Service ──────────────────────────► Users DB
(bypassing User Service)
Handling distributed data challenges
| Challenge | Solution |
|---|---|
| No cross-service joins | API Composition (aggregate in gateway/client) |
| No distributed transactions | Saga pattern |
| Data consistency across services | Eventual consistency + events |
| Reporting across services | Read replica / data warehouse / CQRS |
| Referential integrity | Soft references by ID, accept eventual consistency |
API Composition pattern
// API Gateway aggregates data from multiple services
async function getOrderDetails(orderId) {
const [order, user, products] = await Promise.all([
orderService.getOrder(orderId),
userService.getUser(order.userId),
productService.getProducts(order.productIds),
]);
return {
...order,
customerName: user.name,
customerEmail: user.email,
products: products,
};
}
Deployment
Containerisation (Docker)
Every microservice runs in a Docker container:
# Dockerfile for Order Service
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --production
FROM node:22-alpine
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY . .
EXPOSE 8002
USER node
CMD ["node", "src/index.js"]
Orchestration (Kubernetes)
Kubernetes manages deployment, scaling, and self-healing:
# order-service deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: order-service
spec:
replicas: 3
selector:
matchLabels:
app: order-service
template:
metadata:
labels:
app: order-service
spec:
containers:
- name: order-service
image: myregistry/order-service:v1.2.3
ports:
- containerPort: 8002
resources:
requests:
memory: "128Mi"
cpu: "100m"
limits:
memory: "256Mi"
cpu: "500m"
livenessProbe:
httpGet:
path: /health
port: 8002
initialDelaySeconds: 30
readinessProbe:
httpGet:
path: /ready
port: 8002
---
apiVersion: v1
kind: Service
metadata:
name: order-service
spec:
selector:
app: order-service
ports:
- port: 80
targetPort: 8002
CI/CD per service
Each service has its own pipeline:
# .github/workflows/order-service.yml
name: Order Service CI/CD
on:
push:
paths:
- 'services/order/**'
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run tests
run: cd services/order && npm test
- name: Build and push Docker image
run: |
docker build -t myregistry/order-service:${{ github.sha }} .
docker push myregistry/order-service:${{ github.sha }}
- name: Deploy to Kubernetes
run: kubectl set image deployment/order-service order-service=myregistry/order-service:${{ github.sha }}
Observability
With dozens of services, you need observability. The three pillars:
| Pillar | What it gives you | Tools |
|---|---|---|
| Logs | What happened | ELK Stack, Loki + Grafana, Datadog |
| Metrics | How healthy (CPU, memory, req/s, error rate) | Prometheus + Grafana, Datadog |
| Traces | Where time was spent (across services) | Jaeger, Zipkin, Tempo, Datadog APM |
Distributed tracing
A single request may touch 10 services. Tracing follows it end-to-end:
Request ID: abc-123
│
├── API Gateway 10ms
├── User Service 15ms
│ └── Users DB 8ms
├── Order Service 45ms
│ ├── Orders DB 20ms
│ └── Product Service 18ms
│ └── Products DB 10ms
└── Response 70ms total
Structured logging
Every service should output structured logs with a correlation ID:
{
"timestamp": "2025-01-15T10:30:45Z",
"level": "info",
"service": "order-service",
"trace_id": "abc-123",
"span_id": "def-456",
"message": "Order created",
"order_id": "ord-789",
"user_id": "usr-456",
"total": 99.99
}
Challenges
| Challenge | Description | Solution |
|---|---|---|
| Network latency | In-process calls become network calls | Circuit breakers, caching, async messaging |
| Distributed transactions | ACID across services is hard | Saga pattern, eventual consistency |
| Service discovery | Services need to find each other | Consul, Kubernetes DNS, service mesh |
| Testing | Integration testing across services | Contract testing (Pact), consumer-driven contracts |
| Data consistency | No shared DB, eventual consistency | Design for it, compensating transactions |
| Operational complexity | Many services to deploy and monitor | Kubernetes, service mesh, observability stack |
| Versioning | API changes break consumers | API versioning, backward compatibility |
| Security | Service-to-service auth | mTLS, service mesh (Istio), JWT |
| Debugging | Tracing a bug across services | Distributed tracing, correlation IDs |
When to use microservices
Use microservices when:
| Scenario | Why microservices helps |
|---|---|
| Multiple teams working on same codebase | Independent deployment reduces coordination |
| Different scaling requirements per feature | Scale only the bottleneck service |
| Need to use different tech stacks | Polyglot is possible with microservices |
| Monolith deploy takes hours | Independent deploys speed up delivery |
| Different availability requirements | Critical services can have more replicas |
| Organisational size > 50 engineers | Conway's Law: structure mirrors communication |
| High-scale traffic (millions of users) | Horizontal scaling per service |
Don't use microservices when:
| Scenario | Why monolith is better |
|---|---|
| Early-stage startup | Too much operational overhead, premature optimisation |
| Small team (< 10 engineers) | Microservices complexity exceeds benefits |
| Simple CRUD app | Distributed system is overkill |
| Monolith works fine | "If it ain't broke, don't fix it" |
| No DevOps maturity | You need Kubernetes, CI/CD, monitoring |
| No domain expertise | Hard to find correct service boundaries |
| Tight deadline | Microservices slow down initial development |
Microservices vs related architectures
| Architecture | Granularity | Communication | Data | Best for |
|---|---|---|---|---|
| Monolith | One big app | In-process | Shared DB | Small teams, simple apps |
| Modular Monolith | Modules in one app | In-process | Shared DB (separate schemas) | Medium teams, clear boundaries |
| SOA | Large services | ESB / SOAP | Often shared | Legacy enterprise integration |
| Microservices | Small, focused | HTTP/gRPC/MQ | DB per service | Large teams, high scale |
| Serverless | Functions | HTTP / events | Stateless | Event-driven, variable load |
| Cell-based | Isolated cells | APIs | Per cell | Massive scale, blast radius |
Common mistakes
| Mistake | Problem | Fix |
|---|---|---|
| Starting with microservices | Premature complexity before you know the domain | Start monolith, extract services later |
| Too many services too soon | Distributed monolith — still coupled, more overhead | Find real domain boundaries first |
| Sharing databases between services | Breaks independence, creates hidden coupling | Database per service — always |
| Synchronous everything | Tight coupling, cascading failures | Use async messaging for non-critical paths |
| No API versioning | Breaking changes break consumers | Version your APIs from day one |
| Skipping observability | Debugging distributed systems without traces is impossible | Logs + metrics + traces from day one |
| Chatty services | Too many synchronous calls slow everything down | Batch calls, use async, cache aggressively |
| Ignoring Conway's Law | Team structure ≠ service structure → coordination overhead | Align teams with service ownership |
Quick reference: microservices stack
Layer Tools
─────────────────────────────────────────────────────────
Client Web, Mobile, 3rd Party
API Gateway Kong, AWS API GW, Nginx, Traefik
Service Mesh Istio, Linkerd, Consul Connect
Container Runtime Docker
Orchestration Kubernetes (EKS, GKE, AKS)
CI/CD GitHub Actions, GitLab CI, ArgoCD
Service Registry Kubernetes DNS, Consul, Eureka
Message Broker Kafka, RabbitMQ, AWS SQS/SNS
Logging ELK, Loki + Grafana, Datadog
Metrics Prometheus + Grafana, Datadog
Tracing Jaeger, Tempo, Datadog APM
Config/Secrets Vault, AWS Secrets Manager, K8s Secrets
FAQ
Q: How small should a microservice be? There's no fixed answer — it depends on the domain. A useful heuristic: a service should do one business capability, be maintainable by a small team (2–5 people), and be deployable independently. If you need to coordinate deploys across multiple "microservices" for a single feature, they're too granular or you've drawn the wrong boundaries.
Q: Can microservices share a database? No — sharing a database defeats the purpose of microservices. It creates tight coupling at the data layer, which means you can't change the schema of one service without breaking others. Database per service is a core rule. For reporting/analytics, use a dedicated read replica or data warehouse that pulls from all services.
Q: How do you handle authentication across services? The API Gateway handles authentication (validates JWT from client). The token is then forwarded to downstream services in headers. Services can trust the token (the Gateway already validated it) or re-validate it themselves. For service-to-service auth, use mutual TLS (mTLS) via a service mesh like Istio.
Q: What's the difference between a microservice and a serverless function? A microservice is a long-running service with its own HTTP server. A serverless function (Lambda, Cloud Functions) is stateless, event-triggered, and runs only when called. Microservices are better for complex business logic, stateful operations, and low-latency requirements. Serverless is better for event-driven, variable-load, and simple transformations. Many systems use both.
Q: How do you test microservices? Use a testing pyramid adapted for distributed systems:
- Unit tests — test business logic in isolation (fast, many)
- Integration tests — test the service with its real database
- Contract tests — verify API contracts between services (Pact)
- End-to-end tests — test the whole flow (slow, few)
Contract testing is especially important — it lets teams evolve services independently without breaking consumers.
Q: Is Kubernetes required for microservices? No, but it makes life much easier. You can run microservices on VMs, bare metal, Docker Swarm, or serverless platforms. For small-scale systems, Docker Compose works for local development and simple deployments. For production at scale, Kubernetes is the industry standard because it handles scheduling, self-healing, scaling, service discovery, and rolling deployments.