Building microservices with Spring Boot is straightforward—making them work well in production is the challenge. After architecting dozens of microservices systems, I’ve learned that success depends more on choosing the right patterns than the technology stack.
This guide covers the essential patterns that make Spring Boot microservices reliable, scalable, and maintainable in real-world environments.
Foundation: Service Architecture Patterns
1. Domain-Driven Service Boundaries
The biggest mistake teams make is creating services around technical concerns instead of business domains. Here’s the right approach:
// Wrong: Technical boundaries
@Service
public class DatabaseService { }
@Service
public class EmailService { }
@Service
public class CacheService { }
// Right: Business domain boundaries
@SpringBootApplication
public class OrderService { }
@SpringBootApplication
public class CustomerService { }
@SpringBootApplication
public class PaymentService { }
Service sizing principle: A service should be owned by a team of 6-8 people maximum. If you need more people to maintain it, it’s too big.
2. Database-per-Service Pattern
Each microservice owns its data and never directly accesses another service’s database:
@Entity
@Table(name = "orders")
public class Order {
@Id
private String orderId;
// Only store customer ID, not customer details
private String customerId;
// Order-specific data only
private OrderStatus status;
private List<OrderItem> items;
}
@RestController
public class OrderController {
@GetMapping("/orders/{orderId}")
public OrderDto getOrder(@PathVariable String orderId) {
Order order = orderRepository.findById(orderId);
// Get customer details via API call, not database join
CustomerDto customer = customerService.getCustomer(order.getCustomerId());
return OrderDto.builder()
.order(order)
.customer(customer)
.build();
}
}
Data consistency strategy: Accept eventual consistency and implement compensation patterns for transactions spanning multiple services.
Service Discovery and Configuration
1. Service Registration with Eureka
Spring Cloud Eureka provides service registration and discovery:
// Eureka Server
@SpringBootApplication
@EnableEurekaServer
public class EurekaServerApplication {
public static void main(String[] args) {
SpringApplication.run(EurekaServerApplication.class, args);
}
}
# eureka-server application.yml
server:
port: 8761
eureka:
instance:
hostname: localhost
client:
register-with-eureka: false
fetch-registry: false
service-url:
default-zone: http://${eureka.instance.hostname}:${server.port}/eureka/
// Service registration
@SpringBootApplication
@EnableEurekaClient
public class OrderServiceApplication {
public static void main(String[] args) {
SpringApplication.run(OrderServiceApplication.class, args);
}
}
# order-service application.yml
spring:
application:
name: order-service
eureka:
client:
service-url:
default-zone: http://localhost:8761/eureka/
instance:
prefer-ip-address: true
metadata-map:
version: 1.0
region: us-east-1
2. Externalized Configuration
Use Spring Cloud Config for centralized configuration management:
@Configuration
@RefreshScope
public class OrderConfiguration {
@Value("${order.max-items:10}")
private int maxItemsPerOrder;
@Value("${order.processing.timeout:30s}")
private Duration processingTimeout;
// Configuration can be refreshed without restart
}
Production tip: Use Git repositories to version your configuration and implement approval workflows for changes.
Communication Patterns
1. Synchronous Communication with Load Balancing
Use OpenFeign for declarative REST clients with built-in load balancing:
@FeignClient(
name = "customer-service",
fallback = CustomerServiceFallback.class
)
public interface CustomerService {
@GetMapping("/customers/{customerId}")
CustomerDto getCustomer(@PathVariable String customerId);
@PostMapping("/customers/{customerId}/validate")
ValidationResult validateCustomer(@PathVariable String customerId);
}
@Component
public class CustomerServiceFallback implements CustomerService {
@Override
public CustomerDto getCustomer(String customerId) {
return CustomerDto.builder()
.customerId(customerId)
.status("UNKNOWN")
.build();
}
}
Load balancing configuration:
customer-service:
ribbon:
NFLoadBalancerRuleClassName: com.netflix.loadbalancer.RoundRobinRule
MaxAutoRetries: 1
MaxAutoRetriesNextServer: 2
OkToRetryOnAllOperations: false
ConnectTimeout: 1000
ReadTimeout: 5000
2. Asynchronous Communication with Events
Use Spring Cloud Stream for event-driven architecture:
@Component
public class OrderEventPublisher {
@Autowired
private RabbitTemplate rabbitTemplate;
public void publishOrderCreated(Order order) {
OrderCreatedEvent event = OrderCreatedEvent.builder()
.orderId(order.getId())
.customerId(order.getCustomerId())
.amount(order.getAmount())
.timestamp(Instant.now())
.build();
rabbitTemplate.convertAndSend(
"order.events",
"order.created",
event
);
}
}
@RabbitListener(queues = "order.created.queue")
@Component
public class PaymentEventHandler {
public void handleOrderCreated(OrderCreatedEvent event) {
// Process payment asynchronously
paymentService.processPayment(
event.getCustomerId(),
event.getAmount()
);
}
}
Event design principles:
- Events should be immutable
- Include all necessary context to avoid callbacks
- Version your events for backward compatibility
- Implement idempotent handlers
3. API Gateway Pattern
Spring Cloud Gateway as the single entry point:
@SpringBootApplication
public class ApiGatewayApplication {
@Bean
public RouteLocator customRouteLocator(RouteLocatorBuilder builder) {
return builder.routes()
.route("order-service", r -> r.path("/api/orders/**")
.filters(f -> f
.circuitBreaker(config -> config
.setName("orderCircuitBreaker")
.setFallbackUri("/fallback/orders"))
.addRequestHeader("X-Gateway", "Spring-Cloud-Gateway"))
.uri("lb://order-service"))
.route("customer-service", r -> r.path("/api/customers/**")
.uri("lb://customer-service"))
.build();
}
}
Gateway configuration with rate limiting:
spring:
cloud:
gateway:
routes:
- id: order-service
uri: lb://order-service
predicates:
- Path=/api/orders/**
filters:
- name: RequestRateLimiter
args:
redis-rate-limiter.replenishRate: 10
redis-rate-limiter.burstCapacity: 20
key-resolver: "#{@userKeyResolver}"
Reliability Patterns
1. Circuit Breaker with Resilience4j
Protect services from cascading failures:
@Component
public class CustomerServiceClient {
private final CircuitBreaker circuitBreaker;
private final CustomerService customerService;
public CustomerServiceClient(CustomerService customerService) {
this.customerService = customerService;
this.circuitBreaker = CircuitBreaker.ofDefaults("customer-service");
// Configure circuit breaker
circuitBreaker.getEventPublisher()
.onStateTransition(event ->
log.info("Circuit breaker state transition: {}", event));
}
public Optional<CustomerDto> getCustomer(String customerId) {
return circuitBreaker.executeSupplier(() -> {
try {
return Optional.of(customerService.getCustomer(customerId));
} catch (Exception e) {
log.warn("Customer service call failed: {}", e.getMessage());
throw e;
}
});
}
}
Circuit breaker configuration:
resilience4j:
circuitbreaker:
instances:
customer-service:
failure-rate-threshold: 50
wait-duration-in-open-state: 30s
sliding-window-type: count_based
sliding-window-size: 10
minimum-number-of-calls: 5
2. Retry Pattern with Exponential Backoff
@Component
public class PaymentServiceClient {
private final Retry retry;
public PaymentServiceClient() {
this.retry = Retry.of("payment-service", RetryConfig.custom()
.maxAttempts(3)
.waitDuration(Duration.ofMillis(500))
.exponentialBackoffMultiplier(2)
.retryOnResult(response -> response == null)
.retryExceptions(ConnectException.class, TimeoutException.class)
.ignoreExceptions(PaymentValidationException.class)
.build());
}
public PaymentResult processPayment(PaymentRequest request) {
return retry.executeSupplier(() ->
paymentService.process(request));
}
}
3. Bulkhead Pattern for Resource Isolation
Isolate critical resources using separate thread pools:
@Configuration
public class AsyncConfiguration implements AsyncConfigurer {
@Bean(name = "orderProcessingExecutor")
public Executor orderProcessingExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(10);
executor.setQueueCapacity(25);
executor.setThreadNamePrefix("OrderProcessing-");
executor.initialize();
return executor;
}
@Bean(name = "paymentProcessingExecutor")
public Executor paymentProcessingExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(3);
executor.setMaxPoolSize(7);
executor.setQueueCapacity(15);
executor.setThreadNamePrefix("PaymentProcessing-");
executor.initialize();
return executor;
}
}
@Service
public class OrderService {
@Async("orderProcessingExecutor")
public CompletableFuture<Void> processOrder(Order order) {
// Order processing logic
return CompletableFuture.completedFuture(null);
}
}
Data Management Patterns
1. Saga Pattern for Distributed Transactions
Implement compensating transactions for cross-service operations:
@Component
public class OrderSagaOrchestrator {
public void createOrder(OrderRequest request) {
SagaTransaction saga = SagaTransaction.create("create-order")
.step("validate-customer")
.action(() -> customerService.validateCustomer(request.getCustomerId()))
.compensate(() -> customerService.releaseValidation(request.getCustomerId()))
.step("reserve-inventory")
.action(() -> inventoryService.reserve(request.getItems()))
.compensate(() -> inventoryService.releaseReservation(request.getItems()))
.step("process-payment")
.action(() -> paymentService.charge(request.getPayment()))
.compensate(() -> paymentService.refund(request.getPayment()))
.step("create-order")
.action(() -> orderService.create(request))
.compensate(() -> orderService.cancel(request.getOrderId()))
.build();
sagaManager.execute(saga);
}
}
2. CQRS with Event Sourcing
Separate read and write models for better scalability:
// Command side
@Entity
@Table(name = "order_events")
public class OrderEvent {
@Id
private String eventId;
private String orderId;
private String eventType;
private String eventData;
private Instant timestamp;
}
@Component
public class OrderAggregate {
private String orderId;
private OrderStatus status;
private List<OrderEvent> events = new ArrayList<>();
public void createOrder(CreateOrderCommand command) {
OrderCreatedEvent event = new OrderCreatedEvent(command);
apply(event);
events.add(event);
}
private void apply(OrderCreatedEvent event) {
this.orderId = event.getOrderId();
this.status = OrderStatus.CREATED;
}
}
// Query side
@Entity
@Table(name = "order_read_model")
public class OrderReadModel {
@Id
private String orderId;
private String customerName;
private BigDecimal totalAmount;
private OrderStatus status;
// Denormalized data for efficient queries
}
@EventHandler
@Component
public class OrderReadModelUpdater {
public void handle(OrderCreatedEvent event) {
OrderReadModel readModel = new OrderReadModel();
readModel.setOrderId(event.getOrderId());
// Populate from multiple sources
CustomerDto customer = customerService.getCustomer(event.getCustomerId());
readModel.setCustomerName(customer.getName());
orderReadModelRepository.save(readModel);
}
}
Observability Patterns
1. Distributed Tracing
Use Spring Cloud Sleuth with Zipkin:
spring:
zipkin:
base-url: http://zipkin-server:9411
sleuth:
sampler:
probability: 1.0 # Sample all requests in development
zipkin:
enabled: true
@RestController
public class OrderController {
@Autowired
private Tracer tracer;
@GetMapping("/orders/{orderId}")
public OrderDto getOrder(@PathVariable String orderId) {
Span span = tracer.nextSpan()
.name("get-order")
.tag("order.id", orderId)
.start();
try (Tracer.SpanInScope ws = tracer.withSpanInScope(span)) {
return orderService.getOrder(orderId);
} finally {
span.end();
}
}
}
2. Health Checks and Metrics
Implement comprehensive health checks:
@Component
public class DatabaseHealthIndicator implements HealthIndicator {
@Autowired
private DataSource dataSource;
@Override
public Health health() {
try (Connection connection = dataSource.getConnection()) {
if (connection.isValid(1)) {
return Health.up()
.withDetail("database", "Available")
.withDetail("connection.pool.active", getActiveConnections())
.build();
}
} catch (SQLException e) {
return Health.down()
.withDetail("database", "Unavailable")
.withException(e)
.build();
}
return Health.down().build();
}
}
@Component
public class CustomMetrics {
private final Counter orderCounter;
private final Timer orderProcessingTimer;
public CustomMetrics(MeterRegistry meterRegistry) {
this.orderCounter = Counter.builder("orders.created")
.description("Number of orders created")
.register(meterRegistry);
this.orderProcessingTimer = Timer.builder("orders.processing.time")
.description("Order processing time")
.register(meterRegistry);
}
public void incrementOrderCount() {
orderCounter.increment();
}
public void recordProcessingTime(Duration duration) {
orderProcessingTimer.record(duration);
}
}
Security Patterns
1. JWT Authentication with Spring Security
@Configuration
@EnableWebSecurity
public class SecurityConfiguration {
@Bean
public JwtDecoder jwtDecoder() {
return JwtDecoders.fromOidcIssuerLocation("https://auth-server/auth");
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
.authorizeHttpRequests(authz -> authz
.requestMatchers("/actuator/health", "/actuator/info").permitAll()
.requestMatchers("/api/orders/**").hasAuthority("SCOPE_orders")
.anyRequest().authenticated())
.build();
}
}
2. Service-to-Service Authentication
@Configuration
public class ServiceAuthConfiguration {
@Bean
public RestTemplate authenticatedRestTemplate() {
RestTemplate restTemplate = new RestTemplate();
restTemplate.getInterceptors().add(new ServiceAuthInterceptor());
return restTemplate;
}
}
public class ServiceAuthInterceptor implements ClientHttpRequestInterceptor {
@Override
public ClientHttpResponse intercept(
HttpRequest request,
byte[] body,
ClientHttpRequestExecution execution) throws IOException {
// Add service authentication header
String token = jwtTokenProvider.generateServiceToken();
request.getHeaders().add("Authorization", "Bearer " + token);
return execution.execute(request, body);
}
}
Testing Patterns
1. Contract Testing with Spring Cloud Contract
// contracts/order-service/should_return_order_by_id.groovy
Contract.make {
description "should return order by id"
request {
method GET()
url "/api/orders/12345"
headers {
contentType applicationJson()
}
}
response {
status OK()
body([
orderId: "12345",
customerId: "customer-1",
amount: 99.99,
status: "CREATED"
])
headers {
contentType applicationJson()
}
}
}
2. Integration Testing with TestContainers
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
class OrderServiceIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:13")
.withDatabaseName("orders")
.withUsername("test")
.withPassword("test");
@Container
static GenericContainer<?> redis = new GenericContainer<>("redis:6-alpine")
.withExposedPorts(6379);
@Test
void shouldCreateOrder() {
// Integration test with real database and Redis
}
}
Performance Optimization Patterns
1. Caching Strategy
@Configuration
@EnableCaching
public class CacheConfiguration {
@Bean
public CacheManager cacheManager() {
RedisCacheManager.Builder builder = RedisCacheManager
.RedisCacheManagerBuilder
.fromConnectionFactory(redisConnectionFactory())
.cacheDefaults(cacheConfiguration());
return builder.build();
}
private RedisCacheConfiguration cacheConfiguration() {
return RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10))
.serializeKeysWith(RedisSerializationContext.SerializationPair
.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair
.fromSerializer(new GenericJackson2JsonRedisSerializer()));
}
}
@Service
public class CustomerService {
@Cacheable(value = "customers", key = "#customerId")
public CustomerDto getCustomer(String customerId) {
return customerRepository.findById(customerId)
.map(this::toDto)
.orElseThrow(() -> new CustomerNotFoundException(customerId));
}
@CacheEvict(value = "customers", key = "#customer.id")
public void updateCustomer(Customer customer) {
customerRepository.save(customer);
}
}
2. Connection Pooling
spring:
datasource:
hikari:
maximum-pool-size: 20
minimum-idle: 5
idle-timeout: 300000
max-lifetime: 1200000
connection-timeout: 20000
validation-timeout: 5000
leak-detection-threshold: 60000
Production Deployment Patterns
1. Blue-Green Deployment
# kubernetes deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: order-service-blue
spec:
replicas: 3
selector:
matchLabels:
app: order-service
version: blue
template:
metadata:
labels:
app: order-service
version: blue
spec:
containers:
- name: order-service
image: order-service:v1.0.0
ports:
- containerPort: 8080
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
initialDelaySeconds: 30
periodSeconds: 5
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
initialDelaySeconds: 60
periodSeconds: 10
2. Graceful Shutdown
@Component
public class GracefulShutdownHook implements ApplicationListener<ContextClosedEvent> {
@Override
public void onApplicationEvent(ContextClosedEvent event) {
log.info("Received shutdown signal, starting graceful shutdown");
// Stop accepting new requests
// Complete existing requests
// Close database connections
// Clean up resources
log.info("Graceful shutdown completed");
}
}
server:
shutdown: graceful
spring:
lifecycle:
timeout-per-shutdown-phase: 30s
Lessons Learned
After implementing these patterns across multiple production systems, here are the key takeaways:
-
Start Simple: Don’t implement all patterns from day one. Begin with service discovery and basic communication patterns.
-
Monitor Everything: Observability isn’t optional. Implement distributed tracing and comprehensive metrics from the start.
-
Design for Failure: Circuit breakers and retry mechanisms prevent cascading failures that can bring down entire systems.
-
Data Consistency: Accept eventual consistency and design your business processes accordingly.
-
Testing Strategy: Focus on contract testing and integration tests. Unit tests have limited value in microservices architectures.
The patterns in this guide have been battle-tested in production environments processing millions of transactions daily. Start with the foundation patterns and gradually add complexity as your system grows.
Building Production-Ready Microservices?
Implementing these patterns correctly requires deep Spring Boot and microservices expertise. Our team has architected and built dozens of production microservices systems.