How to Migrate from Java EE to Spring Boot

Type: Software Reference Confidence: 0.93 Sources: 8 Verified: 2026-02-23 Freshness: monthly

TL;DR

Constraints

Quick Reference

Java EE PatternSpring Boot EquivalentExample
@Stateless EJB@Service@Service public class OrderService { ... }
@Stateful EJB@Service + @SessionScope@Service @SessionScope public class CartService { ... }
@Singleton EJB@Service (default singleton)Spring beans are singleton-scoped by default
@MessageDriven (JMS)@JmsListener@JmsListener(destination = "orders") void process(Message m)
@EJB injection@Autowired or constructor injectionpublic OrderController(OrderService svc) { this.svc = svc; }
@PersistenceContext@Autowired EntityManager or Spring Data JPApublic interface OrderRepo extends JpaRepository<Order, Long> {}
@Resource JNDI lookup@Value + application.properties@Value("${datasource.url}") String dbUrl;
@TransactionAttribute@Transactional@Transactional(propagation = REQUIRED)
@Interceptors / @AroundInvokeSpring AOP @Aspect + @Around@Around("execution(* com.app.service.*.*(..))")
@Schedule (EJB Timer)@Scheduled@Scheduled(cron = "0 0 * * * *") void hourlyJob()
JSF Managed BeansSpring MVC @Controller + Thymeleaf@Controller public class OrderController { ... }
web.xml Servlet config@SpringBootApplication auto-configRemove web.xml, rely on embedded Tomcat
persistence.xmlapplication.properties + auto-configspring.datasource.url=jdbc:postgresql://...
ejb-jar.xml deployment descriptorAnnotation-driven configDelete descriptor, use @Service/@Component
JAAS SecuritySpring Security SecurityFilterChain@Bean SecurityFilterChain filterChain(HttpSecurity http)

Decision Tree

START
├── Is the app running on Java EE 5-7 (javax.*)?
│   ├── YES → Target Spring Boot 3.x (requires javax→jakarta namespace change)
│   └── NO (already Jakarta EE 9+) → Direct Spring Boot 3.x/4.x migration
├── Does the app use Stateful EJBs or extended persistence contexts?
│   ├── YES → Extract session state to Redis/DB-backed Spring Session first
│   └── NO ↓
├── Does the app use JSF for the frontend?
│   ├── YES → Replace with Thymeleaf (server-side) or REST API + SPA (React/Angular)
│   └── NO ↓
├── Are there remote EJB calls between services?
│   ├── YES → Replace with REST/gRPC endpoints (Spring WebClient/RestClient)
│   └── NO ↓
├── Does the app rely on vendor-specific features (WebLogic JMS, JTA, JCA)?
│   ├── YES → Replace with Spring-compatible alternatives (Spring JMS, Spring TX, REST)
│   └── NO ↓
├── Are you targeting Spring Boot 4.0?
│   ├── YES → Ensure Jakarta EE 11, Jackson 3.x, JUnit 6 alignment
│   └── NO (3.5.x) → Jakarta EE 10 sufficient
└── DEFAULT → Convert EJBs to @Service, replace JNDI with @Value/ConfigProperties,
              add spring-boot-starter-* dependencies, remove app server

Step-by-Step Guide

1. Audit the Java EE surface area

Inventory every EJB, JNDI lookup, Java EE API usage, and vendor-specific feature before writing any code. This converts a vague migration into discrete, estimable tasks. [src3]

# Count EJB annotations
grep -rn '@Stateless\|@Stateful\|@Singleton\|@MessageDriven' --include='*.java' | wc -l

# Count JNDI lookups
grep -rn 'InitialContext\|@Resource\|lookup(' --include='*.java' | wc -l

# Count Java EE imports (javax.* that need jakarta.* change)
grep -rn 'import javax\.' --include='*.java' | wc -l

# Count JSF usage
grep -rn '@ManagedBean\|@FacesConverter\|@FacesValidator' --include='*.java' | wc -l

Verify: A typical mid-size Java EE app has 20–100 EJBs, 50–200 JNDI lookups, and 500+ javax imports. Prioritize stateless EJBs for early wins.

2. Set up the Spring Boot project alongside the existing app

Create a new Spring Boot project that will gradually absorb the Java EE codebase. Both can coexist during migration. [src1, src2]

<!-- pom.xml for Spring Boot 3.5.x (latest 3.x LTS) -->
<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.5.0</version>
</parent>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
</dependencies>

Verify: mvn spring-boot:run starts embedded Tomcat on port 8080 with no errors.

3. Replace JNDI and XML configuration with application.properties

Migrate all JNDI datasource lookups and XML descriptor configurations to Spring Boot's externalized configuration. [src2, src6]

# application.properties — replaces persistence.xml + JNDI datasource
spring.datasource.url=jdbc:postgresql://localhost:5432/myapp
spring.datasource.username=${DB_USERNAME}
spring.datasource.password=${DB_PASSWORD}
spring.datasource.driver-class-name=org.postgresql.Driver

# JPA/Hibernate (replaces persistence.xml properties)
spring.jpa.hibernate.ddl-auto=validate
spring.jpa.show-sql=false
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
spring.jpa.open-in-view=false

# JMS (replaces JNDI JMS connection factory)
spring.activemq.broker-url=tcp://localhost:61616
spring.activemq.user=${JMS_USERNAME}
spring.activemq.password=${JMS_PASSWORD}

Verify: spring.datasource.* properties connect to the same database the Java EE app uses.

4. Convert EJBs to Spring services

Replace EJB annotations with Spring equivalents. Start with stateless beans — they have the fewest dependencies. [src2, src4]

// BEFORE: Java EE Stateless EJB
@Stateless
public class OrderServiceBean implements OrderService {
    @PersistenceContext
    private EntityManager em;

    @EJB
    private InventoryService inventoryService;

    @TransactionAttribute(TransactionAttributeType.REQUIRED)
    public Order createOrder(OrderRequest request) {
        Inventory inv = inventoryService.reserve(request.getProductId(), request.getQty());
        Order order = new Order(request, inv);
        em.persist(order);
        return order;
    }
}

// AFTER: Spring Boot Service
@Service
@Transactional
public class OrderService {
    private final OrderRepository orderRepo;
    private final InventoryService inventoryService;

    public OrderService(OrderRepository orderRepo, InventoryService inventoryService) {
        this.orderRepo = orderRepo;
        this.inventoryService = inventoryService;
    }

    public Order createOrder(OrderRequest request) {
        Inventory inv = inventoryService.reserve(request.getProductId(), request.getQty());
        Order order = new Order(request, inv);
        return orderRepo.save(order);
    }
}

Verify: Run unit tests for each converted service. Constructor injection ensures missing dependencies fail fast at startup.

5. Replace JPA EntityManager with Spring Data repositories

Convert manual EntityManager queries to Spring Data JPA repositories with derived query methods. [src2]

// BEFORE: Java EE DAO with EntityManager
@Stateless
public class OrderDAO {
    @PersistenceContext
    private EntityManager em;

    public List<Order> findByCustomer(Long customerId) {
        return em.createQuery(
            "SELECT o FROM Order o WHERE o.customerId = :cid", Order.class)
            .setParameter("cid", customerId)
            .getResultList();
    }
}

// AFTER: Spring Data JPA Repository
public interface OrderRepository extends JpaRepository<Order, Long> {
    List<Order> findByCustomerId(Long customerId);

    @Query("SELECT o FROM Order o WHERE o.status = :status AND o.createdAt > :since")
    List<Order> findRecentByStatus(@Param("status") OrderStatus status,
                                    @Param("since") LocalDateTime since);
}

Verify: orderRepository.findByCustomerId(1L) returns the same results as the old DAO. Compare SQL output with spring.jpa.show-sql=true.

6. Migrate security from JAAS to Spring Security

Replace Java EE container-managed security with Spring Security's filter chain. [src6]

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/admin/**").hasRole("ADMIN")
                .requestMatchers("/api/**").authenticated()
                .anyRequest().permitAll()
            )
            .formLogin(Customizer.withDefaults())
            .build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

Verify: Access /admin/dashboard without authentication returns 401/302. Login with ADMIN role grants access.

7. Update javax.* imports to jakarta.* and remove the app server

Update all namespace imports, remove app server deployment descriptors, and deploy as a standalone JAR. [src1, src5]

# Automated namespace migration using OpenRewrite
mvn -U org.openrewrite.maven:rewrite-maven-plugin:run \
  -Drewrite.recipeArtifactCoordinates=org.openrewrite.recipe:rewrite-migrate-java:RELEASE \
  -Drewrite.activeRecipes=org.openrewrite.java.migrate.jakarta.JavaxMigrationToJakarta

# Delete deployment descriptors
rm -f src/main/webapp/WEB-INF/web.xml
rm -f src/main/webapp/WEB-INF/ejb-jar.xml
rm -f src/main/resources/META-INF/persistence.xml

# Build standalone JAR
mvn clean package -DskipTests
java -jar target/myapp-1.0.0.jar

Verify: java -jar target/myapp.jar starts successfully. No WebLogic/JBoss/GlassFish process needed. All endpoints return identical responses.

Code Examples

Java: Converting a Stateless EJB to Spring Service

// Input:  A Java EE stateless EJB with JNDI, EntityManager, and CMT
// Output: Equivalent Spring Boot service with constructor injection

// === BEFORE: Java EE ===
@Stateless
public class PaymentServiceBean implements PaymentServiceRemote {
    @PersistenceContext(unitName = "myPU")
    private EntityManager em;

    @Resource(mappedName = "jms/PaymentQueue")
    private Queue paymentQueue;

    @TransactionAttribute(TransactionAttributeType.REQUIRED)
    public PaymentResult processPayment(PaymentRequest request) {
        Payment payment = new Payment(request);
        em.persist(payment);
        sendNotification(payment);
        return new PaymentResult(payment.getId(), "SUCCESS");
    }
}

// === AFTER: Spring Boot ===
@Service
public class PaymentService {
    private final PaymentRepository paymentRepo;
    private final JmsTemplate jmsTemplate;

    public PaymentService(PaymentRepository paymentRepo, JmsTemplate jmsTemplate) {
        this.paymentRepo = paymentRepo;
        this.jmsTemplate = jmsTemplate;
    }

    @Transactional
    public PaymentResult processPayment(PaymentRequest request) {
        Payment payment = new Payment(request);
        paymentRepo.save(payment);
        jmsTemplate.convertAndSend("payment-queue", payment);
        return new PaymentResult(payment.getId(), "SUCCESS");
    }
}

Java: Converting Message-Driven Bean to Spring JMS Listener

// Input:  A Java EE MDB that processes JMS messages
// Output: Equivalent Spring Boot JMS listener

// === BEFORE: Java EE MDB ===
@MessageDriven(activationConfig = {
    @ActivationConfigProperty(
        propertyName = "destinationType",
        propertyValue = "javax.jms.Queue"),
    @ActivationConfigProperty(
        propertyName = "destination",
        propertyValue = "jms/OrderQueue")
})
public class OrderProcessorMDB implements MessageListener {
    @EJB
    private OrderService orderService;

    @Override
    public void onMessage(Message message) {
        try {
            String orderJson = ((TextMessage) message).getText();
            OrderRequest request = parseOrder(orderJson);
            orderService.createOrder(request);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
}

// === AFTER: Spring Boot JMS Listener ===
@Component
public class OrderProcessor {
    private final OrderService orderService;

    public OrderProcessor(OrderService orderService) {
        this.orderService = orderService;
    }

    @JmsListener(destination = "order-queue", concurrency = "3-10")
    public void processOrder(String orderJson) {
        OrderRequest request = parseOrder(orderJson);
        orderService.createOrder(request);
    }
}

Java: Converting EJB Interceptors to Spring AOP

// Input:  A Java EE interceptor that logs method execution time
// Output: Equivalent Spring AOP aspect

// === BEFORE: Java EE Interceptor ===
@Interceptor
public class PerformanceInterceptor {
    @AroundInvoke
    public Object measureTime(InvocationContext ctx) throws Exception {
        long start = System.currentTimeMillis();
        try {
            return ctx.proceed();
        } finally {
            long duration = System.currentTimeMillis() - start;
            System.out.println(ctx.getMethod().getName() + " took " + duration + "ms");
        }
    }
}

// === AFTER: Spring AOP Aspect ===
@Aspect
@Component
public class PerformanceAspect {
    @Around("execution(* com.myapp.service.*.*(..))")
    public Object measureTime(ProceedingJoinPoint joinPoint) throws Throwable {
        long start = System.currentTimeMillis();
        try {
            return joinPoint.proceed();
        } finally {
            long duration = System.currentTimeMillis() - start;
            log.info("{} took {}ms", joinPoint.getSignature().getName(), duration);
        }
    }
}

Anti-Patterns

Wrong: Manual JNDI lookups inside Spring beans

// ❌ BAD — JNDI lookup in a Spring-managed bean defeats dependency injection
@Service
public class OrderService {
    public DataSource getDataSource() throws NamingException {
        InitialContext ctx = new InitialContext();
        return (DataSource) ctx.lookup("java:comp/env/jdbc/MyDB");
    }
}

Correct: Use Spring's dependency injection and configuration

// ✅ GOOD — Spring auto-configures the DataSource from application.properties
@Service
public class OrderService {
    private final JdbcTemplate jdbcTemplate;

    public OrderService(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }
}
// application.properties: spring.datasource.url=jdbc:postgresql://...

Wrong: Mixing javax.* and jakarta.* imports

// ❌ BAD — Mixed namespaces cause ClassNotFoundException at runtime
import javax.persistence.Entity;        // WRONG for Spring Boot 3+
import javax.persistence.Id;
import jakarta.validation.constraints.NotNull;  // Different namespace!

@Entity
public class Order {
    @Id private Long id;
    @NotNull private String name;
}

Correct: Use jakarta.* exclusively with Spring Boot 3+

// ✅ GOOD — Consistent jakarta.* namespace
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.validation.constraints.NotNull;

@Entity
public class Order {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @NotNull private String name;
}

Wrong: Keeping WAR packaging and external app server

<!-- ❌ BAD — Still deploying as WAR to external Tomcat/JBoss -->
<packaging>war</packaging>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-tomcat</artifactId>
    <scope>provided</scope>
</dependency>

Correct: Use standalone JAR with embedded server

<!-- ✅ GOOD — Standalone executable JAR with embedded Tomcat -->
<packaging>jar</packaging>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

Wrong: Converting EJB interfaces to Spring interfaces unnecessarily

// ❌ BAD — Leftover from EJB remote/local interface pattern
public interface OrderServiceLocal {
    Order createOrder(OrderRequest request);
}
public interface OrderServiceRemote extends OrderServiceLocal {}

@Service
public class OrderServiceImpl implements OrderServiceLocal {
    public Order createOrder(OrderRequest request) { ... }
}

Correct: Use concrete service classes

// ✅ GOOD — No interface needed unless you have multiple implementations
@Service
public class OrderService {
    public Order createOrder(OrderRequest request) { ... }
}

Wrong: Big-bang migration of the entire application

// ❌ BAD — Attempting to rewrite everything at once
// - 6+ months of development with zero deployable output
// - No regression testing against live behavior
// - Business logic drift between old and new codebases

Correct: Strangler Fig pattern with incremental migration

// ✅ GOOD — Migrate one module at a time, deploy each to production
// Phase 1: OrderService EJB → Spring @Service (2 weeks)
// Phase 2: PaymentService EJB → Spring @Service (2 weeks)
// Phase 3: JMS MDBs → Spring @JmsListener (1 week)
// Phase 4: JSF → Thymeleaf or REST API (4 weeks)
// Phase 5: Remove app server, deploy as standalone JAR
// Each phase is independently deployable and testable

Common Pitfalls

Diagnostic Commands

# Find remaining javax.* imports (must be zero for Spring Boot 3+)
grep -rn 'import javax\.' --include='*.java' | grep -v 'javax.crypto\|javax.net\|javax.xml' | wc -l

# Find remaining EJB annotations (must be zero after migration)
grep -rn '@Stateless\|@Stateful\|@MessageDriven\|@Singleton' --include='*.java' | wc -l

# Find JNDI lookups (must be zero)
grep -rn 'InitialContext\|lookup(' --include='*.java' | wc -l

# Find deployment descriptors (should be deleted)
find . -name 'web.xml' -o -name 'ejb-jar.xml' -o -name 'persistence.xml' -o -name 'application.xml'

# Verify Spring Boot starts cleanly
mvn spring-boot:run 2>&1 | grep -E 'Started|ERROR|WARN'

# Check for dependency conflicts (javax vs jakarta)
mvn dependency:tree | grep -i 'javax\.\|jakarta\.'

# Verify OpenRewrite migration completeness
mvn -U org.openrewrite.maven:rewrite-maven-plugin:dryRun \
  -Drewrite.recipeArtifactCoordinates=org.openrewrite.recipe:rewrite-migrate-java:RELEASE \
  -Drewrite.activeRecipes=org.openrewrite.java.migrate.jakarta.JavaxMigrationToJakarta

# Check Spring Boot 4.0 compatibility (if targeting 4.x)
mvn -U org.openrewrite.maven:rewrite-maven-plugin:dryRun \
  -Drewrite.recipeArtifactCoordinates=org.openrewrite.recipe:rewrite-spring:RELEASE \
  -Drewrite.activeRecipes=org.openrewrite.java.spring.boot4.UpgradeSpringBoot_4_0

Version History & Compatibility

VersionStatusBreaking ChangesMigration Notes
Spring Boot 4.0 (Nov 2025)CurrentJakarta EE 11, Spring Framework 7, Jackson 3.x, JUnit 6Modular auto-config, virtual threads, JSpecify null safety, API versioning in @RequestMapping
Spring Boot 3.5 (2025)Active (supported until Jun 2026)Spring Boot properties changesLatest 3.x LTS, OpenRewrite recipe available
Spring Boot 3.4 (2024)Active (supported until Dec 2025)RestClient replaces RestTemplateIncremental improvements, no major breaks
Spring Boot 3.0 (2022)LTSjavax.*jakarta.*, Java 17+, Hibernate 6Use OpenRewrite + spring-boot-properties-migrator
Spring Boot 2.7 (2022)EOL Nov 2023Last version with javax.* supportUpgrade to 2.7 first as stepping stone to 3.x
Java EE 8 / Jakarta EE 8MaintenanceLast version with javax.* namespaceDirect migration target for Spring Boot 2.7
Jakarta EE 9–10Activejavax.* renamed to jakarta.*Aligned with Spring Boot 3.x namespace
Jakarta EE 11CurrentAdditional API updatesRequired for Spring Boot 4.0

When to Use / When Not to Use

Use WhenDon't Use WhenUse Instead
App server licensing costs are unsustainable (WebLogic/WebSphere)Java EE app is small (<5 EJBs) and stableStay on Jakarta EE with WildFly/Payara
Team needs cloud-native deployment (Docker, K8s)App is scheduled for retirement within 12 monthsKeep legacy, invest in documentation
Hiring Spring developers is easier than Java EE specialistsHeavy use of JCA connectors or proprietary app server APIsConsider Quarkus (closer to Jakarta EE)
Need microservices decomposition pathTeam has no Spring experience and tight deadlineTrain first, then migrate
Existing app server is EOL (JBoss AS 7, GlassFish 4)App relies on EJB remote calls across many servicesConsider service mesh or API gateway first
Want virtual threads and modern observability (Spring Boot 4.0)Must maintain compatibility with Java 8/11Stay on Jakarta EE or use Quarkus with older Java

Important Caveats

Related Units