Most microservices pattern guides are theoretical. This one covers what production systems actually look like: service discovery, circuit breakers, distributed tracing, and the decisions you have to make.
JOptimize Team
The microservices literature is full of patterns with impressive names: Strangler Fig, CQRS, Saga, Service Mesh, Anti-Corruption Layer. What it's less good at is telling you which patterns are actually worth implementing for a team of 10 engineers shipping a real product, versus which ones are overhead you'll regret.
This guide is honest about trade-offs. Some patterns are essential from day one. Others only become worth the complexity once you're at significant scale. And some are solutions to problems you don't have yet.
Before talking about patterns, it helps to be clear about what microservices make harder:
Network is unreliable. Method calls don't fail in monoliths. Network calls fail all the time — timeouts, connection resets, temporary DNS failures. Every service-to-service call needs retry logic, timeout handling, and circuit breakers.
Distributed transactions don't exist. There's no @Transactional that spans two services. If service A updates its database and then calls service B which fails, you have inconsistent state. Every operation that spans services needs a compensating mechanism.
Observability becomes essential, not optional. A bug in a monolith produces a single stack trace. A bug in a microservices system produces symptoms in service C, caused by a failure in service B, triggered by a bad request from service A. Without distributed tracing, debugging is nearly impossible.
Testing is harder. Integration testing a monolith means starting one application. Integration testing microservices means starting all of them in the right order with the right configuration.
None of this means microservices are wrong. It means the patterns that make them work are not optional — they're the price of admission.
An API gateway is the single entry point for all client traffic. It handles cross-cutting concerns — authentication, rate limiting, routing, SSL termination — so individual services don't have to:
# Spring Cloud Gateway configuration spring: cloud: gateway: routes: - id: order-service uri: lb://order-service # Load-balanced via service discovery predicates: - Path=/api/v1/orders/** filters: - StripPrefix=0 - name: RequestRateLimiter args: redis-rate-limiter.replenishRate: 100 redis-rate-limiter.burstCapacity: 200 - name: CircuitBreaker args: name: orderServiceCB fallbackUri: forward:/fallback/orders - id: auth-service uri: lb://auth-service predicates: - Path=/api/v1/auth/**
The gateway pattern is non-negotiable for production microservices. Without it, every service needs its own rate limiting, SSL certificates, and CORS configuration. The gateway centralizes all of this.
A circuit breaker prevents cascading failures. When service B is slow or failing, calls from service A pile up, exhausting A's thread pool and causing A to fail too — even though A's own logic is fine. Circuit breakers detect this and stop sending traffic to failing services:
@Service @RequiredArgsConstructor public class InventoryClient { private final WebClient webClient; @CircuitBreaker(name = "inventory", fallbackMethod = "getStockFallback") @TimeLimiter(name = "inventory") // Timeout if response takes > 2s @Retry(name = "inventory") // Retry up to 3 times before circuit opens public CompletableFuture<StockStatus> getStock(Long productId) { return webClient.get() .uri("/api/v1/inventory/{id}", productId) .retrieve() .bodyToMono(StockStatus.class) .toFuture(); } // Fallback: return cached or default value when circuit is open public CompletableFuture<StockStatus> getStockFallback(Long productId, Exception e) { log.warn("Inventory service unavailable for product {}: {}", productId, e.getMessage()); return CompletableFuture.completedFuture( StockStatus.assumed() // Return optimistic stock status ); } }
The fallback is crucial. A circuit breaker without a fallback just returns errors faster. The fallback defines what the system does in degraded mode — cached data, default values, graceful degradation.
Distributed tracing connects logs across services with a correlation ID. When a request spans 5 services and fails, you need to see the entire call path in one view:
<dependency> <groupId>io.micrometer</groupId> <artifactId>micrometer-tracing-bridge-otel</artifactId> </dependency>
management: tracing: sampling: probability: 0.1 # Sample 10% of requests in production otlp: tracing: endpoint: http://jaeger:4318/v1/traces logging: pattern: correlation: "[${spring.application.name},%X{traceId},%X{spanId}] "
With this configuration, every log line includes the trace ID. In Jaeger or Zipkin, you can look up a trace ID and see every service call in the chain, with timing for each one. Finding the slow service in a 400ms request that spans 5 services takes seconds instead of hours.
Services in a microservices system should not trust each other blindly. An attacker who compromises one service shouldn't be able to call all other services:
// Service A: generate a service token when calling service B @Service @RequiredArgsConstructor public class ServiceAuthInterceptor implements ClientHttpRequestInterceptor { private final ServiceTokenProvider tokenProvider; @Override public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws IOException { // Add service identity token to outbound calls request.getHeaders().set( "X-Service-Token", tokenProvider.getToken("order-service") // JWT with service identity ); return execution.execute(request, body); } } // Service B: validate incoming service token @Component public class ServiceTokenValidator { public ServiceIdentity validate(String token) { // Verify JWT signature and extract calling service name // Reject if service is not in the allowed list for this endpoint } }
In Kubernetes environments, mTLS via a service mesh (Istio, Linkerd) handles this at the infrastructure level without application code changes. For simpler deployments, application-level service tokens are the pragmatic choice.
The database-per-service pattern gives each microservice its own database schema, preventing tight coupling through shared tables:
# Each service owns its data: order-service → orders DB (PostgreSQL) inventory-service → inventory DB (PostgreSQL) payment-service → payments DB (PostgreSQL) notification-service → no DB (stateless, uses Kafka)
The benefit is clear: you can change the order service schema without coordinating with the inventory team. You can scale the inventory database independently. You can even change the database technology per service.
The cost is that cross-service queries don't exist. If you need a report that joins orders with inventory data, you either:
For teams early in their microservices journey, sharing a database with separate schemas (not tables) is often the right pragmatic choice. Physical separation can come later when the need is clear.
Not every operation needs a synchronous response. Processing that happens after the user gets their response — sending emails, updating analytics, generating reports — should be async:
@Service @RequiredArgsConstructor public class OrderService { private final OrderRepository orderRepo; private final KafkaTemplate<String, Object> kafka; @Transactional public OrderResponse placeOrder(PlaceOrderRequest req) { // Synchronous: validate and save Order order = orderRepo.save(Order.from(req)); // Async: everything that doesn't affect the response kafka.send("orders.placed", new OrderPlacedEvent(order)); // notification-service picks this up and sends email // analytics-service picks this up and updates dashboards // loyalty-service picks this up and awards points // Return immediately — don't wait for any of the above return OrderResponse.from(order); } }
The synchronous path does the minimum work needed to give the user a response. Everything else happens asynchronously. This reduces response latency and makes the system more resilient — if the notification service is down, orders still work.
Some patterns are genuinely unnecessary until you're at significant scale:
Service Mesh (Istio, Linkerd): Powerful, but adds significant operational complexity. Unless you have dedicated platform engineering, start with application-level circuit breakers and Spring Cloud Gateway. Migrate to service mesh when you have the team to manage it.
Event Sourcing: Storing every state change as an immutable event sequence is powerful for audit logs and temporal queries. But it's a significant architectural commitment that affects every aspect of your data layer. Implement it for specific bounded contexts where you genuinely need the capabilities, not as a blanket approach.
CQRS (Command Query Responsibility Segregation): Separate read and write models make sense when read performance is critical and reads vastly outnumber writes. For most services, a single model with good indexing works fine.
Microservices patterns aren't optional add-ons — they're the infrastructure that makes distributed systems reliable. API gateways centralize cross-cutting concerns. Circuit breakers prevent cascading failures. Distributed tracing makes debugging possible. Service-to-service authentication prevents lateral movement. The database-per-service pattern enables independent evolution. And async-first design reduces latency and coupling. Start with the essentials, add complexity only when the need is clear and measurable.
Each microservice has its own data access layer and its own performance characteristics. JOptimize analyzes each service independently — finding N+1 patterns, missing indexes, and over-fetching that multiply across the service boundary.
Build microservices that perform at every layer.
Master Spring Boot, security, and Java performance with hands-on courses.
JOptimize finds N+1 queries, EAGER collections, and 70+ other issues in your Java codebase — in under 30 seconds.