As a best-selling author, I invite you to explore my books on Amazon. Don't forget to follow me on Medium and show your support. Thank you! Your support means the world!
When I first encountered the challenge of migrating enterprise applications from Jakarta EE to Spring, I realized the magnitude of this undertaking. Legacy systems built on Jakarta EE represent years of business logic and proven stability, yet modern cloud deployment demands and development practices necessitate this transition. Through extensive experience with multiple migration projects, I've identified five proven strategies that minimize risk while delivering the benefits of Spring's modern ecosystem.
The complexity of enterprise applications makes a complete rewrite unfeasible. Instead, I've learned that systematic approaches preserve business continuity while modernizing the underlying technology stack. Each strategy addresses specific aspects of the migration challenge, from dependency management to testing frameworks.
Incremental Component Replacement
The most effective approach I've used begins with peripheral services before touching core business logic. This strategy reduces complexity and allows teams to gain confidence with Spring patterns before migrating critical components.
Starting with utility services and moving toward business-critical components creates a natural progression. I typically begin by identifying standalone services that have minimal dependencies on Jakarta EE-specific features.
// Original Jakarta EE Service
@Stateless
public class NotificationService {
@Inject
private EmailService emailService;
@Inject
private SmsService smsService;
public void sendOrderConfirmation(Order order) {
String message = buildConfirmationMessage(order);
emailService.sendEmail(order.getCustomer().getEmail(), message);
if (order.getCustomer().hasPhoneNumber()) {
smsService.sendSms(order.getCustomer().getPhone(), message);
}
}
}
// Migrated Spring Service
@Service
public class NotificationService {
private final EmailService emailService;
private final SmsService smsService;
public NotificationService(EmailService emailService, SmsService smsService) {
this.emailService = emailService;
this.smsService = smsService;
}
public void sendOrderConfirmation(Order order) {
String message = buildConfirmationMessage(order);
emailService.sendEmail(order.getCustomer().getEmail(), message);
if (order.getCustomer().hasPhoneNumber()) {
smsService.sendSms(order.getCustomer().getPhone(), message);
}
}
}
Business logic services require more careful consideration due to their transaction management and data access patterns. I've found success in maintaining the same service boundaries while adapting the implementation details.
// Complex Business Service Migration
@Service
@Transactional
public class OrderProcessingService {
private final OrderRepository orderRepository;
private final InventoryService inventoryService;
private final PaymentService paymentService;
private final NotificationService notificationService;
public OrderProcessingService(OrderRepository orderRepository,
InventoryService inventoryService,
PaymentService paymentService,
NotificationService notificationService) {
this.orderRepository = orderRepository;
this.inventoryService = inventoryService;
this.paymentService = paymentService;
this.notificationService = notificationService;
}
@Transactional(rollbackFor = Exception.class)
public Order processOrder(OrderRequest request) throws OrderProcessingException {
validateOrderRequest(request);
if (!inventoryService.checkAvailability(request.getItems())) {
throw new InsufficientInventoryException("Items not available");
}
Order order = createOrder(request);
order = orderRepository.save(order);
try {
inventoryService.reserveItems(request.getItems());
PaymentResult result = paymentService.processPayment(order.getPaymentInfo());
if (result.isSuccessful()) {
order.setStatus(OrderStatus.CONFIRMED);
order.setPaymentReference(result.getTransactionId());
order = orderRepository.save(order);
notificationService.sendOrderConfirmation(order);
return order;
} else {
throw new PaymentProcessingException("Payment failed: " + result.getErrorMessage());
}
} catch (Exception e) {
inventoryService.releaseReservation(request.getItems());
throw new OrderProcessingException("Order processing failed", e);
}
}
}
Data access layer migration often provides the most significant benefits. Spring Data JPA eliminates boilerplate code while providing powerful query capabilities that surpass traditional DAO patterns.
// Repository Layer Migration
@Repository
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query("SELECT o FROM Order o WHERE o.customer.id = :customerId AND o.status = :status")
List<Order> findByCustomerAndStatus(@Param("customerId") Long customerId,
@Param("status") OrderStatus status);
@Query("SELECT o FROM Order o WHERE o.createdDate BETWEEN :startDate AND :endDate")
Page<Order> findOrdersByDateRange(@Param("startDate") LocalDateTime startDate,
@Param("endDate") LocalDateTime endDate,
Pageable pageable);
@Modifying
@Query("UPDATE Order o SET o.status = :status WHERE o.id IN :orderIds")
int updateOrderStatus(@Param("orderIds") List<Long> orderIds,
@Param("status") OrderStatus status);
}
Configuration Migration Strategy
Jakarta EE applications often rely heavily on application server configuration and JNDI resources. Moving to Spring requires externalizing these configurations while maintaining environment-specific behavior.
I've developed a pattern for migrating configuration that preserves existing deployment practices while enabling Spring's flexible configuration model. The key lies in identifying all configuration sources and creating equivalent Spring property sources.
// Configuration Class for Database Settings
@Configuration
@ConfigurationProperties(prefix = "app.datasource")
public class DatabaseConfiguration {
private String url;
private String username;
private String password;
private int maxPoolSize = 20;
private int minPoolSize = 5;
private long connectionTimeout = 30000;
@Bean
@Primary
public DataSource primaryDataSource() {
HikariConfig config = new HikariConfig();
config.setJdbcUrl(url);
config.setUsername(username);
config.setPassword(password);
config.setMaximumPoolSize(maxPoolSize);
config.setMinimumIdle(minPoolSize);
config.setConnectionTimeout(connectionTimeout);
return new HikariDataSource(config);
}
// Getters and setters
}
// Environment-specific properties
// application-dev.yml
app:
datasource:
url: jdbc:postgresql://dev-db:5432/orders
username: dev_user
password: dev_password
max-pool-size: 10
// application-prod.yml
app:
datasource:
url: jdbc:postgresql://prod-db:5432/orders
username: ${DB_USERNAME}
password: ${DB_PASSWORD}
max-pool-size: 50
Security configuration migration requires careful attention to maintain existing authentication and authorization patterns while adopting Spring Security's more flexible model.
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true)
public class SecurityConfiguration {
private final UserDetailsService userDetailsService;
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
public SecurityConfiguration(UserDetailsService userDetailsService,
JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint) {
this.userDetailsService = userDetailsService;
this.jwtAuthenticationEntryPoint = jwtAuthenticationEntryPoint;
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(authz -> authz
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/public/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/orders/**").hasRole("USER")
.requestMatchers(HttpMethod.POST, "/api/orders/**").hasRole("USER")
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.exceptionHandling(ex -> ex.authenticationEntryPoint(jwtAuthenticationEntryPoint))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
http.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public JwtAuthenticationFilter jwtAuthenticationFilter() {
return new JwtAuthenticationFilter();
}
}
Dependency Injection Transformation
Converting from CDI to Spring's dependency injection requires understanding the subtle differences between the two models. While both provide inversion of control, Spring offers more sophisticated dependency resolution and lifecycle management.
I've found that maintaining service boundaries while converting annotations provides the smoothest transition. The business logic remains unchanged while gaining Spring's advanced features.
// Original CDI-based Service
@ApplicationScoped
public class CustomerService {
@Inject
private CustomerRepository customerRepository;
@Inject
private AddressValidator addressValidator;
@Inject
@ConfigProperty(name = "customer.validation.enabled")
private boolean validationEnabled;
public Customer createCustomer(CustomerRequest request) {
if (validationEnabled) {
validateCustomerRequest(request);
}
Customer customer = new Customer();
customer.setName(request.getName());
customer.setEmail(request.getEmail());
customer.setAddress(request.getAddress());
if (!addressValidator.isValid(customer.getAddress())) {
throw new InvalidAddressException("Invalid address provided");
}
return customerRepository.save(customer);
}
}
// Spring-based Service
@Service
public class CustomerService {
private final CustomerRepository customerRepository;
private final AddressValidator addressValidator;
private final boolean validationEnabled;
public CustomerService(CustomerRepository customerRepository,
AddressValidator addressValidator,
@Value("${customer.validation.enabled:true}") boolean validationEnabled) {
this.customerRepository = customerRepository;
this.addressValidator = addressValidator;
this.validationEnabled = validationEnabled;
}
public Customer createCustomer(CustomerRequest request) {
if (validationEnabled) {
validateCustomerRequest(request);
}
Customer customer = new Customer();
customer.setName(request.getName());
customer.setEmail(request.getEmail());
customer.setAddress(request.getAddress());
if (!addressValidator.isValid(customer.getAddress())) {
throw new InvalidAddressException("Invalid address provided");
}
return customerRepository.save(customer);
}
}
Complex scenarios involving producer methods and qualifiers require more sophisticated Spring configuration. I've developed patterns for handling these advanced CDI features in Spring.
// CDI Producer Pattern
@ApplicationScoped
public class ServiceConfiguration {
@Produces
@PaymentProvider
@ConfigProperty(name = "payment.provider.type")
public PaymentService createPaymentService(String providerType) {
switch (providerType.toLowerCase()) {
case "stripe":
return new StripePaymentService();
case "paypal":
return new PayPalPaymentService();
default:
return new DefaultPaymentService();
}
}
}
// Spring Configuration Pattern
@Configuration
public class ServiceConfiguration {
@Bean
@ConditionalOnProperty(name = "payment.provider.type", havingValue = "stripe")
public PaymentService stripePaymentService() {
return new StripePaymentService();
}
@Bean
@ConditionalOnProperty(name = "payment.provider.type", havingValue = "paypal")
public PaymentService paypalPaymentService() {
return new PayPalPaymentService();
}
@Bean
@ConditionalOnMissingBean(PaymentService.class)
public PaymentService defaultPaymentService() {
return new DefaultPaymentService();
}
}
Transaction Management Modernization
Jakarta EE's container-managed transactions provide declarative transaction handling, but Spring's transaction management offers more flexibility and better testing support. I've migrated numerous applications by carefully mapping transaction boundaries and rollback rules.
The migration process involves identifying existing transaction boundaries and converting them to Spring's declarative model while maintaining the same ACID properties.
// Original EJB Transaction Management
@Stateless
@TransactionAttribute(TransactionAttributeType.REQUIRED)
public class FinancialService {
@PersistenceContext
private EntityManager entityManager;
@TransactionAttribute(TransactionAttributeType.REQUIRES_NEW)
public void processRefund(Long orderId, BigDecimal amount) {
Order order = entityManager.find(Order.class, orderId);
if (order == null) {
throw new OrderNotFoundException("Order not found: " + orderId);
}
Refund refund = new Refund();
refund.setOrder(order);
refund.setAmount(amount);
refund.setProcessedDate(LocalDateTime.now());
entityManager.persist(refund);
order.setStatus(OrderStatus.REFUNDED);
entityManager.merge(order);
// External payment gateway call
paymentGateway.processRefund(order.getPaymentReference(), amount);
}
}
// Spring Transaction Management
@Service
@Transactional
public class FinancialService {
private final OrderRepository orderRepository;
private final RefundRepository refundRepository;
private final PaymentGateway paymentGateway;
public FinancialService(OrderRepository orderRepository,
RefundRepository refundRepository,
PaymentGateway paymentGateway) {
this.orderRepository = orderRepository;
this.refundRepository = refundRepository;
this.paymentGateway = paymentGateway;
}
@Transactional(propagation = Propagation.REQUIRES_NEW,
rollbackFor = Exception.class,
timeout = 30)
public void processRefund(Long orderId, BigDecimal amount) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException("Order not found: " + orderId));
Refund refund = new Refund();
refund.setOrder(order);
refund.setAmount(amount);
refund.setProcessedDate(LocalDateTime.now());
refundRepository.save(refund);
order.setStatus(OrderStatus.REFUNDED);
orderRepository.save(order);
try {
paymentGateway.processRefund(order.getPaymentReference(), amount);
} catch (PaymentGatewayException e) {
throw new RefundProcessingException("Failed to process refund", e);
}
}
}
Advanced transaction scenarios require careful consideration of isolation levels and rollback rules. Spring provides more granular control over these aspects compared to Jakarta EE's standard transaction attributes.
@Service
@Transactional
public class ReportingService {
private final OrderRepository orderRepository;
private final ReportRepository reportRepository;
public ReportingService(OrderRepository orderRepository,
ReportRepository reportRepository) {
this.orderRepository = orderRepository;
this.reportRepository = reportRepository;
}
@Transactional(readOnly = true,
isolation = Isolation.READ_COMMITTED,
timeout = 60)
public SalesReport generateSalesReport(LocalDate startDate, LocalDate endDate) {
List<Order> orders = orderRepository.findOrdersByDateRange(
startDate.atStartOfDay(),
endDate.atTime(23, 59, 59)
);
SalesReport report = new SalesReport();
report.setStartDate(startDate);
report.setEndDate(endDate);
report.setTotalOrders(orders.size());
report.setTotalRevenue(calculateTotalRevenue(orders));
report.setAverageOrderValue(calculateAverageOrderValue(orders));
return report;
}
@Transactional(rollbackFor = {ReportGenerationException.class, DataAccessException.class})
public void saveReport(SalesReport report) {
try {
reportRepository.save(report);
} catch (DataAccessException e) {
throw new ReportGenerationException("Failed to save report", e);
}
}
}
Testing Framework Adoption
One of the most significant benefits of migrating to Spring comes from its comprehensive testing support. I've replaced complex Arquillian-based integration tests with Spring's testing framework, resulting in faster test execution and better developer productivity.
Spring Test provides multiple testing approaches, from unit tests with mocked dependencies to full integration tests with embedded databases. The migration from Arquillian requires restructuring test classes but delivers superior testing capabilities.
// Original Arquillian Test
@RunWith(Arquillian.class)
public class OrderServiceTest {
@Deployment
public static WebArchive createDeployment() {
return ShrinkWrap.create(WebArchive.class)
.addClass(OrderService.class)
.addClass(OrderRepository.class)
.addAsResource("test-persistence.xml", "META-INF/persistence.xml");
}
@Inject
private OrderService orderService;
@Test
public void shouldProcessOrder() {
OrderRequest request = new OrderRequest();
request.setCustomerId(1L);
request.setItems(Arrays.asList(new OrderItem("PROD1", 2)));
Order result = orderService.processOrder(request);
assertNotNull(result);
assertEquals(OrderStatus.CONFIRMED, result.getStatus());
}
}
// Spring Boot Test
@SpringBootTest
@Transactional
@Rollback
class OrderServiceTest {
@Autowired
private OrderService orderService;
@Autowired
private TestEntityManager testEntityManager;
@MockBean
private PaymentService paymentService;
@Test
void shouldProcessOrder() {
// Given
Customer customer = new Customer();
customer.setName("Test Customer");
customer.setEmail("[email protected]");
customer = testEntityManager.persistAndFlush(customer);
OrderRequest request = new OrderRequest();
request.setCustomerId(customer.getId());
request.setItems(Arrays.asList(new OrderItem("PROD1", 2)));
PaymentResult paymentResult = new PaymentResult();
paymentResult.setSuccessful(true);
paymentResult.setTransactionId("TXN123");
when(paymentService.processPayment(any())).thenReturn(paymentResult);
// When
Order result = orderService.processOrder(request);
// Then
assertThat(result).isNotNull();
assertThat(result.getStatus()).isEqualTo(OrderStatus.CONFIRMED);
assertThat(result.getPaymentReference()).isEqualTo("TXN123");
verify(paymentService).processPayment(any());
}
}
Integration testing with TestContainers provides realistic database testing without the complexity of application server deployment. This approach has significantly improved my testing workflow.
@SpringBootTest
@Testcontainers
class OrderIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:13")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired
private OrderService orderService;
@Autowired
private OrderRepository orderRepository;
@Test
void shouldPersistOrderWithRealDatabase() {
// Given
OrderRequest request = createValidOrderRequest();
// When
Order result = orderService.processOrder(request);
// Then
assertThat(result.getId()).isNotNull();
Optional<Order> savedOrder = orderRepository.findById(result.getId());
assertThat(savedOrder).isPresent();
assertThat(savedOrder.get().getStatus()).isEqualTo(OrderStatus.CONFIRMED);
}
}
Web layer testing benefits from Spring's MockMvc framework, which provides comprehensive testing of REST endpoints without deploying to an application server.
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private OrderService orderService;
@Test
void shouldCreateOrder() throws Exception {
// Given
OrderRequest request = new OrderRequest();
request.setCustomerId(1L);
Order expectedOrder = new Order();
expectedOrder.setId(1L);
expectedOrder.setStatus(OrderStatus.CONFIRMED);
when(orderService.processOrder(any(OrderRequest.class))).thenReturn(expectedOrder);
// When & Then
mockMvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").value(1L))
.andExpect(jsonPath("$.status").value("CONFIRMED"));
verify(orderService).processOrder(any(OrderRequest.class));
}
}
These five strategies have proven effective across multiple enterprise migration projects. The incremental approach minimizes risk while delivering immediate benefits. Configuration externalization enables cloud deployment, while modern dependency injection and transaction management improve maintainability. The testing framework adoption alone justifies the migration effort through improved developer productivity and system reliability.
Success depends on careful planning and gradual implementation. I recommend starting with non-critical components to build team confidence before tackling core business logic. Each migrated component becomes a foundation for subsequent migrations, creating momentum that accelerates the overall process.
The investment in migration pays dividends through improved development velocity, better testing capabilities, and enhanced deployment flexibility. Modern Spring applications integrate seamlessly with cloud platforms and DevOps practices, positioning enterprise systems for future growth and evolution.
📘 Checkout my latest ebook for free on my channel!
Be sure to like, share, comment, and subscribe to the channel!
101 Books
101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.
Check out our book Golang Clean Code available on Amazon.
Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!
Our Creations
Be sure to check out our creations:
Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | JS Schools
We are on Medium
Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva
Top comments (0)