Microservices Architecture Patterns That Actually Work
Practical lessons from designing and operating microservices at scale: event-driven communication, service boundaries and the patterns that survived.

The Microservices Reality Check
After years of building and operating microservices, I've learned that the hardest part isn't splitting a monolith — it's knowing where to split and how to keep the pieces talking to each other reliably.
This post covers the patterns that have survived production in my experience.
Finding Service Boundaries
The most common mistake? Splitting by technical layer instead of business domain.
// ❌ Technical split — leads to tight coupling
// services/user-api/
// services/user-database/
// services/user-cache/
// ✅ Domain split — each service owns its full stack
// services/identity/ → auth, profiles, permissions
// services/catalog/ → products, categories, search
// services/orders/ → checkout, fulfillment, returnsDomain-Driven Design gives you the vocabulary for this: Bounded Contexts. Each service should map to a bounded context with clear ownership of its data and behavior.
Event-Driven Communication
Synchronous HTTP calls between services create brittle chains. When Service A calls Service B which calls Service C, a failure anywhere breaks everything.
Event-driven communication decouples services:
// Order service — publishes event after checkout
interface OrderCreatedEvent {
type: "order.created";
data: {
orderId: string;
customerId: string;
items: Array<{ productId: string; quantity: number }>;
total: number;
};
metadata: {
timestamp: string;
correlationId: string;
};
}
async function createOrder(input: CreateOrderInput): Promise<Order> {
const order = await db.orders.create(input);
await eventBus.publish({
type: "order.created",
data: {
orderId: order.id,
customerId: order.customerId,
items: order.items,
total: order.total,
},
metadata: {
timestamp: new Date().toISOString(),
correlationId: generateId(),
},
});
return order;
}The inventory, notification, and analytics services each subscribe to order.created and react independently.
The Saga Pattern for Distributed Transactions
When a business process spans multiple services, you can't use a database transaction. Sagas coordinate the flow:
| Step | Service | Action | Compensation |
|---|---|---|---|
| 1 | Orders | Create order | Cancel order |
| 2 | Payment | Charge card | Refund payment |
| 3 | Inventory | Reserve stock | Release stock |
| 4 | Shipping | Schedule delivery | Cancel shipment |
If step 3 fails, you run compensations in reverse: refund the payment, then cancel the order. This is choreography-based when services react to events, or orchestration-based when a central coordinator manages the flow.
API Gateway Pattern
An API gateway sits between clients and your services, handling:
- Routing — Direct requests to the right service
- Authentication — Validate tokens once, not in every service
- Rate limiting — Protect services from traffic spikes
- Response aggregation — Combine data from multiple services
// Simplified gateway route example
async function getProductPage(productId: string) {
const [product, reviews, recommendations] = await Promise.all([
catalogService.getProduct(productId),
reviewService.getReviews(productId),
recommendationService.getSimilar(productId),
]);
return { product, reviews, recommendations };
}Circuit Breaker
When a downstream service is failing, keep calling it and you'll cascade failures through your entire system. The circuit breaker pattern prevents this:
class CircuitBreaker {
private failures = 0;
private lastFailure = 0;
private state: "closed" | "open" | "half-open" = "closed";
constructor(
private threshold: number = 5,
private resetTimeout: number = 30000,
) {}
async execute<T>(fn: () => Promise<T>): Promise<T> {
if (this.state === "open") {
if (Date.now() - this.lastFailure > this.resetTimeout) {
this.state = "half-open";
} else {
throw new Error("Circuit is open");
}
}
try {
const result = await fn();
this.onSuccess();
return result;
} catch (error) {
this.onFailure();
throw error;
}
}
private onSuccess() {
this.failures = 0;
this.state = "closed";
}
private onFailure() {
this.failures++;
this.lastFailure = Date.now();
if (this.failures >= this.threshold) {
this.state = "open";
}
}
}Observability Is Not Optional
In a distributed system, you will have issues that span services. Without proper observability, debugging becomes impossible.
The three pillars:
- Structured logging — JSON logs with correlation IDs that trace a request across services
- Distributed tracing — Tools like OpenTelemetry to visualize request flows
- Metrics — RED metrics (Rate, Errors, Duration) per service
The correlation ID is critical — propagate it through every service call and log entry so you can reconstruct the full journey of a request.
Key Takeaways
- Split by domain, not by layer — Bounded contexts make natural service boundaries
- Default to async — Event-driven communication prevents cascading failures
- Plan for failure — Circuit breakers, retries with backoff, and sagas for distributed transactions
- Observe everything — You can't fix what you can't see
- Start with a monolith — Extract services as you learn where the boundaries should be
The best architecture is the simplest one that meets your requirements. Don't adopt microservices because it's trendy — do it when your team and domain complexity demand it.


