How to Migrate from Java EE to Spring Boot
How do I migrate from Java EE to Spring Boot?
TL;DR
- Bottom line: Migrate incrementally using the Strangler Fig pattern — convert EJBs to
@Service/@ComponentSpring beans, replace JNDI with dependency injection, swap the app server for embedded Tomcat, and updatejavax.*imports tojakarta.*for Spring Boot 3+. - Key tool/command:
@Servicereplaces@Stateless,@Transactionalreplaces container-managed transactions,application.propertiesreplaces JNDI/XML config. Use OpenRewriteJavaxMigrationToJakartarecipe for automated namespace migration. - Watch out for: The
javax.*tojakarta.*namespace change in Spring Boot 3+ breaks every import — use OpenRewrite to automate, not manual find-and-replace. Spring Boot 4.0 now requires Jakarta EE 11 alignment. - Works with: Spring Boot 3.5.14 (final 3.x, OSS support ends 2026-06-30) or Spring Boot 4.0.6 (current GA, supported through 2026-12-31; Java 17 minimum, Java 25 recommended), migrating from any Java EE 5–8 / Jakarta EE 8–10 app server (WebLogic, JBoss/WildFly, GlassFish, WebSphere).
Constraints
- Spring Boot 3+ requires Java 17 as the minimum JDK version. If the Java EE app runs on Java 8 or 11, the JDK must be upgraded first, which may surface deprecated API issues (
javax.xml.bind, reflection access). [src1] - All
javax.*Java EE imports must becomejakarta.*for Spring Boot 3+. There is no partial migration — mixed namespaces in the same compilation unit causeClassNotFoundExceptionat runtime. [src5] - OpenRewrite’s
JavaxMigrationToJakartarecipe automates ~80% of the namespace change, but code using reflection to loadjavax.*classes (e.g.,Class.forName("javax.persistence.Entity")) or string-based JNDI lookups will not be caught. Manual audit required. [src1] - Container-managed transactions (CMT) in EJBs apply to every business method implicitly. Spring requires explicit
@Transactionalannotations — omitting them silently disables transaction management and can cause partial commits. [src2] - Spring Boot 4.0 (current GA 4.0.6, April 2026) requires Jakarta EE 11 alignment, Jackson 3.x, and JUnit 6. Jackson 3 also moved its Maven group ID from
com.fasterxml.jacksontotools.jackson, renamed@JsonComponent/@JsonMixinto@JacksonComponent/@JacksonMixin, and reorganized properties underspring.jackson.json.read/spring.jackson.json.write. [src8, src9, src10] - Never attempt a big-bang rewrite of the entire application at once. Use the Strangler Fig pattern to migrate one module at a time, keeping both systems running in parallel until each module is validated in production. [src3]
- Spring Boot 4.0 replaces
@MockBean/@SpyBeanwith@MockitoBean/@MockitoSpyBean, removesMockitoTestExecutionListener, and stops auto-configuring MockMVC/WebClient inside@SpringBootTest. Test suites that compiled clean on 3.x will fail to compile on 4.x without these annotation renames. [src9, src10]
Quick Reference
| Java EE Pattern | Spring Boot Equivalent | Example |
|---|---|---|
@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 injection | public OrderController(OrderService svc) { this.svc = svc; } |
@PersistenceContext | @Autowired EntityManager or Spring Data JPA | public interface OrderRepo extends JpaRepository<Order, Long> {} |
@Resource JNDI lookup | @Value + application.properties | @Value("${datasource.url}") String dbUrl; |
@TransactionAttribute | @Transactional | @Transactional(propagation = REQUIRED) |
@Interceptors / @AroundInvoke | Spring AOP @Aspect + @Around | @Around("execution(* com.app.service.*.*(..))") |
@Schedule (EJB Timer) | @Scheduled | @Scheduled(cron = "0 0 * * * *") void hourlyJob() |
| JSF Managed Beans | Spring MVC @Controller + Thymeleaf | @Controller public class OrderController { ... } |
web.xml Servlet config | @SpringBootApplication auto-config | Remove web.xml, rely on embedded Tomcat |
persistence.xml | application.properties + auto-config | spring.datasource.url=jdbc:postgresql://... |
ejb-jar.xml deployment descriptor | Annotation-driven config | Delete descriptor, use @Service/@Component |
| JAAS Security | Spring 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.14 (final 3.x; OSS support ends 2026-06-30) -->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.14</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
- javax.* imports not updated to jakarta.*: Spring Boot 3+ uses Jakarta EE 9+ exclusively. Every
javax.persistence,javax.servlet,javax.validationimport must becomejakarta.*. Fix: Run OpenRewrite recipeJavaxMigrationToJakartabefore compiling. [src1, src5] - Hibernate dialect misconfiguration after upgrade: Hibernate 6 changed dialect auto-detection and removed legacy dialects. Fix: Remove explicit
hibernate.dialectfrom properties; let Hibernate auto-detect. [src1] - Stateful EJB session state lost in Spring: Spring beans are singleton-scoped by default, so
@StatefulEJB state patterns break silently. Fix: Use@SessionScopefor HTTP session state or externalize to Redis via Spring Session. [src4] - JNDI lookups fail at runtime: Spring Boot has no JNDI context by default. Any remaining
InitialContext.lookup()calls throwNamingException. Fix: Replace all JNDI lookups with@Value,@ConfigurationProperties, or Spring-managed beans. [src3] - EJB remote interfaces cause ClassNotFoundException: Remote EJB stubs depend on app server classloader. Fix: Replace remote EJB calls with REST endpoints using
RestClientorWebClient. [src4] - Transaction propagation behaves differently: EJB defaults to
REQUIREDfor all methods; Spring only applies@Transactionalwhere annotated. Fix: Add@Transactionalto service class or individual methods. [src2] - web.xml filters not picked up: Spring Boot ignores
web.xmlby default. Servlet filters, listeners, and URL mappings are silently lost. Fix: Register as@Bean FilterRegistrationBeanor use@WebFilterwith@ServletComponentScan. [src1] - Vendor-specific JMS/JCA not portable: WebLogic T3, JBoss HornetQ, or WebSphere SIBus JMS configurations don't work outside their app server. Fix: Switch to ActiveMQ Artemis, RabbitMQ, or Kafka with Spring Boot starters. [src6]
- Spring Boot 4.0 Jackson 3.x breaking changes: Jackson 3 moved its Maven group ID from
com.fasterxml.jacksontotools.jackson, renamedJsonObjectSerializer→ObjectValueSerializer,@JsonComponent→@JacksonComponent,@JsonMixin→@JacksonMixin, and regrouped properties underspring.jackson.json.read.*/spring.jackson.json.write.*. Fix: update every Jackson dependency coordinate, rename the four annotations, and rerun integration tests. [src8, src9] - Spring Boot 4.0 test annotation rename:
@MockBean/@SpyBeanare removed in 4.x; replace with@MockitoBean/@MockitoSpyBean.@SpringBootTestno longer auto-configures MockMVC/WebClient; add@AutoConfigureMockMvcexplicitly. Fix: run OpenRewrite recipeUpgradeSpringBoot_4_0which renames these automatically. [src9, src10]
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
| Version | Status | Breaking Changes | Migration Notes |
|---|---|---|---|
| Spring Boot 4.1 (2026) | RC1 released 2026-04-23 | Continues 4.x line | Pre-GA — pilot only |
| Spring Boot 4.0.6 (Apr 2026) | Current GA (OSS support through 2026-12-31) | Jakarta EE 11, Spring Framework 7, Jackson 3.x (group tools.jackson), JUnit 6, modular starters, Undertow removed | Java 17 min / Java 25 recommended; virtual threads on by default for blocking I/O; @MockitoBean replaces @MockBean; API versioning in @RequestMapping |
| Spring Boot 3.5.14 (Apr 2026) | Active (OSS support ends 2026-06-30; 5-yr commercial extension) | Final 3.x line | Move to 3.5.x before any 4.x jump — clears deprecations |
| Spring Boot 3.4 (2024) | EOL | RestClient replaces RestTemplate | Upgrade to 3.5.x first |
| Spring Boot 3.0 (2022) | EOL | javax.* → jakarta.*, Java 17+, Hibernate 6 | Use OpenRewrite + spring-boot-properties-migrator |
| Spring Boot 2.7 (2022) | EOL Nov 2023 | Last version with javax.* support | Upgrade to 2.7 first as stepping stone to 3.x |
| Java EE 8 / Jakarta EE 8 | Maintenance | Last version with javax.* namespace | Direct migration target for Spring Boot 2.7 |
| Jakarta EE 9–10 | Active | javax.* renamed to jakarta.* | Aligned with Spring Boot 3.x namespace |
| Jakarta EE 11 | Current | Servlet 6.1 baseline, additional API updates | Required for Spring Boot 4.0 |
When to Use / When Not to Use
| Use When | Don't Use When | Use Instead |
|---|---|---|
| App server licensing costs are unsustainable (WebLogic/WebSphere) | Java EE app is small (<5 EJBs) and stable | Stay on Jakarta EE with WildFly/Payara |
| Team needs cloud-native deployment (Docker, K8s) | App is scheduled for retirement within 12 months | Keep legacy, invest in documentation |
| Hiring Spring developers is easier than Java EE specialists | Heavy use of JCA connectors or proprietary app server APIs | Consider Quarkus (closer to Jakarta EE) |
| Need microservices decomposition path | Team has no Spring experience and tight deadline | Train first, then migrate |
| Existing app server is EOL (JBoss AS 7, GlassFish 4) | App relies on EJB remote calls across many services | Consider service mesh or API gateway first |
| Want virtual threads and modern observability (Spring Boot 4.0) | Must maintain compatibility with Java 8/11 | Stay on Jakarta EE or use Quarkus with older Java |
Important Caveats
- Spring Boot 3+ requires Java 17 minimum. If your Java EE app runs on Java 8, you must upgrade the JDK first. This may surface issues with deprecated APIs (
java.util.Date,javax.xml.bind, reflection access). - The
javax.*tojakarta.*namespace change affects ALL Java EE APIs: Servlet, JPA, Bean Validation, CDI, JMS, JAX-RS. Third-party libraries that depend onjavax.*must also be upgraded to jakarta-compatible versions. - OpenRewrite can automate ~80% of the namespace migration, but custom code using reflection to load
javax.*classes (e.g.,Class.forName("javax.persistence.Entity")) will not be caught by automated tools. - Container-managed transactions (CMT) in EJBs are implicit — every business method is transactional. Spring requires explicit
@Transactionalannotations. Omitting them silently disables transaction management. - EJB
@Scheduleuses different cron syntax than Spring's@Scheduled. Spring uses 6 fields (second, minute, hour, day, month, weekday); Java EE uses different field orders. Double-check expressions during migration. - Spring Boot 4.0 (current GA 4.0.6, April 2026) introduces Jakarta EE 11, Jackson 3.x (group ID moved from
com.fasterxml.jacksontotools.jackson), JUnit 6, modular starters, and removes Undertow / Spock /MockitoTestExecutionListener. If migrating directly from Java EE, do not jump straight to 4.0 — land on 3.5.14 first to clear deprecations, then run the OpenRewriteUpgradeSpringBoot_4_0composite recipe. Spring 3.5 OSS support ends 2026-06-30, so plan the 3.5 → 4.0 jump before that date or buy commercial extended support.