@Service/@Component Spring beans, replace JNDI with dependency injection, swap the app server for embedded Tomcat, and update javax.* imports to jakarta.* for Spring Boot 3+.@Service replaces @Stateless, @Transactional replaces container-managed transactions, application.properties replaces JNDI/XML config. Use OpenRewrite JavaxMigrationToJakarta recipe for automated namespace migration.javax.* to jakarta.* 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.javax.xml.bind, reflection access). [src1]javax.* Java EE imports must become jakarta.* for Spring Boot 3+. There is no partial migration — mixed namespaces in the same compilation unit cause ClassNotFoundException at runtime. [src5]JavaxMigrationToJakarta recipe automates ~80% of the namespace change, but code using reflection to load javax.* classes (e.g., Class.forName("javax.persistence.Entity")) or string-based JNDI lookups will not be caught. Manual audit required. [src1]@Transactional annotations — omitting them silently disables transaction management and can cause partial commits. [src2]| 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) |
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
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.
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.
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.
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.
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.
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.
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.
// 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");
}
}
// 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);
}
}
// 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);
}
}
}
// ❌ 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");
}
}
// ✅ 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://...
// ❌ 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;
}
// ✅ 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;
}
<!-- ❌ 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>
<!-- ✅ GOOD — Standalone executable JAR with embedded Tomcat -->
<packaging>jar</packaging>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
// ❌ 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) { ... }
}
// ✅ GOOD — No interface needed unless you have multiple implementations
@Service
public class OrderService {
public Order createOrder(OrderRequest request) { ... }
}
// ❌ 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
// ✅ 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
javax.persistence, javax.servlet, javax.validation import must become jakarta.*. Fix: Run OpenRewrite recipe JavaxMigrationToJakarta before compiling. [src1, src5]hibernate.dialect from properties; let Hibernate auto-detect. [src1]@Stateful EJB state patterns break silently. Fix: Use @SessionScope for HTTP session state or externalize to Redis via Spring Session. [src4]InitialContext.lookup() calls throw NamingException. Fix: Replace all JNDI lookups with @Value, @ConfigurationProperties, or Spring-managed beans. [src3]RestClient or WebClient. [src4]REQUIRED for all methods; Spring only applies @Transactional where annotated. Fix: Add @Transactional to service class or individual methods. [src2]web.xml by default. Servlet filters, listeners, and URL mappings are silently lost. Fix: Register as @Bean FilterRegistrationBean or use @WebFilter with @ServletComponentScan. [src1]# 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 | Status | Breaking Changes | Migration Notes |
|---|---|---|---|
| Spring Boot 4.0 (Nov 2025) | Current | Jakarta EE 11, Spring Framework 7, Jackson 3.x, JUnit 6 | Modular auto-config, virtual threads, JSpecify null safety, API versioning in @RequestMapping |
| Spring Boot 3.5 (2025) | Active (supported until Jun 2026) | Spring Boot properties changes | Latest 3.x LTS, OpenRewrite recipe available |
| Spring Boot 3.4 (2024) | Active (supported until Dec 2025) | RestClient replaces RestTemplate | Incremental improvements, no major breaks |
| Spring Boot 3.0 (2022) | LTS | 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 | Additional API updates | Required for Spring Boot 4.0 |
| 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 |
java.util.Date, javax.xml.bind, reflection access).javax.* to jakarta.* namespace change affects ALL Java EE APIs: Servlet, JPA, Bean Validation, CDI, JMS, JAX-RS. Third-party libraries that depend on javax.* must also be upgraded to jakarta-compatible versions.javax.* classes (e.g., Class.forName("javax.persistence.Entity")) will not be caught by automated tools.@Transactional annotations. Omitting them silently disables transaction management.@Schedule uses 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.