Back to blog
March 15, 2022
5 min read

From Monolith to Microservices: A Spring Perspective

Real-world guide to breaking down a monolith into microservices, focusing on Spring-specific challenges and solutions.

From Monolith to Microservices: A Spring Perspective

The journey from monolith to microservices is like breaking down a house while people are living in it. Let’s see how to do this safely with Spring.

Start with a Modular Monolith

Before diving into microservices, aim for a modular monolith:

// Instead of this tangled mess:
@Service
public class OrderService {
    @Autowired private CustomerRepository customerRepo;
    @Autowired private ProductRepository productRepo;
    @Autowired private PaymentService paymentService;
    @Autowired private ShippingService shippingService;
    // Everything mixed together
}

// Build clear modules with defined boundaries:
@Module("orders")
public class OrderModule {
    private final OrdersAPI api;
    private final CustomerAPI customerAPI;
    private final ProductCatalogAPI catalogAPI;

    // Dependencies flow through clear interfaces
    public OrderModule(OrdersAPI api, CustomerAPI customerAPI,
                      ProductCatalogAPI catalogAPI) {
        this.api = api;
        this.customerAPI = customerAPI;
        this.catalogAPI = catalogAPI;
    }
}

Common Pitfalls

1. Shared Database Anti-pattern

// DON'T: Multiple services accessing same tables
@Repository
public interface OrderRepository extends JpaRepository<Order, Long> {
    // Used by both OrderService and ShippingService
    @Query("SELECT o FROM Order o WHERE o.status = :status")
    List<Order> findByStatus(String status);
}

// DO: Define clear boundaries and APIs
@RestController
@RequestMapping("/api/v1/orders")
public class OrderController {
    @GetMapping("/status/{status}")
    public List<OrderDTO> getByStatus(@PathVariable String status) {
        // Each service owns its data
        return orderService.findByStatus(status);
    }
}

2. Distributed Transaction Headaches

// DON'T: Try to maintain ACID across services
@Transactional // This won't work across services!
public void processOrder(Order order) {
    paymentService.charge(order);
    inventoryService.reserve(order);
    shippingService.schedule(order);
}

// DO: Use Saga pattern
@Service
public class OrderSaga {
    public void processOrder(Order order) {
        try {
            PaymentResult payment = paymentService.charge(order);
            try {
                InventoryResult inventory = inventoryService.reserve(order);
                try {
                    shippingService.schedule(order);
                } catch (Exception e) {
                    inventoryService.compensate(inventory);
                    paymentService.refund(payment);
                    throw e;
                }
            } catch (Exception e) {
                paymentService.refund(payment);
                throw e;
            }
        } catch (Exception e) {
            // Handle failure
        }
    }
}

3. Configuration Sprawl

# DON'T: Copy-paste config everywhere
spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/mydb
    username: ${DB_USER}
    password: ${DB_PASS}
  kafka:
    bootstrap-servers: localhost:9092
  redis:
    host: localhost
    port: 6379

# DO: Use Spring Cloud Config Server
spring:
  config:
    import: "configserver:"
  cloud:
    config:
      uri: http://config-server:8888
      fail-fast: true

The Hardest Parts

1. Data Boundaries

Drawing clear data boundaries is crucial:

Before:
┌─────────────────────┐
│     Database        │
├─────────────────────┤
│ - Orders           │
│ - Customers        │
│ - Products         │
│ - Payments         │
└─────────────────────┘

After:
┌───────────┐ ┌───────────┐ ┌───────────┐
│  Orders   │ │ Customers │ │ Products  │
│   DB      │ │    DB     │ │    DB     │
└───────────┘ └───────────┘ └───────────┘

2. Service Communication

// First stage: HTTP/REST
@FeignClient(name = "product-service")
public interface ProductClient {
    @GetMapping("/api/v1/products/{id}")
    ProductDTO getProduct(@PathVariable Long id);
}

// Later stage: Event-driven
@Service
public class OrderEventHandler {
    @KafkaListener(topics = "order-events")
    public void handleOrderEvent(OrderEvent event) {
        switch(event.getType()) {
            case CREATED:
                // Handle async
                break;
            case UPDATED:
                // Handle async
                break;
        }
    }
}

3. Testing Strategy

// Integration tests become more complex
@SpringBootTest
@AutoConfigureMockMvc
public class OrderFlowTests {
    @MockBean
    private ProductClient productClient;

    @MockBean
    private PaymentClient paymentClient;

    @Test
    public void testOrderFlow() {
        // Need to mock multiple service interactions
        when(productClient.getProduct(1L))
            .thenReturn(new ProductDTO("Test", 100));

        // Test becomes more complex
    }
}

Migration Strategy

  1. Start with Strangler Fig Pattern:
@Configuration
public class RouterConfig {
    @Bean
    public RouteLocator routeLocator(RouteLocatorBuilder builder) {
        return builder.routes()
            // Route some traffic to new microservice
            .route("products", r -> r.path("/api/v2/products/**")
                .uri("lb://product-service"))
            // Keep rest in monolith
            .route("legacy", r -> r.path("/**")
                .uri("lb://monolith"))
            .build();
    }
}
  1. Extract Shared Libraries:
// Common library for DTOs and interfaces
public interface OrderAPI {
    OrderDTO createOrder(OrderRequest request);
    OrderDTO getOrder(Long id);
}

// Implemented by both monolith and microservice
@Service
public class OrderService implements OrderAPI {
    // Implementation
}
  1. Gradual Data Migration:
@Service
public class DualWriteOrderService {
    private final LegacyOrderRepo legacyRepo;
    private final NewOrderService newService;

    public Order save(Order order) {
        // Write to both systems during migration
        Order legacyOrder = legacyRepo.save(order);
        newService.createOrder(toDTO(order));
        return legacyOrder;
    }
}

When to Stay Monolithic

  1. Small Team (< 20 developers)
  2. Simple Domain
  3. No Scale Issues
  4. Fast Release Cycle
Complexity Growth:
Monolith:     └─────────┐
              Manageable │

Microservices: └────────┴─────────┐
               Hard at first      │
                      Then manageable

Best Practices

  1. Start Modular:
// Use Spring modules for clear boundaries
@Configuration
@ComponentScan("com.company.orders")
public class OrdersModule {
    // Module-specific config
}
  1. Event-First Design:
// Define events before services
public interface OrderEvents {
    void orderCreated(OrderCreatedEvent event);
    void orderUpdated(OrderUpdatedEvent event);
    void orderCancelled(OrderCancelledEvent event);
}
  1. API Versioning:
@RestController
@RequestMapping("/api/v1/orders")
public class OrderController {
    // V1 API
}

@RestController
@RequestMapping("/api/v2/orders")
public class OrderControllerV2 {
    // V2 API with breaking changes
}

Bottom Line

  1. Start with a modular monolith
  2. Extract services along clear business boundaries
  3. Use events for communication where possible
  4. Migrate gradually using patterns like Strangler Fig
  5. Consider whether you really need microservices

Remember: The goal isn’t to have microservices—it’s to solve business problems effectively. Sometimes a well-structured monolith is the better solution.