DEV Community

Cover image for 5 Proven Strategies for Migrating Enterprise Applications from Jakarta EE to Spring
Nithin Bharadwaj
Nithin Bharadwaj

Posted on

5 Proven Strategies for Migrating Enterprise Applications from Jakarta EE to Spring

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);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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());
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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));
    }
}
Enter fullscreen mode Exit fullscreen mode

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)