Patterns — Cheat Sheet
Microservices · 6 topics. Download the PDF or the Instagram carousel and share it.
Strangler Fig Pattern
Incrementally migrate a monolith by routing individual features to new microservices behind an API gateway until the monolith is completely replaced.
- ✓Strangler Fig: add a gateway, route features to new services incrementally, shrink the monolith
- ✓No big-bang cutover — both monolith and services run in parallel; rollback is a route toggle
- ✓Use Debezium CDC or dual-write to migrate data ownership from monolith to new service DB
- ✓A distributed monolith (shared DB + synchronous coupling) is worse than the original monolith
- ✓Each extracted service must be independently deployable, scalable, and own its data
- ✓Extract high-ROI, low-coupling features first (e.g., notifications, reporting)
# Spring Cloud Gateway — strangler routing config
# Phase 1: everything goes to monolith
spring:
cloud:
gateway:
routes:
- id: monolith
uri: http://monolith-service
predicates: [Path=/**]
# Phase 2: order endpoints → new order-service, rest → monolith
spring:
cloud:
gateway:
routes:
- id: order-service
uri: http://order-service
predicates: [Path=/api/orders/**, /api/order-items/**]
order: 1
- id: monolith-fallback
uri: http://monolith-service
predicates: [Path=/**]
order: 100 # lower precedenceIdempotency in APIs
An idempotent operation produces the same result when called multiple times; implement via idempotency keys stored in Redis or a DB to deduplicate retries.
- ✓HTTP GET and PUT are naturally idempotent; POST, PATCH, and DELETE need explicit deduplication logic.
- ✓Idempotency keys must be client-generated (UUID v4) and scoped to a single logical operation, not per-retry.
- ✓Redis SET NX with TTL is the fastest implementation; a DB table is better when you need audit history.
- ✓The check-and-store must be atomic — race conditions between two identical concurrent requests can cause double-processing.
- ✓Store the full response body, not just a "done" flag, so replays return identical HTTP status and payload.
- ✓Set a reasonable TTL (24h–7d) to bound storage growth; communicate the window to API consumers.
@Component
public class IdempotencyInterceptor implements HandlerInterceptor {
private final StringRedisTemplate redis;
@Override
public boolean preHandle(HttpServletRequest req,
HttpServletResponse res, Object handler) throws Exception {
String key = req.getHeader("Idempotency-Key");
if (key == null) return true; // key optional for GET
String cached = redis.opsForValue().get("idem:" + key);
if (cached != null) {
res.setStatus(200);
res.setContentType("application/json");
res.getWriter().write(cached);
return false; // short-circuit, reply cached
}
return true;
}
// Call this from controller advice after response committed
public void cacheResponse(String key, String responseBody) {
redis.opsForValue().set("idem:" + key, responseBody,
Duration.ofHours(24));
}
}Feature Flags in Microservices
Feature flags decouple deployment from release; toggle features per environment or user segment without redeploying, enabling dark launches and A/B tests.
- ✓Feature flags decouple deployment (code merged and deployed) from release (feature visible to users)
- ✓OpenFeature provides a vendor-neutral SDK — swap providers (LaunchDarkly, Unleash, Flagsmith) without code changes
- ✓Evaluation context (userId, country, plan) enables targeting: specific user segments get different flag values
- ✓Kill switches must be pre-tested and accessible to on-call engineers — not just developers — for true operational value
- ✓Gradual rollout (1% → 10% → 50% → 100%) enables safe production validation before full release
- ✓Remove flags from code after full rollout — dead flags become technical debt and confuse new developers
// application.yml — feature flags as properties
features:
new-checkout-flow: true
experimental-pricing: false
max-order-size-check: true
// Configuration properties class
@ConfigurationProperties(prefix = "features")
@Component
public class FeatureFlags {
private boolean newCheckoutFlow;
private boolean experimentalPricing;
private boolean maxOrderSizeCheck;
// getters/setters or use @ConstructorBinding record
}
// Usage in service
@Service
public class CheckoutService {
private final FeatureFlags flags;
public CheckoutResult checkout(Cart cart, User user) {
if (flags.isNewCheckoutFlow()) {
return newCheckoutFlowService.process(cart, user);
}
return legacyCheckoutService.process(cart, user);
}
}
// Toggle without redeployment via K8s ConfigMap reload
# kubectl patch configmap app-config
# --patch '{"data":{"features.new-checkout-flow":"true"}}'
# (requires spring.config.import=kubernetes: + @RefreshScope or restart)Backward Compatibility & Contract Testing
Consumer-Driven Contract Testing (Pact) verifies that provider APIs remain compatible with consumer expectations, catching breaking changes before they reach production.
- ✓Consumer-Driven Contract Testing catches breaking API changes in provider CI before they reach production
- ✓Pact contracts define minimum required response shape — type-matching (not value-matching) makes contracts resilient
- ✓Provider state ("given()" in Pact) sets up test data to match each consumer scenario — must be deterministic
- ✓Publish verification results to Pact Broker with git commit/branch — enables "can-i-deploy" checks in deployment pipelines
- ✓Avro/JSON Schema BACKWARD compatibility: add optional fields; NOT backward compatible: remove/rename required fields
- ✓FULL compatibility (add optional fields with defaults) is the safest evolution strategy for bidirectional compatibility
// Consumer side — define contract in a consumer test
@ExtendWith(PactConsumerTestExt.class)
@PactTestFor(providerName = "inventory-service")
public class InventoryClientContractTest {
@Pact(consumer = "order-service")
public RequestResponsePact stockLookupPact(PactDslWithProvider builder) {
return builder
.given("product 101 exists with stock 50")
.uponReceiving("a request for product stock")
.path("/stock/101")
.method("GET")
.willRespondWith()
.status(200)
.body(new PactDslJsonBody()
.integerType("productId", 101) // type-only matching (flexible)
.integerType("quantity", 50)
.stringType("unit", "units"))
.toPact();
}
@Test
@PactTestFor(pactMethod = "stockLookupPact")
public void testStockLookup(MockServer mockServer) {
InventoryClient client = new InventoryClient(mockServer.getUrl());
StockResponse response = client.getStock(101L);
assertThat(response.getQuantity()).isGreaterThanOrEqualTo(0);
// Pact generates a contract JSON file — publish to Pact Broker
}
}Graceful Shutdown
On SIGTERM, stop accepting new requests, drain in-flight requests within a configurable timeout, then close connections — Spring Boot handles this via server.shutdown=graceful.
- ✓server.shutdown=graceful stops accepting new requests and waits for in-flight requests to finish
- ✓spring.lifecycle.timeout-per-shutdown-phase controls the drain window (default: 30s)
- ✓Kubernetes preStop sleep gives the load balancer time to deregister the pod before SIGTERM
- ✓terminationGracePeriodSeconds = preStop time + drain window + buffer (set to their sum)
- ✓Kafka consumers drain via SmartLifecycle.stop() — called automatically during context shutdown
- ✓Readiness probe /actuator/health/readiness goes DOWN on shutdown — prevents new traffic routing
# application.properties
server.shutdown=graceful
spring.lifecycle.timeout-per-shutdown-phase=20s # wait up to 20s for in-flight HTTP requests
# --- Kubernetes Deployment YAML ---
spec:
containers:
- name: order-service
lifecycle:
preStop:
exec:
command: ["sh", "-c", "sleep 5"] # wait 5s after deregister before SIGTERM
terminationGracePeriodSeconds: 30 # total time before SIGKILL
# Timeline:
# t=0: SIGTERM sent + preStop hook runs (5s sleep)
# t=5: Spring receives SIGTERM → stops accepting new requests
# t=25: Drain window closes (20s)
# t=30: SIGKILL if process still runningMicroservices Anti-Patterns
Common pitfalls include distributed monolith (tight coupling), chatty services, shared databases, synchronous call chains, and under-invested observability infrastructure.
- ✓Distributed Monolith: services that cannot be independently deployed due to shared DB, circular deps, or shared domain models
- ✓Shared database: the most common coupling source — each service must own its data; share via API or events
- ✓Chatty services: N serial round trips per user request — solve with BFF aggregation or batch APIs
- ✓Deep synchronous chains (A→B→C→D): failure rate and latency multiply — break with async events for non-critical paths
- ✓Nano-services: overhead without benefit — merge if it cannot be independently developed and owned by a small team
- ✓Under-invested observability: the #1 operational pain — structured logs, RED metrics, tracing, and alerting are non-negotiable
// ANTI-PATTERN: Shared Database — OrderService and InventoryService
// both access the same "shop" database directly
// OrderService
@Repository
public interface OrderRepository extends JpaRepository<Order, Long> {
// queries shop.orders table
}
// InventoryService — WRONG: directly queries shop.orders to check order status
@Repository
public interface OrderQueryRepository extends JpaRepository<Order, Long> {
// BAD: InventoryService depends on OrderService's database schema
@Query("SELECT o FROM Order o WHERE o.status = 'PLACED'")
List<Order> findPlacedOrders();
}
// SOLUTION: Each service owns its data; cross-service data via API or events
// OrderService exposes: GET /orders?status=PLACED
// InventoryService calls OrderService's API (or subscribes to order-placed events)
// Or better: InventoryService maintains its own read model
// populated by consuming "order-placed" Kafka events
@KafkaListener(topics = "order-placed")
public void onOrderPlaced(OrderPlacedEvent event) {
// InventoryService maintains its own copy of relevant order data
pendingOrderRepository.save(new PendingOrder(event.getOrderId(), event.getItems()));
}