Back to Blog

Microservices with Spring Boot: Architecture Patterns and Best Practices

20 min read
MicroservicesSpring BootArchitectureScalability

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:

  1. Starting Simple: Begin with a well-defined monolith and extract services gradually
  2. Domain-Driven Design: Align services with business capabilities
  3. Operational Excellence: Invest in monitoring, logging, and deployment automation
  4. Team Structure: Organize teams around service ownership
  5. 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.