Microservices Architecture: Patterns and Implementation with Spring Cloud
Microservices architecture has become the standard for building scalable, maintainable enterprise applications. However, distributed systems introduce complexity that requires careful architectural decisions and robust infrastructure patterns.
This guide covers the essential patterns for building production-ready microservices using Spring Cloud and related technologies.
Why Microservices?
Monolithic applications become difficult to scale and maintain as they grow:
| Aspect | Monolith | Microservices |
|---|---|---|
| Deployment | All-or-nothing | Independent per service |
| Scaling | Scale entire app | Scale specific services |
| Technology | Single stack | Polyglot possible |
| Team Structure | Large coordinated teams | Small autonomous teams |
| Failure Impact | Entire system | Isolated to service |
| Development Speed | Slows with size | Remains constant |
Architecture Overview
A production microservices architecture requires several supporting components:
Core Components
| Component | Purpose | Technologies |
|---|---|---|
| API Gateway | Single entry point, routing, auth | Spring Cloud Gateway, Kong |
| Service Discovery | Dynamic service location | Eureka, Consul, Kubernetes DNS |
| Config Server | Centralized configuration | Spring Cloud Config, Vault |
| Circuit Breaker | Fault tolerance | Resilience4j, Hystrix |
| Message Broker | Async communication | Kafka, RabbitMQ |
| Observability | Monitoring, tracing, logging | Prometheus, Jaeger, ELK |
API Gateway Pattern
The API Gateway is the single entry point for all client requests:
Responsibilities
- Request Routing: Route requests to appropriate services
- Authentication: Validate tokens, enforce security
- Rate Limiting: Protect services from overload
- Load Balancing: Distribute traffic across instances
- Response Aggregation: Combine responses from multiple services
- Protocol Translation: REST to gRPC, WebSocket handling
Spring Cloud Gateway Implementation
# application.yml
spring:
cloud:
gateway:
routes:
- id: order-service
uri: lb://order-service
predicates:
- Path=/api/orders/**
filters:
- StripPrefix=1
- name: CircuitBreaker
args:
name: orderCircuitBreaker
fallbackUri: forward:/fallback/orders
- id: product-service
uri: lb://product-service
predicates:
- Path=/api/products/**
filters:
- StripPrefix=1
- name: RequestRateLimiter
args:
redis-rate-limiter.replenishRate: 100
redis-rate-limiter.burstCapacity: 200
default-filters:
- name: Retry
args:
retries: 3
statuses: BAD_GATEWAY
Gateway Security
@Configuration
@EnableWebFluxSecurity
public class GatewaySecurityConfig {
@Bean
public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
http
.csrf(ServerHttpSecurity.CsrfSpec::disable)
.authorizeExchange(exchanges -> exchanges
.pathMatchers("/api/public/**").permitAll()
.pathMatchers("/api/admin/**").hasRole("ADMIN")
.anyExchange().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(Customizer.withDefaults())
);
return http.build();
}
}
Service Discovery
Services need to find each other dynamically as instances scale up/down:
Netflix Eureka Setup
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:
registerWithEureka: false
fetchRegistry: false
server:
enableSelfPreservation: false
Service Registration:
# service application.yml
spring:
application:
name: order-service
eureka:
client:
serviceUrl:
defaultZone: http://localhost:8761/eureka/
instance:
preferIpAddress: true
lease-renewal-interval-in-seconds: 10
lease-expiration-duration-in-seconds: 30
Kubernetes-Native Discovery
With Kubernetes, use native DNS-based discovery:
spring:
cloud:
kubernetes:
discovery:
enabled: true
all-namespaces: false
loadbalancer:
mode: SERVICE
Circuit Breaker Pattern
Prevent cascading failures when services are unavailable:
Circuit Breaker States
| State | Behavior |
|---|---|
| Closed | Requests pass through normally |
| Open | Requests fail immediately (fallback) |
| Half-Open | Limited requests to test recovery |
Resilience4j Implementation
@Service
public class OrderService {
private final ProductClient productClient;
@CircuitBreaker(name = "productService", fallbackMethod = "getProductFallback")
@Retry(name = "productService")
@TimeLimiter(name = "productService")
public CompletableFuture<Product> getProduct(String productId) {
return CompletableFuture.supplyAsync(() ->
productClient.getProduct(productId)
);
}
public CompletableFuture<Product> getProductFallback(String productId, Exception ex) {
log.warn("Fallback for product {}: {}", productId, ex.getMessage());
return CompletableFuture.completedFuture(
Product.builder()
.id(productId)
.name("Product Unavailable")
.cached(true)
.build()
);
}
}
# application.yml
resilience4j:
circuitbreaker:
instances:
productService:
registerHealthIndicator: true
slidingWindowSize: 10
minimumNumberOfCalls: 5
permittedNumberOfCallsInHalfOpenState: 3
automaticTransitionFromOpenToHalfOpenEnabled: true
waitDurationInOpenState: 5s
failureRateThreshold: 50
eventConsumerBufferSize: 10
retry:
instances:
productService:
maxAttempts: 3
waitDuration: 100ms
enableExponentialBackoff: true
exponentialBackoffMultiplier: 2
timelimiter:
instances:
productService:
timeoutDuration: 3s
cancelRunningFuture: true
Configuration Management
Centralize configuration for all services:
Spring Cloud Config Server
@SpringBootApplication
@EnableConfigServer
public class ConfigServerApplication {
public static void main(String[] args) {
SpringApplication.run(ConfigServerApplication.class, args);
}
}
# config-server application.yml
spring:
cloud:
config:
server:
git:
uri: https://github.com/org/config-repo
default-label: main
search-paths: '{application}'
encrypt:
enabled: true
encrypt:
key: ${ENCRYPT_KEY}
HashiCorp Vault Integration
For secrets management:
spring:
cloud:
vault:
uri: https://vault.example.com
authentication: KUBERNETES
kubernetes:
role: my-service
kubernetes-path: kubernetes
kv:
enabled: true
backend: secret
default-context: application
config:
import: vault://
@Configuration
@ConfigurationProperties(prefix = "database")
public class DatabaseConfig {
@Value("${database.username}")
private String username;
@Value("${database.password}")
private String password; // Fetched from Vault
// ...
}
Database Per Service
Each microservice owns its data:
Patterns
| Pattern | Use Case |
|---|---|
| Private Database | Full isolation, different schemas |
| Schema Per Service | Shared database, logical separation |
| Shared Database | Legacy migration (avoid long-term) |
Implementation
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private String id;
private String customerId; // Reference, not FK
private String productId; // Reference, not FK
@Enumerated(EnumType.STRING)
private OrderStatus status;
private BigDecimal totalAmount;
@CreatedDate
private Instant createdAt;
}
Data Consistency: Saga Pattern
For distributed transactions:
@Service
public class OrderSagaOrchestrator {
public void createOrder(CreateOrderCommand command) {
// Step 1: Create order
Order order = orderService.createOrder(command);
try {
// Step 2: Reserve inventory
inventoryService.reserveStock(order.getProductId(), order.getQuantity());
// Step 3: Process payment
paymentService.processPayment(order.getCustomerId(), order.getTotalAmount());
// Step 4: Confirm order
orderService.confirmOrder(order.getId());
} catch (InventoryException e) {
// Compensate: Cancel order
orderService.cancelOrder(order.getId());
throw e;
} catch (PaymentException e) {
// Compensate: Release inventory, cancel order
inventoryService.releaseStock(order.getProductId(), order.getQuantity());
orderService.cancelOrder(order.getId());
throw e;
}
}
}
Asynchronous Communication
Use message brokers for event-driven communication:
Apache Kafka Integration
@Configuration
public class KafkaConfig {
@Bean
public ProducerFactory<String, OrderEvent> producerFactory() {
Map<String, Object> config = new HashMap<>();
config.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "kafka:9092");
config.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
config.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, JsonSerializer.class);
return new DefaultKafkaProducerFactory<>(config);
}
@Bean
public KafkaTemplate<String, OrderEvent> kafkaTemplate() {
return new KafkaTemplate<>(producerFactory());
}
}
@Service
public class OrderEventPublisher {
private final KafkaTemplate<String, OrderEvent> kafkaTemplate;
public void publishOrderCreated(Order order) {
OrderEvent event = new OrderEvent(
order.getId(),
OrderEventType.CREATED,
order
);
kafkaTemplate.send("order-events", order.getId(), event);
}
}
@Service
public class InventoryEventConsumer {
@KafkaListener(topics = "order-events", groupId = "inventory-service")
public void handleOrderEvent(OrderEvent event) {
switch (event.getType()) {
case CREATED -> reserveInventory(event.getOrder());
case CANCELLED -> releaseInventory(event.getOrder());
}
}
}
Inter-Service Communication
OpenFeign Clients
@FeignClient(name = "product-service", fallback = ProductClientFallback.class)
public interface ProductClient {
@GetMapping("/api/products/{id}")
Product getProduct(@PathVariable String id);
@GetMapping("/api/products")
List<Product> getProducts(@RequestParam List<String> ids);
}
@Component
public class ProductClientFallback implements ProductClient {
@Override
public Product getProduct(String id) {
return Product.unavailable(id);
}
@Override
public List<Product> getProducts(List<String> ids) {
return ids.stream()
.map(Product::unavailable)
.collect(toList());
}
}
gRPC for High Performance
// product.proto
syntax = "proto3";
service ProductService {
rpc GetProduct(ProductRequest) returns (ProductResponse);
rpc GetProducts(ProductsRequest) returns (stream ProductResponse);
}
message ProductRequest {
string product_id = 1;
}
message ProductResponse {
string id = 1;
string name = 2;
double price = 3;
int32 stock = 4;
}
@GrpcService
public class ProductGrpcService extends ProductServiceGrpc.ProductServiceImplBase {
private final ProductRepository repository;
@Override
public void getProduct(ProductRequest request, StreamObserver<ProductResponse> observer) {
Product product = repository.findById(request.getProductId())
.orElseThrow(() -> new ProductNotFoundException(request.getProductId()));
observer.onNext(toProto(product));
observer.onCompleted();
}
}
Observability
Distributed Tracing with Micrometer
management:
tracing:
sampling:
probability: 1.0
zipkin:
tracing:
endpoint: http://zipkin:9411/api/v2/spans
logging:
pattern:
level: "%5p [${spring.application.name:},%X{traceId:-},%X{spanId:-}]"
Prometheus Metrics
@RestController
public class OrderController {
private final Counter orderCounter;
private final Timer orderTimer;
public OrderController(MeterRegistry registry) {
this.orderCounter = Counter.builder("orders.created")
.description("Number of orders created")
.register(registry);
this.orderTimer = Timer.builder("orders.processing.time")
.description("Order processing time")
.register(registry);
}
@PostMapping("/orders")
public Order createOrder(@RequestBody CreateOrderRequest request) {
return orderTimer.record(() -> {
Order order = orderService.create(request);
orderCounter.increment();
return order;
});
}
}
Centralized Logging
# logback-spring.xml
<appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
<includeMdcKeyName>traceId</includeMdcKeyName>
<includeMdcKeyName>spanId</includeMdcKeyName>
</encoder>
</appender>
Deployment Architecture
Kubernetes Deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: order-service
spec:
replicas: 3
selector:
matchLabels:
app: order-service
template:
metadata:
labels:
app: order-service
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "8080"
prometheus.io/path: "/actuator/prometheus"
spec:
containers:
- name: order-service
image: registry.example.com/order-service:1.0.0
ports:
- containerPort: 8080
env:
- name: SPRING_PROFILES_ACTIVE
value: kubernetes
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
initialDelaySeconds: 30
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
resources:
requests:
memory: "512Mi"
cpu: "250m"
limits:
memory: "1Gi"
cpu: "1000m"
---
apiVersion: v1
kind: Service
metadata:
name: order-service
spec:
selector:
app: order-service
ports:
- port: 80
targetPort: 8080
Testing Microservices
Contract Testing with Pact
@ExtendWith(PactConsumerTestExt.class)
class ProductClientContractTest {
@Pact(consumer = "order-service", provider = "product-service")
public V4Pact getProductPact(PactDslWithProvider builder) {
return builder
.given("product exists")
.uponReceiving("get product request")
.path("/api/products/prod-123")
.method("GET")
.willRespondWith()
.status(200)
.body(new PactDslJsonBody()
.stringValue("id", "prod-123")
.stringValue("name", "Test Product")
.decimalType("price", 99.99))
.toPact(V4Pact.class);
}
@Test
@PactTestFor(pactMethod = "getProductPact")
void testGetProduct(MockServer mockServer) {
ProductClient client = new ProductClient(mockServer.getUrl());
Product product = client.getProduct("prod-123");
assertThat(product.getId()).isEqualTo("prod-123");
assertThat(product.getName()).isEqualTo("Test Product");
}
}
Integration Testing with Testcontainers
@SpringBootTest
@Testcontainers
class OrderServiceIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15");
@Container
static KafkaContainer kafka = new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.4.0"));
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.kafka.bootstrap-servers", kafka::getBootstrapServers);
}
@Test
void shouldCreateOrderAndPublishEvent() {
// Test implementation
}
}
Best Practices
Service Design
- Single Responsibility: Each service owns one business capability
- Loose Coupling: Services communicate via well-defined APIs
- High Cohesion: Related functionality grouped together
- API Versioning: Support backward compatibility
Operational Excellence
- Health Checks: Liveness and readiness probes
- Graceful Shutdown: Drain connections before terminating
- Idempotency: Handle duplicate requests safely
- Correlation IDs: Track requests across services
Security
- Zero Trust: Authenticate all service-to-service calls
- Secrets Management: Use Vault, not environment variables
- Network Policies: Restrict traffic between services
- mTLS: Encrypt service-to-service communication
Conclusion
Building microservices requires careful attention to:
- API Gateway: Single entry point with routing, security, and rate limiting
- Service Discovery: Dynamic service location for scaling
- Resilience: Circuit breakers, retries, and fallbacks
- Configuration: Centralized config with secrets management
- Data: Database per service with saga patterns for consistency
- Communication: Sync (REST/gRPC) and async (Kafka) patterns
- Observability: Metrics, tracing, and centralized logging
The Spring Cloud ecosystem provides production-ready implementations for these patterns, enabling teams to focus on business logic rather than infrastructure concerns.
Microservices Architecture: Patterns and Implementation with Spring Cloud
A guide to building resilient distributed systems.
Achraf SOLTANI — August 10, 2024
