Safe migration patterns for JSP/Struts/legacy Java codebases: strangler fig, anti-corruption layer, finding seams, characterization testing, migration priority order. Use when planning or executing migration from legacy Java to Spring Boot, or adding new features alongside legacy flows.
The strangler fig pattern routes new traffic through new code while legacy code continues to handle existing flows. New behavior grows around the legacy system until the old path can be removed safely.
// Migration trajectory
Phase 1: Legacy Struts Action handles all /orders/** traffic
Phase 2: New Spring @RestController handles POST /orders (new orders only)
Legacy Struts Action still handles GET /orders, PUT /orders/:id
Phase 3: Spring controller handles all /orders/**
Legacy action removed
Never rewrite a working Struts Action in-place. The rewrite is higher risk than a parallel implementation because you lose the ability to fall back.
// Do NOT do this: modify the legacy action to add Spring annotations
// ActionServlet.java (legacy)
public class CreateOrderAction extends Action {
// Existing logic...
// Wrong: adding new logic directly into legacy code
@PostMapping("/orders/new") // annotations don't work here anyway
public void handleNewFlow(...) { ... }
}
// Do this: new Spring controller alongside the legacy action
@RestController
@RequestMapping("/api/v2/orders") // new path, doesn't conflict with legacy /orders.do
public class OrderController {
// Fresh implementation using Spring conventions
}
Before calling a legacy service or utility from new code, wrap it in a thin interface. This shields new code from legacy naming conventions, mutable data structures, and exception contracts.
// Legacy code you cannot change
public class LegacyInventoryManager {
// Global state, throws checked Exception, returns null on miss
public static InventoryRecord getInventory(String sku) throws Exception { ... }
public static boolean reserveStock(String sku, int qty) throws Exception { ... }
}
// Anti-corruption layer: clean interface + adapter
public interface InventoryService {
Optional<InventoryRecord> findBySku(Sku sku);
boolean reserveStock(Sku sku, int quantity);
}
@Service
public class LegacyInventoryAdapter implements InventoryService {
@Override
public Optional<InventoryRecord> findBySku(Sku sku) {
try {
return Optional.ofNullable(LegacyInventoryManager.getInventory(sku.value()));
} catch (Exception e) {
throw new InventoryLookupException("Failed to look up SKU: " + sku.value(), e);
}
}
@Override
public boolean reserveStock(Sku sku, int quantity) {
try {
return LegacyInventoryManager.reserveStock(sku.value(), quantity);
} catch (Exception e) {
throw new InventoryReservationException("Failed to reserve stock for: " + sku.value(), e);
}
}
}
// New code only sees InventoryService — no legacy types cross the boundary
@Service
public class OrderService {
private final InventoryService inventoryService; // not LegacyInventoryManager
}
A seam is a place in the code where behavior can be changed without modifying the code at that point. Identify seams before touching legacy code.
Common seams in Java legacy codebases:
new)// Legacy code with no seam (hardcoded dependency)
public class OrderProcessor {
public void process(Order order) {
// Hardcoded: no way to substitute without modifying this class
EmailSender sender = new SmtpEmailSender("smtp.company.com", 25);
sender.send(order.getEmail(), "Your order has been processed");
}
}
// Introducing a seam: extract interface, inject dependency
public interface NotificationSender {
void sendOrderConfirmation(String email, OrderId orderId);
}
public class OrderProcessor {
private final NotificationSender notificationSender;
public OrderProcessor(NotificationSender notificationSender) {
this.notificationSender = notificationSender; // seam: can substitute in tests and migration
}
public void process(Order order) {
notificationSender.sendOrderConfirmation(order.getEmail(), order.getId());
}
}
Do not migrate untested legacy code. If the existing behavior isn't captured in tests, you have no way to verify your migration preserves it.
Migration sequence for a legacy Struts Action:
@Service// Step 1: Characterization test — captures existing behavior as-is
@Test
void legacyCreateOrderAction_producesExpectedSessionAttributes() {
var action = new CreateOrderAction();
var form = new OrderForm();
form.setCustomerId("cust-1");
form.setItemSku("SKU-001");
form.setQuantity(2);
ActionForward forward = action.execute(mapping, form, request, response);
assertThat(request.getSession().getAttribute("orderConfirmation")).isNotNull();
assertThat(forward.getName()).isEqualTo("success");
}
When deciding what to modernize first, use this sequence:
// High-value migration target: Struts Action with business logic
public class ProcessPaymentAction extends Action {
public ActionForward execute(...) {
// 200 lines of payment logic mixed with HTTP concerns
String cardNumber = request.getParameter("cardNumber");
// validation, charge, receipt generation all tangled together
}
}
// Extract the business logic, leave the routing alone until ready
@Service
public class PaymentService {
public PaymentReceipt processPayment(PaymentRequest request) {
// Clean implementation, testable, no HTTP
}
}
// Phase 1: the old Action delegates to the new service
public class ProcessPaymentAction extends Action {
private final PaymentService paymentService = SpringContext.getBean(PaymentService.class);
public ActionForward execute(...) {
PaymentRequest req = PaymentRequest.from(request); // extract params
PaymentReceipt receipt = paymentService.processPayment(req);
request.setAttribute("receipt", receipt);
return mapping.findForward("success");
}
}
If a legacy code path works and has users, do not break it. Parallel paths exist for a reason. If you must change behavior, add a feature flag or route new traffic to a new endpoint.
Prefer adding a new path over changing an existing one. Removing the old path comes last, after the new path has operated in production under load.