25 Must-Know Design Patterns for Microservices Architecture
25 essential microservices design patterns with Java, Resilience4j, Spring Cloud, and Kafka.
A Practical Java Guide (with Resilience4j, Spring Cloud, Kafka & More)
This guide walks through 25 essential microservices design patterns. Each section includes: what problem it solves, when to use it, a Java code example, and the common tools/libraries used in real projects (Resilience4j, Spring Cloud, Kafka, Eureka, etc.).
Table of Contents
- Circuit Breaker
- Retry
- Bulkhead
- Saga Pattern
- API Gateway
- Service Discovery
- Database per Service
- Event-Driven Architecture
- CQRS Pattern
- Event Sourcing
- Strangler Fig Pattern
- Sidecar Pattern
- Ambassador Pattern
- Adapter Pattern
- Proxy Pattern
- Factory Pattern
- Strategy Pattern
- Observer Pattern
- Singleton Pattern
- Builder Pattern
- Decorator Pattern
- Repository Pattern
- Dependency Injection Pattern
- Outbox Pattern
- Idempotency Pattern
1. Circuit Breaker
Problem it solves: Prevents cascading failures by stopping requests to a service that is already failing, giving it time to recover instead of piling on more load.
When to use: Any time Service A calls Service B over the network and B might become slow or unavailable.
Common tool: Resilience4j (or Hystrix, now deprecated).
// build.gradle: implementation 'io.github.resilience4j:resilience4j-spring-boot3'
@Service
public class InventoryClient {
private final RestTemplate restTemplate;
public InventoryClient(RestTemplate restTemplate) {
this.restTemplate = restTemplate;
}
@CircuitBreaker(name = "inventoryService", fallbackMethod = "fallbackStock")
public StockResponse checkStock(String productId) {
return restTemplate.getForObject(
"http://inventory-service/stock/" + productId, StockResponse.class);
}
// Fallback signature must match + extra Throwable param
private StockResponse fallbackStock(String productId, Throwable t) {
return new StockResponse(productId, 0, "UNAVAILABLE");
}
}
# application.yml
resilience4j.circuitbreaker:
instances:
inventoryService:
sliding-window-size: 10
failure-rate-threshold: 50
wait-duration-in-open-state: 5s
permitted-number-of-calls-in-half-open-state: 3
When the failure rate crosses 50% over the last 10 calls, the circuit “opens” and calls fail fast (invoking the fallback) for 5 seconds before allowing a few trial calls through (half-open state).
2. Retry
Problem it solves: Automatically retries a failed request that may have failed due to a transient issue (network blip, brief overload) before giving up.
When to use: Idempotent operations (GET, or safe-to-repeat POST/PUT) where the failure is likely temporary.
Common tool: Resilience4j Retry, Spring Retry.
@Retry(name = "paymentService", fallbackMethod = "fallbackCharge")
public ChargeResponse chargeCard(ChargeRequest request) {
return paymentClient.charge(request);
}
private ChargeResponse fallbackCharge(ChargeRequest request, Throwable t) {
return ChargeResponse.failed("Payment service unavailable after retries");
}
resilience4j.retry:
instances:
paymentService:
max-attempts: 3
wait-duration: 500ms
retry-exceptions:
- java.io.IOException
- org.springframework.web.client.ResourceAccessException
Tip: Combine Retry with Circuit Breaker - Retry should give up quickly once the Circuit Breaker is open, to avoid hammering a known-down service.
3. Bulkhead
Problem it solves: Isolates resources (thread pools, connection pools) per downstream dependency so that one slow/failing service can’t exhaust resources needed by others.
When to use: When a service calls multiple downstream dependencies and you don’t want one to starve the others.
Common tool: Resilience4j Bulkhead (semaphore or thread-pool based).
@Bulkhead(name = "reportingService", type = Bulkhead.Type.THREADPOOL)
public CompletableFuture<Report> generateReport(String orgId) {
return CompletableFuture.supplyAsync(() -> reportingClient.generate(orgId));
}
resilience4j.thread-pool-bulkhead:
instances:
reportingService:
max-thread-pool-size: 10
core-thread-pool-size: 5
queue-capacity: 20
This caps the reporting service’s calls to a dedicated pool of 10 threads, so a slow reporting backend can never starve threads needed by, say, the checkout flow.
4. Saga Pattern
Problem it solves: Manages distributed transactions across services using a sequence of local transactions, each with a compensating action if a later step fails.
When to use: Multi-step business processes spanning several services (e.g., Order → Payment → Inventory → Shipping) where a 2PC distributed transaction isn’t feasible.
Common tools: Axon Framework, Camunda, Spring State Machine, or hand-rolled with Kafka events (choreography) or an orchestrator service.
// Orchestration-based saga using a simple state machine
public class OrderSagaOrchestrator {
public void startSaga(Order order) {
try {
paymentService.charge(order.getPaymentInfo());
inventoryService.reserve(order.getItems());
shippingService.schedule(order);
orderService.markCompleted(order.getId());
} catch (PaymentException e) {
orderService.markFailed(order.getId(), "Payment failed");
} catch (InventoryException e) {
paymentService.refund(order.getPaymentInfo()); // compensating action
orderService.markFailed(order.getId(), "Inventory unavailable");
} catch (ShippingException e) {
inventoryService.release(order.getItems()); // compensating action
paymentService.refund(order.getPaymentInfo()); // compensating action
orderService.markFailed(order.getId(), "Shipping failed");
}
}
}
For a choreography-based saga, each service publishes a domain event (OrderCreated, PaymentCharged, InventoryReserved) to Kafka, and the next service in line subscribes and reacts - with each step also publishing a compensating event (PaymentRefunded) on failure.
5. API Gateway
Problem it solves: Provides a single entry point for clients, handling routing, authentication, rate limiting, SSL termination, and caching so individual services don’t each reimplement these concerns.
When to use: Whenever you have multiple client types (mobile, web, partner) hitting multiple backend services.
Common tool: Spring Cloud Gateway.
@Configuration
public class GatewayRoutesConfig {
@Bean
public RouteLocator routes(RouteLocatorBuilder builder) {
return builder.routes()
.route("order-service", r -> r.path("/api/orders/**")
.filters(f -> f
.circuitBreaker(c -> c.setName("orderServiceCB")
.setFallbackUri("forward:/fallback/orders"))
.requestRateLimiter(rl -> rl.setRateLimiter(redisRateLimiter())))
.uri("lb://ORDER-SERVICE"))
.route("payment-service", r -> r.path("/api/payments/**")
.uri("lb://PAYMENT-SERVICE"))
.build();
}
@Bean
public RedisRateLimiter redisRateLimiter() {
return new RedisRateLimiter(10, 20); // 10 req/sec, burst 20
}
}
The Gateway also commonly handles JWT validation in a global filter before requests are forwarded to lb:// (load-balanced) backend services registered in Eureka.
6. Service Discovery
Problem it solves: Lets services register and discover each other dynamically, avoiding hardcoded hostnames/ports.
When to use: Any environment where service instances scale up/down or move (containers, cloud auto-scaling).
Common tool: Netflix Eureka (Spring Cloud Netflix), Consul.
// Order Service - registers itself
@SpringBootApplication
@EnableDiscoveryClient
public class OrderServiceApplication {
public static void main(String[] args) {
SpringApplication.run(OrderServiceApplication.class, args);
}
}
// Payment Service - discovers Order Service via logical name, not hardcoded IP
@Service
public class OrderClient {
private final RestTemplate restTemplate;
public OrderClient(@LoadBalanced RestTemplate restTemplate) {
this.restTemplate = restTemplate;
}
public Order getOrder(String id) {
return restTemplate.getForObject("http://ORDER-SERVICE/orders/" + id, Order.class);
}
}
eureka:
client:
service-url:
defaultZone: http://eureka-server:8761/eureka
7. Database per Service
Problem it solves: Each microservice owns its own database schema/instance, improving independence and letting each team choose the right storage tech and scale independently.
When to use: Standard practice for any properly decoupled microservices architecture - avoid a single shared database.
// Order Service - PostgreSQL
@Entity
@Table(name = "orders")
public class Order {
@Id @GeneratedValue
private Long id;
private String customerId;
private BigDecimal totalAmount;
// Order service NEVER queries the Payment or Inventory DB directly
}
// application.yml for order-service
spring:
datasource:
url: jdbc:postgresql://order-db:5432/orders_db
// application.yml for inventory-service (different tech entirely)
spring:
data:
mongodb:
uri: mongodb://inventory-db:27017/inventory_db
Cross-service data needs are satisfied via API calls or async events (see Event-Driven Architecture below) - never direct SQL joins across service boundaries.
8. Event-Driven Architecture
Problem it solves: Services communicate asynchronously through events instead of direct synchronous calls, achieving loose coupling and better scalability.
When to use: When services need to react to state changes in other services without tight coupling or blocking calls.
Common tool: Apache Kafka, RabbitMQ.
// Order Service - publishes an event
@Service
public class OrderService {
private final KafkaTemplate<String, OrderCreatedEvent> kafkaTemplate;
public void createOrder(Order order) {
orderRepository.save(order);
OrderCreatedEvent event = new OrderCreatedEvent(order.getId(), order.getItems());
kafkaTemplate.send("order-events", order.getId().toString(), event);
}
}
// Payment Service - consumes the event
@Component
public class OrderCreatedListener {
@KafkaListener(topics = "order-events", groupId = "payment-service")
public void handleOrderCreated(OrderCreatedEvent event) {
paymentService.processPayment(event.getOrderId(), event.getItems());
}
}
// Inventory Service and Notification Service subscribe to the SAME topic independently
@KafkaListener(topics = "order-events", groupId = "inventory-service")
public void reserveStock(OrderCreatedEvent event) { /* ... */ }
9. CQRS Pattern (Command Query Responsibility Segregation)
Problem it solves: Separates the write model (commands) from the read model (queries) so each can be optimized and scaled independently.
When to use: High-read, complex-query systems where the write model’s normalized shape isn’t ideal for reads (dashboards, reporting).
// WRITE side - command model
@Service
public class CreateOrderCommandHandler {
private final OrderWriteRepository writeRepository; // normalized write DB
public void handle(CreateOrderCommand command) {
Order order = new Order(command.getCustomerId(), command.getItems());
writeRepository.save(order);
eventPublisher.publish(new OrderCreatedEvent(order));
}
}
// READ side - separate, denormalized query model, updated asynchronously
@Component
public class OrderReadModelUpdater {
@KafkaListener(topics = "order-events")
public void on(OrderCreatedEvent event) {
OrderSummaryView view = new OrderSummaryView(
event.getOrderId(), event.getCustomerName(), event.getTotal());
readRepository.save(view); // e.g. Elasticsearch or a read-replica table
}
}
@RestController
public class OrderQueryController {
@GetMapping("/orders/{id}/summary")
public OrderSummaryView getSummary(@PathVariable String id) {
return readRepository.findById(id); // fast, denormalized read
}
}
10. Event Sourcing
Problem it solves: Instead of storing current state, stores the full sequence of events; current state is rebuilt by replaying events when needed.
When to use: Domains needing full audit history, temporal queries (“what was the state at time T”), or complex undo/replay requirements (banking ledgers, order lifecycles).
Common tool: Axon Framework, EventStoreDB, or a custom event table.
public class Account {
private String accountId;
private BigDecimal balance = BigDecimal.ZERO;
private final List<Object> uncommittedEvents = new ArrayList<>();
public void deposit(BigDecimal amount) {
apply(new MoneyDepositedEvent(accountId, amount));
}
public void withdraw(BigDecimal amount) {
if (balance.compareTo(amount) < 0) throw new InsufficientFundsException();
apply(new MoneyWithdrawnEvent(accountId, amount));
}
private void apply(Object event) {
mutate(event);
uncommittedEvents.add(event);
}
private void mutate(Object event) {
if (event instanceof MoneyDepositedEvent e) balance = balance.add(e.amount());
if (event instanceof MoneyWithdrawnEvent e) balance = balance.subtract(e.amount());
}
// Rebuild state from history
public static Account rehydrate(String id, List<Object> history) {
Account account = new Account();
account.accountId = id;
history.forEach(account::mutate);
return account;
}
}
The EventStore persists MoneyDepositedEvent/MoneyWithdrawnEvent rows; current balance is always derivable by replaying them, and you get a full audit trail for free.
11. Strangler Fig Pattern
Problem it solves: Gradually replaces parts of a legacy monolith with microservices, routing traffic incrementally to the new services until the monolith can be retired.
When to use: Migrating a monolith to microservices without a risky big-bang rewrite.
// A facade/gateway routes based on which feature has been migrated
@RestController
public class StranglerFacadeController {
private final NewOrderServiceClient newOrderService;
private final LegacyMonolithClient legacyMonolith;
@GetMapping("/orders/{id}")
public ResponseEntity<Order> getOrder(@PathVariable String id) {
if (featureFlags.isEnabled("orders-migrated-to-microservice")) {
return ResponseEntity.ok(newOrderService.getOrder(id));
}
return ResponseEntity.ok(legacyMonolith.getOrder(id));
}
}
Over time, more endpoints are flipped from legacyMonolith to newOrderService until the facade routes 100% of traffic to microservices and the monolith is decommissioned.
12. Sidecar Pattern
Problem it solves: Deploys a helper component (logging, security, monitoring, service mesh proxy) alongside a service, in its own process/container, to extend functionality without changing the main service’s code.
When to use: Cross-cutting concerns like observability, mTLS, or traffic management - most commonly implemented via a service mesh.
Common tool: Envoy proxy (Istio), Kubernetes sidecar containers.
# Kubernetes pod with app + sidecar
apiVersion: v1
kind: Pod
metadata:
name: order-service-pod
spec:
containers:
- name: order-service
image: order-service:1.0
ports:
- containerPort: 8080
- name: envoy-sidecar
image: envoyproxy/envoy:v1.28
ports:
- containerPort: 9901 # admin/metrics
// The Java app doesn't need to know about mTLS, retries, or tracing headers -
// the sidecar (Envoy) intercepts traffic transparently.
@RestController
public class OrderController {
@GetMapping("/orders/{id}")
public Order getOrder(@PathVariable String id) {
return orderService.findById(id); // business logic only
}
}
13. Ambassador Pattern
Problem it solves: A proxy acts on behalf of a service to handle cross-cutting concerns like retries, circuit breaking, or protocol translation for outgoing calls - similar to a sidecar, but focused on outbound traffic.
When to use: When you want to offload client-side networking logic (retry, TLS, monitoring) from application code into a separate local proxy process.
// Application code calls a LOCAL ambassador proxy, unaware of the real external services
@Service
public class ExternalServiceClient {
private final RestTemplate restTemplate;
public ServiceAResponse callServiceA(Request req) {
// Calls localhost ambassador, which forwards to the real "External Service 1"
// and applies retry/circuit-breaking/logging on the way
return restTemplate.postForObject("http://localhost:9000/service-a", req, ServiceAResponse.class);
}
public ServiceBResponse callServiceB(Request req) {
return restTemplate.postForObject("http://localhost:9001/service-b", req, ServiceBResponse.class);
}
}
The ambassador (often a small Envoy or custom Go/Java proxy running as a sidecar container) handles TLS, retries, and circuit breaking uniformly for every service that uses it, without duplicating that logic in each codebase.
14. Adapter Pattern
Problem it solves: Helps incompatible interfaces work together - especially useful for integrating a legacy system with a modern API contract.
When to use: When wrapping a legacy system, third-party SDK, or differently-shaped API behind the interface your application expects.
// Target interface your modern application expects
public interface PaymentGateway {
PaymentResult pay(BigDecimal amount, String currency);
}
// Legacy system with an incompatible interface
public class LegacyPaymentSystem {
public LegacyResponseCode processPaymentInCents(long amountInCents, String currencyCode) {
// old SOAP/legacy call
return LegacyResponseCode.OK;
}
}
// Adapter bridges the two
public class LegacyPaymentAdapter implements PaymentGateway {
private final LegacyPaymentSystem legacySystem;
@Override
public PaymentResult pay(BigDecimal amount, String currency) {
long cents = amount.multiply(BigDecimal.valueOf(100)).longValue();
LegacyResponseCode code = legacySystem.processPaymentInCents(cents, currency);
return code == LegacyResponseCode.OK ? PaymentResult.success() : PaymentResult.failure();
}
}
Your service depends only on PaymentGateway - swapping the legacy system for a modern one later means writing a new adapter, not rewriting business logic.
15. Proxy Pattern
Problem it solves: A proxy controls access to the real service, commonly used for security (auth checks), caching, or logging - adding behavior transparently in front of the real object.
When to use: When you need to add access control, caching, or lazy loading in front of a service without changing it.
public interface ProductService {
Product getProduct(String id);
}
public class RealProductService implements ProductService {
@Override
public Product getProduct(String id) {
return database.findById(id); // expensive DB call
}
}
public class CachingProductServiceProxy implements ProductService {
private final RealProductService realService;
private final Map<String, Product> cache = new ConcurrentHashMap<>();
public CachingProductServiceProxy(RealProductService realService) {
this.realService = realService;
}
@Override
public Product getProduct(String id) {
return cache.computeIfAbsent(id, realService::getProduct);
}
}
Spring itself uses this pattern internally - @Cacheable, @Transactional, and Spring Security method-level checks are all implemented via dynamic proxies wrapping your beans.
16. Factory Pattern
Problem it solves: Creates objects without exposing the instantiation logic to the client, promoting loose coupling between object creation and usage.
When to use: When object creation involves logic/branching (choosing an implementation based on type) that shouldn’t leak into calling code.
public interface NotificationSender {
void send(String recipient, String message);
}
public class EmailSender implements NotificationSender { /* ... */ }
public class SmsSender implements NotificationSender { /* ... */ }
public class PushSender implements NotificationSender { /* ... */ }
public class NotificationSenderFactory {
public static NotificationSender create(NotificationType type) {
return switch (type) {
case EMAIL -> new EmailSender();
case SMS -> new SmsSender();
case PUSH -> new PushSender();
};
}
}
// Usage
NotificationSender sender = NotificationSenderFactory.create(NotificationType.SMS);
sender.send("+905551234567", "Your order has shipped!");
17. Strategy Pattern
Problem it solves: Defines a family of interchangeable algorithms and lets the client pick one at runtime, avoiding large if/else or switch blocks.
When to use: Multiple ways to perform the same operation (pricing rules, shipping cost calculation, discount strategies).
public interface ShippingStrategy {
BigDecimal calculate(Order order);
}
public class StandardShipping implements ShippingStrategy {
public BigDecimal calculate(Order order) { return BigDecimal.valueOf(5.99); }
}
public class ExpressShipping implements ShippingStrategy {
public BigDecimal calculate(Order order) { return BigDecimal.valueOf(15.99); }
}
public class ShippingCalculator {
private final ShippingStrategy strategy;
public ShippingCalculator(ShippingStrategy strategy) {
this.strategy = strategy; // injected at runtime, e.g. by Spring @Qualifier
}
public BigDecimal calculateCost(Order order) {
return strategy.calculate(order);
}
}
18. Observer Pattern
Problem it solves: Establishes a one-to-many dependency where multiple observers are automatically notified when a subject changes state.
When to use: In-process event notification (as opposed to Event-Driven Architecture, which is the cross-service equivalent). Common in Spring via ApplicationEventPublisher.
// Event
public record OrderPlacedEvent(String orderId, BigDecimal total) {}
// Subject publishes
@Service
public class OrderService {
private final ApplicationEventPublisher publisher;
public void placeOrder(Order order) {
orderRepository.save(order);
publisher.publishEvent(new OrderPlacedEvent(order.getId(), order.getTotal()));
}
}
// Multiple observers react independently
@Component
public class LoyaltyPointsObserver {
@EventListener
public void onOrderPlaced(OrderPlacedEvent event) {
loyaltyService.addPoints(event.orderId(), event.total());
}
}
@Component
public class AnalyticsObserver {
@EventListener
public void onOrderPlaced(OrderPlacedEvent event) {
analyticsService.track(event);
}
}
19. Singleton Pattern
Problem it solves: Ensures a class has only one instance and provides a global access point to it.
When to use: Shared, stateless (or carefully synchronized) resources like configuration holders or connection pools. Note: in Spring, every @Bean/@Service is a singleton by default - you rarely hand-roll this.
public class ConfigurationManager {
private static volatile ConfigurationManager instance;
private final Map<String, String> settings = new ConcurrentHashMap<>();
private ConfigurationManager() { /* load settings */ }
public static ConfigurationManager getInstance() {
if (instance == null) {
synchronized (ConfigurationManager.class) {
if (instance == null) {
instance = new ConfigurationManager();
}
}
}
return instance;
}
}
// The idiomatic Spring way - singleton scope is the default:
@Service // one shared instance managed by the Spring container
public class ConfigurationService {
// ...
}
20. Builder Pattern
Problem it solves: Builds complex objects step by step, useful for objects with many optional fields (immutable objects, DTOs) without telescoping constructors.
When to use: Objects with many parameters, especially optional ones, or when you want immutability with readable construction code.
public final class OrderRequest {
private final String customerId;
private final List<OrderItem> items;
private final String couponCode; // optional
private final boolean giftWrap; // optional
private OrderRequest(Builder builder) {
this.customerId = builder.customerId;
this.items = builder.items;
this.couponCode = builder.couponCode;
this.giftWrap = builder.giftWrap;
}
public static class Builder {
private String customerId;
private List<OrderItem> items = new ArrayList<>();
private String couponCode;
private boolean giftWrap = false;
public Builder customerId(String customerId) { this.customerId = customerId; return this; }
public Builder items(List<OrderItem> items) { this.items = items; return this; }
public Builder couponCode(String couponCode) { this.couponCode = couponCode; return this; }
public Builder giftWrap(boolean giftWrap) { this.giftWrap = giftWrap; return this; }
public OrderRequest build() { return new OrderRequest(this); }
}
}
// Usage
OrderRequest request = new OrderRequest.Builder()
.customerId("cust-123")
.items(cartItems)
.couponCode("SAVE10")
.giftWrap(true)
.build();
In modern Java,
record+ a builder library (like Lombok’s@Builder) is often used instead of hand-writing this boilerplate.
21. Decorator Pattern
Problem it solves: Dynamically adds behavior to an object without altering its code, by wrapping it in one or more decorator layers.
When to use: Adding cross-cutting behavior (logging, caching, validation) around a core implementation, composably.
public interface OrderProcessor {
void process(Order order);
}
public class BasicOrderProcessor implements OrderProcessor {
@Override
public void process(Order order) { /* core processing logic */ }
}
public class LoggingOrderProcessorDecorator implements OrderProcessor {
private final OrderProcessor delegate;
public LoggingOrderProcessorDecorator(OrderProcessor delegate) { this.delegate = delegate; }
@Override
public void process(Order order) {
log.info("Processing order {}", order.getId());
delegate.process(order);
log.info("Finished order {}", order.getId());
}
}
public class ValidatingOrderProcessorDecorator implements OrderProcessor {
private final OrderProcessor delegate;
public ValidatingOrderProcessorDecorator(OrderProcessor delegate) { this.delegate = delegate; }
@Override
public void process(Order order) {
if (order.getItems().isEmpty()) throw new IllegalArgumentException("Empty order");
delegate.process(order);
}
}
// Compose decorators
OrderProcessor processor = new LoggingOrderProcessorDecorator(
new ValidatingOrderProcessorDecorator(
new BasicOrderProcessor()));
processor.process(order);
22. Repository Pattern
Problem it solves: Abstracts data access logic behind a collection-like interface, making code cleaner and easier to unit test (by mocking the repository).
When to use: Virtually always in Spring Data applications - it’s the standard way to separate persistence details from business logic.
public interface OrderRepository extends JpaRepository<Order, Long> {
List<Order> findByCustomerId(String customerId);
Optional<Order> findByIdAndStatus(Long id, OrderStatus status);
}
@Service
public class OrderService {
private final OrderRepository orderRepository; // business logic never touches SQL/JDBC directly
public List<Order> getCustomerOrders(String customerId) {
return orderRepository.findByCustomerId(customerId);
}
}
Spring Data JPA auto-generates the implementation of OrderRepository at runtime from the method name conventions - no manual DAO boilerplate needed.
23. Dependency Injection Pattern
Problem it solves: Dependencies are provided from outside rather than constructed internally, promoting loose coupling and testability.
When to use: Universally in Spring - it’s the foundation of the framework (the “IoC container”).
public interface NotificationService {
void notify(String userId, String message);
}
@Service
public class EmailNotificationService implements NotificationService {
@Override
public void notify(String userId, String message) { /* send email */ }
}
@Service
public class OrderService {
private final NotificationService notificationService; // injected, not "new EmailNotificationService()"
// Constructor injection - preferred over field injection
public OrderService(NotificationService notificationService) {
this.notificationService = notificationService;
}
public void completeOrder(Order order) {
// ...
notificationService.notify(order.getCustomerId(), "Order completed!");
}
}
In a test, you can inject a mock NotificationService without touching OrderService’s code at all - that’s the whole point of DI.
24. Outbox Pattern
Problem it solves: Ensures reliable event publishing even if the service crashes between the DB update and the message broker publish, by writing the event to an “outbox” table in the same DB transaction.
When to use: Whenever a service needs to atomically update its own DB AND publish an event - avoiding the classic “dual write” problem.
Common tool: Debezium (CDC) + Kafka, or a polling publisher.
@Service
public class OrderService {
@Transactional
public void createOrder(Order order) {
orderRepository.save(order); // 1. business transaction (DB update)
OutboxEvent event = new OutboxEvent(
UUID.randomUUID().toString(),
"Order",
order.getId().toString(),
"OrderCreated",
toJson(order)
);
outboxRepository.save(event); // 2. write to outbox table - SAME transaction, SAME commit
}
}
// 3. A separate poller (or Debezium CDC) publishes to the broker
@Scheduled(fixedDelay = 500)
public void publishOutboxEvents() {
List<OutboxEvent> pending = outboxRepository.findUnpublished();
for (OutboxEvent event : pending) {
kafkaTemplate.send("order-events", event.getPayload());
outboxRepository.markPublished(event.getId());
}
}
Because step 1 and step 2 happen in the same DB transaction, either both succeed or both roll back - there’s never a state where the order is saved but the event is silently lost.
25. Idempotency Pattern
Problem it solves: Ensures duplicate requests (from client retries, network timeouts, or message re-delivery) don’t cause duplicate processing (e.g., double-charging a customer).
When to use: Any operation that a client might retry, or any message consumer reading from an at-least-once delivery queue (like Kafka).
@RestController
public class PaymentController {
private final IdempotencyKeyRepository idempotencyRepo;
private final PaymentService paymentService;
@PostMapping("/payments")
public ResponseEntity<PaymentResult> charge(
@RequestHeader("Idempotency-Key") String idempotencyKey,
@RequestBody ChargeRequest request) {
Optional<PaymentResult> existing = idempotencyRepo.findResult(idempotencyKey);
if (existing.isPresent()) {
return ResponseEntity.ok(existing.get()); // return previous response, don't re-process
}
PaymentResult result = paymentService.charge(request);
idempotencyRepo.save(idempotencyKey, result);
return ResponseEntity.ok(result);
}
}
// Same principle for Kafka consumers (at-least-once delivery)
@KafkaListener(topics = "payment-events")
public void handle(PaymentEvent event) {
if (processedEventRepository.existsById(event.getEventId())) {
return; // already processed - skip
}
paymentService.process(event);
processedEventRepository.save(new ProcessedEvent(event.getEventId()));
}
Key Principles Behind Microservices
| Principle | What it means |
|---|---|
| Decentralized Data Management | Each service owns its data; no shared database. |
| Independent Deployment | Services can be deployed without redeploying the whole system. |
| Fault Isolation | A failure in one service shouldn’t cascade to others. |
| Loose Coupling | Services interact via well-defined contracts (APIs/events), not internal details. |
| High Availability | Redundancy and resilience patterns keep the system running despite partial failures. |
Common Tools & Tech Stack
| Category | Tools |
|---|---|
| Resilience | Resilience4j, Hystrix (legacy) |
| Service Discovery | Eureka, Consul |
| API Gateway | Spring Cloud Gateway, Kong |
| Messaging | Apache Kafka, RabbitMQ |
| Observability | Jaeger, Zipkin, ELK Stack, Prometheus, Grafana |
| Containers/Orchestration | Docker, Kubernetes |
| Config Management | Spring Cloud Config, Consul |
Good architecture is about making the right trade-offs today for a better tomorrow. Design it smart. Build it strong. Scale it endlessly.