After building and maintaining microservices architectures for several years across fintech and enterprise platforms, I've learned that the patterns you choose early on determine whether your system scales gracefully or crumbles under complexity.
Here are the patterns that have consistently proven their worth in production.
Service Decomposition: Getting the Boundaries Right
The single biggest mistake teams make is decomposing too early and too granularly. Start with a well-structured monolith, identify natural domain boundaries, and extract services only when there's a clear operational reason.
Good reasons to extract a service:
- Independent scaling requirements (e.g., a reporting service that's CPU-heavy)
- Different deployment cadence (e.g., a payment service that changes rarely but must be highly stable)
- Team ownership boundaries
Bad reasons to extract a service:
- "Microservices are best practice"
- Each entity should be its own service
- Resume-driven development
A practical decomposition approach uses Domain-Driven Design (DDD) bounded contexts:
Order Context → order-service
- Order
- OrderLine
- OrderStatus
Payment Context → payment-service
- Payment
- Transaction
- Refund
Notification Context → notification-service
- EmailNotification
- PushNotification
The Saga Pattern: Managing Distributed Transactions
In a monolith, a database transaction ensures consistency. In microservices, you need the Saga pattern. I prefer the choreography-based saga for simple flows and orchestration-based saga for complex ones.
Orchestration Example (Order Flow)
@Service
public class OrderSagaOrchestrator {
public void processOrder(OrderRequest request) {
try {
// Step 1: Reserve inventory
inventoryService.reserve(request.getItems());
// Step 2: Process payment
paymentService.charge(request.getPaymentDetails());
// Step 3: Confirm order
orderService.confirm(request.getOrderId());
} catch (InventoryException e) {
// No compensation needed — first step failed
orderService.reject(request.getOrderId(), "Out of stock");
} catch (PaymentException e) {
// Compensate: release inventory
inventoryService.release(request.getItems());
orderService.reject(request.getOrderId(), "Payment failed");
}
}
}
Key principle: Every saga step must have a corresponding compensation action. Design compensations before you design the happy path.
Circuit Breaker: Failing Gracefully
When a downstream service is struggling, the worst thing you can do is keep hammering it with requests. The circuit breaker pattern prevents cascading failures.
@Service
public class PaymentServiceClient {
@CircuitBreaker(name = "paymentService", fallbackMethod = "fallback")
@Retry(name = "paymentService")
public PaymentResponse processPayment(PaymentRequest request) {
return restTemplate.postForObject(
"http://payment-service/api/payments",
request,
PaymentResponse.class
);
}
private PaymentResponse fallback(PaymentRequest request, Exception e) {
// Queue for retry, return pending status
retryQueue.enqueue(request);
return PaymentResponse.pending("Payment queued for processing");
}
}
Configure your circuit breaker thresholds based on actual SLA requirements:
resilience4j:
circuitbreaker:
instances:
paymentService:
sliding-window-size: 10
failure-rate-threshold: 50
wait-duration-in-open-state: 30s
permitted-number-of-calls-in-half-open-state: 3
API Gateway Pattern
A single entry point for all client requests simplifies authentication, rate limiting, and request routing.
Client Request
↓
[API Gateway]
├── /api/orders/* → order-service
├── /api/payments/* → payment-service
├── /api/users/* → user-service
└── /api/reports/* → report-service
The gateway handles cross-cutting concerns:
- Authentication/Authorization — validate JWT tokens once
- Rate Limiting — protect services from traffic spikes
- Request/Response Transformation — version API contracts
- Load Balancing — distribute traffic across instances
Service Discovery and Communication
For inter-service communication, choose your pattern based on the use case:
| Pattern | Use When | Example |
|---|---|---|
| Synchronous REST | Real-time response needed | Get user profile |
| Async Messaging | Fire-and-forget, eventual consistency | Send notification |
| Event Streaming | Multiple consumers, event replay | Order state changes |
| gRPC | High-throughput, internal services | Data pipeline |
Observability: The Non-Negotiable
You cannot debug distributed systems without three pillars of observability:
- Structured Logging — JSON logs with correlation IDs across services
- Distributed Tracing — Trace a request across service boundaries (Zipkin/Jaeger)
- Metrics — RED metrics (Rate, Errors, Duration) per service
// Structured log entry with trace context
{
"timestamp": "2025-01-15T10:30:00Z",
"level": "INFO",
"service": "order-service",
"traceId": "abc123",
"spanId": "def456",
"message": "Order processed",
"orderId": "ORD-789",
"duration_ms": 245
}
Final Thoughts
Microservices are not inherently better than monoliths. They trade one set of problems (deployment coupling, scaling limitations) for another (distributed complexity, network reliability, data consistency).
Choose microservices when the operational benefits outweigh the complexity cost. And when you do, invest heavily in the patterns that handle failure gracefully — because in distributed systems, failure isn't the exception, it's the norm.