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
- 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();
}
}
- 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
}
- 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
- Small Team (< 20 developers)
- Simple Domain
- No Scale Issues
- Fast Release Cycle
Complexity Growth:
Monolith: └─────────┐
Manageable │
│
Microservices: └────────┴─────────┐
Hard at first │
Then manageable
Best Practices
- Start Modular:
// Use Spring modules for clear boundaries
@Configuration
@ComponentScan("com.company.orders")
public class OrdersModule {
// Module-specific config
}
- Event-First Design:
// Define events before services
public interface OrderEvents {
void orderCreated(OrderCreatedEvent event);
void orderUpdated(OrderUpdatedEvent event);
void orderCancelled(OrderCancelledEvent event);
}
- 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
- Start with a modular monolith
- Extract services along clear business boundaries
- Use events for communication where possible
- Migrate gradually using patterns like Strangler Fig
- 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.