Microservices with Spring Boot: Architecture Patterns and Best Practices
Microservices with Spring Boot: Architecture Patterns and Best Practices
Microservices architecture has become the gold standard for building scalable, maintainable applications. When combined with Spring Boot's powerful ecosystem, you get a robust platform for creating distributed systems that can grow with your business needs.
Understanding Microservices Architecture
Microservices break down monolithic applications into smaller, independent services that communicate over well-defined APIs. Each service owns its data, can be deployed independently, and can be developed by different teams.
Key Principles
- Single Responsibility: Each service has one business capability
- Decentralized: Services manage their own data and business logic
- Fault Tolerant: Failure in one service doesn't bring down the system
- Technology Agnostic: Services can use different technologies
Service Design Patterns
Domain-Driven Design (DDD)
Structure your services around business domains:
// User Service - Handles user management
@RestController
@RequestMapping("/api/users")
public class UserController {
@Autowired
private UserService userService;
@PostMapping
public ResponseEntity<User> createUser(@RequestBody CreateUserRequest request) {
User user = userService.createUser(request);
return ResponseEntity.status(HttpStatus.CREATED).body(user);
}
@GetMapping("/{id}")
public ResponseEntity<User> getUser(@PathVariable Long id) {
return userService.findById(id)
.map(user -> ResponseEntity.ok(user))
.orElse(ResponseEntity.notFound().build());
}
}
// Order Service - Handles order processing
@RestController
@RequestMapping("/api/orders")
public class OrderController {
@Autowired
private OrderService orderService;
@PostMapping
public ResponseEntity<Order> createOrder(@RequestBody CreateOrderRequest request) {
Order order = orderService.processOrder(request);
return ResponseEntity.status(HttpStatus.CREATED).body(order);
}
}
Database Per Service
Each microservice should have its own database:
# User Service - application.yml
spring:
datasource:
url: jdbc:postgresql://localhost:5432/userdb
username: user_service
password: ${DB_PASSWORD}
jpa:
hibernate:
ddl-auto: validate
# Order Service - application.yml
spring:
datasource:
url: jdbc:postgresql://localhost:5432/orderdb
username: order_service
password: ${DB_PASSWORD}
Service Communication Patterns
Synchronous Communication with OpenFeign
// User Service Client
@FeignClient(name = "user-service", url = "${services.user-service.url}")
public interface UserServiceClient {
@GetMapping("/api/users/{id}")
User getUserById(@PathVariable("id") Long id);
@PostMapping("/api/users/{id}/validate")
boolean validateUser(@PathVariable("id") Long id);
}
// Order Service using User Service
@Service
public class OrderService {
@Autowired
private UserServiceClient userServiceClient;
@Autowired
private OrderRepository orderRepository;
public Order processOrder(CreateOrderRequest request) {
// Validate user exists
User user = userServiceClient.getUserById(request.getUserId());
if (user == null) {
throw new UserNotFoundException("User not found: " + request.getUserId());
}
// Process order
Order order = new Order();
order.setUserId(request.getUserId());
order.setItems(request.getItems());
order.setStatus(OrderStatus.PENDING);
return orderRepository.save(order);
}
}
Asynchronous Communication with Events
// Event Publisher
@Component
public class OrderEventPublisher {
@Autowired
private RabbitTemplate rabbitTemplate;
public void publishOrderCreated(Order order) {
OrderCreatedEvent event = new OrderCreatedEvent(
order.getId(),
order.getUserId(),
order.getTotalAmount(),
Instant.now()
);
rabbitTemplate.convertAndSend("order.exchange", "order.created", event);
}
}
// Event Listener in Notification Service
@RabbitListener(queues = "notification.order.created")
@Component
public class OrderEventListener {
@Autowired
private NotificationService notificationService;
public void handleOrderCreated(OrderCreatedEvent event) {
notificationService.sendOrderConfirmation(
event.getUserId(),
event.getOrderId()
);
}
}
Configuration Management
Centralized Configuration with Spring Cloud Config
# Config Server - application.yml
server:
port: 8888
spring:
cloud:
config:
server:
git:
uri: https://github.com/your-org/config-repo
default-label: main
# Service Configuration - bootstrap.yml
spring:
application:
name: user-service
cloud:
config:
uri: http://config-server:8888
fail-fast: true
retry:
initial-interval: 1000
max-attempts: 6
Environment-Specific Configurations
# user-service-dev.yml
spring:
datasource:
url: jdbc:h2:mem:testdb
jpa:
show-sql: true
logging:
level:
com.yourcompany: DEBUG
# user-service-prod.yml
spring:
datasource:
url: jdbc:postgresql://prod-db:5432/userdb
hikari:
maximum-pool-size: 20
minimum-idle: 5
logging:
level:
root: WARN
com.yourcompany: INFO
Service Discovery and Load Balancing
Eureka Service Discovery
// Eureka Server
@SpringBootApplication
@EnableEurekaServer
public class EurekaServerApplication {
public static void main(String[] args) {
SpringApplication.run(EurekaServerApplication.class, args);
}
}
// Service Registration
@SpringBootApplication
@EnableEurekaClient
public class UserServiceApplication {
public static void main(String[] args) {
SpringApplication.run(UserServiceApplication.class, args);
}
}
# Service configuration
eureka:
client:
service-url:
defaultZone: http://eureka-server:8761/eureka/
instance:
prefer-ip-address: true
lease-renewal-interval-in-seconds: 30
Circuit Breaker Pattern
Resilience4j Implementation
@Service
public class OrderService {
@Autowired
private UserServiceClient userServiceClient;
@CircuitBreaker(name = "user-service", fallbackMethod = "fallbackGetUser")
@Retry(name = "user-service")
@TimeLimiter(name = "user-service")
public CompletableFuture<User> getUserAsync(Long userId) {
return CompletableFuture.supplyAsync(() ->
userServiceClient.getUserById(userId)
);
}
public CompletableFuture<User> fallbackGetUser(Long userId, Exception ex) {
// Return cached user or default user
return CompletableFuture.completedFuture(createDefaultUser(userId));
}
}
# Resilience4j configuration
resilience4j:
circuitbreaker:
instances:
user-service:
sliding-window-size: 10
failure-rate-threshold: 50
wait-duration-in-open-state: 30s
permitted-number-of-calls-in-half-open-state: 3
retry:
instances:
user-service:
max-attempts: 3
wait-duration: 1s
Data Management Patterns
Saga Pattern for Distributed Transactions
// Order Saga Orchestrator
@Component
public class OrderSagaOrchestrator {
@Autowired
private PaymentService paymentService;
@Autowired
private InventoryService inventoryService;
@Autowired
private OrderService orderService;
@SagaOrchestrationStart
public void processOrder(CreateOrderRequest request) {
try {
// Step 1: Reserve inventory
inventoryService.reserveItems(request.getItems());
// Step 2: Process payment
paymentService.processPayment(request.getPaymentInfo());
// Step 3: Create order
orderService.createOrder(request);
} catch (Exception e) {
// Compensate in reverse order
compensateOrder(request);
}
}
private void compensateOrder(CreateOrderRequest request) {
try {
orderService.cancelOrder(request.getOrderId());
paymentService.refundPayment(request.getPaymentInfo());
inventoryService.releaseItems(request.getItems());
} catch (Exception e) {
// Log compensation failure and alert operations
log.error("Saga compensation failed", e);
}
}
}
Event Sourcing
// Event Store
@Entity
public class EventStore {
@Id
private String eventId;
private String aggregateId;
private String eventType;
private String eventData;
private Instant timestamp;
private Long version;
// getters and setters
}
// Event Sourced Aggregate
@Component
public class OrderAggregate {
private String orderId;
private OrderStatus status;
private List<OrderItem> items;
private Long version;
public void apply(OrderCreatedEvent event) {
this.orderId = event.getOrderId();
this.status = OrderStatus.CREATED;
this.items = event.getItems();
this.version = event.getVersion();
}
public void apply(OrderConfirmedEvent event) {
this.status = OrderStatus.CONFIRMED;
this.version = event.getVersion();
}
}
Monitoring and Observability
Distributed Tracing with Sleuth
# application.yml
spring:
sleuth:
sampler:
probability: 1.0
zipkin:
base-url: http://zipkin-server:9411
// Custom Span
@Service
public class OrderService {
@NewSpan("order-processing")
public Order processOrder(@SpanTag("userId") Long userId, CreateOrderRequest request) {
// Processing logic with automatic tracing
return createOrder(request);
}
}
Metrics with Micrometer
@RestController
public class OrderController {
private final Counter orderCounter;
private final Timer orderProcessingTimer;
public OrderController(MeterRegistry meterRegistry) {
this.orderCounter = Counter.builder("orders.created")
.description("Number of orders created")
.register(meterRegistry);
this.orderProcessingTimer = Timer.builder("order.processing.time")
.description("Order processing time")
.register(meterRegistry);
}
@PostMapping("/orders")
public ResponseEntity<Order> createOrder(@RequestBody CreateOrderRequest request) {
return orderProcessingTimer.recordCallable(() -> {
Order order = orderService.processOrder(request);
orderCounter.increment();
return ResponseEntity.ok(order);
});
}
}
Security Patterns
JWT Token-Based Authentication
// JWT Service
@Service
public class JwtService {
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.expiration}")
private Long expiration;
public String generateToken(UserDetails userDetails) {
Map<String, Object> claims = new HashMap<>();
claims.put("roles", userDetails.getAuthorities());
return createToken(claims, userDetails.getUsername());
}
public Boolean validateToken(String token, UserDetails userDetails) {
final String username = getUsernameFromToken(token);
return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));
}
}
// Security Configuration
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Autowired
private JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
@Autowired
private JwtRequestFilter jwtRequestFilter;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf().disable()
.authorizeHttpRequests(authz -> authz
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/actuator/**").permitAll()
.anyRequest().authenticated()
)
.exceptionHandling().authenticationEntryPoint(jwtAuthenticationEntryPoint)
.and()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
http.addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
Deployment Strategies
Docker Containerization
# Dockerfile
FROM openjdk:21-jdk-slim
VOLUME /tmp
COPY target/user-service-1.0.0.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app.jar"]
# docker-compose.yml
version: '3.8'
services:
user-service:
build: ./user-service
ports:
- "8081:8080"
environment:
- SPRING_PROFILES_ACTIVE=docker
- DB_HOST=user-db
depends_on:
- user-db
- config-server
user-db:
image: postgres:15
environment:
POSTGRES_DB: userdb
POSTGRES_USER: user_service
POSTGRES_PASSWORD: password
volumes:
- user_data:/var/lib/postgresql/data
volumes:
user_data:
Kubernetes Deployment
# user-service-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: user-service
spec:
replicas: 3
selector:
matchLabels:
app: user-service
template:
metadata:
labels:
app: user-service
spec:
containers:
- name: user-service
image: your-registry/user-service:latest
ports:
- containerPort: 8080
env:
- name: SPRING_PROFILES_ACTIVE
value: "kubernetes"
- name: DB_HOST
value: "user-db-service"
resources:
requests:
memory: "512Mi"
cpu: "250m"
limits:
memory: "1Gi"
cpu: "500m"
livenessProbe:
httpGet:
path: /actuator/health
port: 8080
initialDelaySeconds: 60
periodSeconds: 30
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
Testing Strategies
Contract Testing with Pact
// Consumer Test (Order Service)
@ExtendWith(PactConsumerTestExt.class)
@PactTestFor(providerName = "user-service")
public class UserServiceContractTest {
@Pact(consumer = "order-service")
public RequestResponsePact getUserPact(PactDslWithProvider builder) {
return builder
.given("user exists")
.uponReceiving("get user by id")
.path("/api/users/1")
.method("GET")
.willRespondWith()
.status(200)
.headers(Map.of("Content-Type", "application/json"))
.body(new PactDslJsonBody()
.stringType("id", "1")
.stringType("username", "john.doe")
.stringType("email", "john@example.com"))
.toPact();
}
@Test
@PactTestFor(pactMethod = "getUserPact")
void testGetUser(MockServer mockServer) {
UserServiceClient client = new UserServiceClient(mockServer.getUrl());
User user = client.getUserById(1L);
assertThat(user.getUsername()).isEqualTo("john.doe");
assertThat(user.getEmail()).isEqualTo("john@example.com");
}
}
Integration Testing
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
class OrderServiceIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
@Container
static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine")
.withExposedPorts(6379);
@Autowired
private TestRestTemplate restTemplate;
@Test
void shouldCreateOrder() {
CreateOrderRequest request = new CreateOrderRequest();
request.setUserId(1L);
request.setItems(List.of(new OrderItem("product1", 2, BigDecimal.valueOf(29.99))));
ResponseEntity<Order> response = restTemplate.postForEntity(
"/api/orders",
request,
Order.class
);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
assertThat(response.getBody().getUserId()).isEqualTo(1L);
}
}
Performance Optimization
Caching Strategies
@Service
public class UserService {
@Cacheable(value = "users", key = "#id")
public User findById(Long id) {
return userRepository.findById(id)
.orElseThrow(() -> new UserNotFoundException("User not found: " + id));
}
@CacheEvict(value = "users", key = "#user.id")
public User updateUser(User user) {
return userRepository.save(user);
}
@Caching(evict = {
@CacheEvict(value = "users", key = "#id"),
@CacheEvict(value = "userProfiles", key = "#id")
})
public void deleteUser(Long id) {
userRepository.deleteById(id);
}
}
Database Optimization
// Read Replicas Configuration
@Configuration
public class DatabaseConfig {
@Bean
@Primary
@ConfigurationProperties("spring.datasource.write")
public DataSource writeDataSource() {
return DataSourceBuilder.create().build();
}
@Bean
@ConfigurationProperties("spring.datasource.read")
public DataSource readDataSource() {
return DataSourceBuilder.create().build();
}
@Bean
public DataSource routingDataSource() {
RoutingDataSource routingDataSource = new RoutingDataSource();
Map<Object, Object> dataSourceMap = new HashMap<>();
dataSourceMap.put("write", writeDataSource());
dataSourceMap.put("read", readDataSource());
routingDataSource.setTargetDataSources(dataSourceMap);
routingDataSource.setDefaultTargetDataSource(writeDataSource());
return routingDataSource;
}
}
Common Pitfalls and Solutions
Distributed Data Management
Problem: Maintaining data consistency across services Solution: Use eventual consistency with event sourcing and CQRS
Service Communication
Problem: Cascading failures and tight coupling Solution: Implement circuit breakers and asynchronous communication
Configuration Management
Problem: Configuration drift across environments Solution: Centralized configuration with version control
Monitoring Complexity
Problem: Difficulty tracking requests across services Solution: Distributed tracing and centralized logging
Conclusion
Building microservices with Spring Boot requires careful consideration of architecture patterns, communication strategies, and operational concerns. The key to success lies in:
- Starting Simple: Begin with a well-defined monolith and extract services gradually
- Domain-Driven Design: Align services with business capabilities
- Operational Excellence: Invest in monitoring, logging, and deployment automation
- Team Structure: Organize teams around service ownership
- Continuous Learning: Microservices architecture evolves with experience
Remember that microservices are not a silver bullet—they introduce complexity that must be managed through proper tooling, processes, and team practices. Start with the patterns that solve your specific problems and evolve your architecture as your understanding and requirements grow.
The Spring Boot ecosystem provides excellent tools for building microservices, but success ultimately depends on thoughtful design, careful implementation, and operational discipline. Focus on delivering business value while building the foundation for long-term scalability and maintainability.