Microservices Architecture: Patterns That Actually Work

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:

  1. Structured Logging — JSON logs with correlation IDs across services
  2. Distributed Tracing — Trace a request across service boundaries (Zipkin/Jaeger)
  3. 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.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top