Jakarta EE is the set of standard APIs for server-side Java: dependency injection, REST endpoints, persistence, transactions, validation, security, messaging and more. It is what Java EE became after Oracle handed the platform to the Eclipse Foundation in 2017. The code you write against it is portable across several independent runtimes, and the same APIs sit underneath frameworks that do not call themselves Jakarta EE at all: Spring Boot 3 is built on Jakarta Servlet, Persistence and Validation, and Quarkus and Helidon implement many of the specifications directly.

This page explains how the platform is organised, from specifications and test kits down to profiles, then builds a small orders service using CDI, Jakarta REST, Persistence and the new Jakarta Data repositories. It covers concurrency with virtual threads, the javax-to-jakarta namespace migration that still blocks many upgrades, and the failure modes you will meet in production. Version numbers are taken from the Jakarta EE 11 release plan published by the Eclipse Foundation.

Advertisement

How the platform is organised

Platformadds Messaging, Batch, Connectors, Mail, Enterprise Beans, full Transactions ...Web Profileadds Servlet, Pages, Faces, Persistence, Validation, Security, Concurrency, WebSocket ...Core ProfileCDI Lite, REST, JSON-P, JSON-B, Annotations, Interceptors, Dependency InjectionSpecificationAPI + documentTCKcompatibility test kitCompatible implementationGlassFish, WildFly, Liberty ...passesEach profile is a superset of the one inside it. Your code depends only on APIs; the runtime you deploy to supplies the implementation.
Jakarta EE profiles nest inside each other. A specification is only usable once a runtime passes its TCK.

Every Jakarta EE technology is a specification: a document plus an API jar such as jakarta.ws.rs-api. Each specification has a TCK, the test suite an implementation must pass to call itself compatible. A runtime such as Eclipse GlassFish, WildFly, Open Liberty or Payara bundles implementations of many specifications and is certified against a profile. Your application compiles against the API jars with provided scope and the runtime supplies the implementation at deployment. That separation is the source of portability, and also of a class of bugs when an application accidentally bundles a second implementation.

There are three profiles. The Core Profile, added in Jakarta EE 10, is a small set for microservices and ahead-of-time compilation: CDI Lite, REST, JSON Processing and Binding, Annotations, Interceptors and Dependency Injection. The Web Profile adds the servlet stack, Faces, Persistence, Validation, Security, Concurrency and WebSocket. The full Platform adds the older enterprise pieces such as Messaging, Batch, Connectors, Mail and Enterprise Beans.

From Java EE to Jakarta EE 11

Jakarta EE 8, released in 2019, was API-identical to Java EE 8 and kept the javax packages. Oracle did not license the javax namespace for new development, so Jakarta EE 9 (2020) was essentially one change: renaming every package from javax.* to jakarta.*. That release broke binary compatibility for the entire ecosystem in one step. Jakarta EE 10 (2022) brought the Core Profile and Java 11 as the minimum. Jakarta EE 11, whose full Platform was announced on 26 June 2025 after the Core Profile in December 2024 and the Web Profile in March 2025, requires Java SE 17 or later, supports Java records and virtual threads, adds Jakarta Data 1.0, removes Managed Beans and drops references to the SecurityManager.

SpecificationJakarta EE 11 versionWhat it gives you
CDI4.1Dependency injection, scopes, events, interceptors
RESTful Web Services4.0Annotated HTTP resources, client API
Persistence3.2Object-relational mapping and JPQL
Data1.0 (new)Repository interfaces implemented by the runtime
Servlet6.1HTTP request handling underneath everything web
Validation3.1Constraint annotations, now on records
Security4.0Authentication mechanisms and identity stores
Concurrency3.1Managed executors, virtual-thread support
Faces4.1Server-side component UI

Jakarta EE 12 was planned for 2026 with Java SE 21 as its minimum, according to the Eclipse Foundation's release plan. At the time of writing its final release could not be confirmed, so check jakarta.ee before targeting it.

Advertisement

CDI from first principles

CDI is the glue of the platform. The container scans your classes, and any class with a bean-defining annotation such as @ApplicationScoped or @RequestScoped becomes a bean. When another bean declares @Inject OrderService service, the container resolves the dependency by type plus qualifiers at deployment, and fails the deployment if zero or several beans match. That fail-fast check is one of CDI's most useful properties: wiring errors appear at start-up, not on the first request.

Normal-scoped beans are injected as client proxies. The field holds a proxy, and every call looks up the real instance for the current context: one per application, one per HTTP request, one per session. This is why a request-scoped bean can be injected into an application-scoped one safely, and why CDI needs proxyable classes, meaning not final and with a non-private no-argument constructor. Interceptors such as @Transactional also work through this proxy, so a call from one method to another inside the same object bypasses them. That self-invocation trap causes many missing-transaction bugs.

Other building blocks: producer methods create beans from code you do not own, qualifiers distinguish two beans of the same type, and events (Event<T> with @Observes methods) decouple publishers from listeners, optionally after the transaction commits with @Observes(during = TransactionPhase.AFTER_SUCCESS). CDI Lite, used by the Core Profile, removes features that need runtime reflection, such as portable extensions, so frameworks like Quarkus can resolve the whole bean graph at build time.

Worked example: an orders service

Build a service that accepts an order over HTTP, validates it, stores it and publishes an event. Start with the entity and two records. Records work for request and event types because Jakarta EE 11 supports them in Validation and JSON Binding; Persistence entities must still be classes, because a provider needs to mutate and proxy them.

@Entity
@Table(name = "purchase_order")
public class PurchaseOrder {
    @Id @GeneratedValue
    private Long id;
    @NotBlank
    private String customerId;
    @NotNull @Positive
    private BigDecimal total;
    private String status = "NEW";
    private Instant createdAt = Instant.now();
    // getters and setters omitted
}

public record NewOrder(@NotBlank String customerId, @NotNull @Positive BigDecimal total) {}
public record OrderPlaced(long orderId) {}

Next, a Jakarta Data repository. You declare an interface and the runtime generates the implementation. CrudRepository provides insert, update, save, delete and findById. @Find methods derive the query from parameters annotated with @By, and @Query takes JDQL, a subset of JPQL in which the select and from clauses can be omitted.

import jakarta.data.page.Page;
import jakarta.data.page.PageRequest;
import jakarta.data.repository.*;

@Repository
public interface PurchaseOrders extends CrudRepository<PurchaseOrder, Long> {

    @Find
    List<PurchaseOrder> byCustomer(@By("customerId") String customerId);

    @Query("where status = :status order by createdAt desc")
    Page<PurchaseOrder> byStatus(@Param("status") String status, PageRequest page);
}

Finally, the service and resource. The service owns the transaction boundary, and the resource owns HTTP concerns: validation, status codes and the Location header.

@ApplicationScoped
public class OrderService {

    @Inject PurchaseOrders orders;
    @Inject Event<OrderPlaced> placed;

    @Transactional
    public PurchaseOrder place(NewOrder req) {
        var o = new PurchaseOrder();
        o.setCustomerId(req.customerId());
        o.setTotal(req.total());
        PurchaseOrder saved = orders.insert(o);
        placed.fire(new OrderPlaced(saved.getId()));   // observers run in this transaction
        return saved;
    }

    public Optional<PurchaseOrder> find(long id) { return orders.findById(id); }
}

@ApplicationPath("/api")
public class Api extends Application {}

@Path("/orders")
@RequestScoped
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class OrderResource {

    @Inject OrderService service;

    @POST
    public Response create(@Valid NewOrder req, @Context UriInfo uri) {
        PurchaseOrder o = service.place(req);
        return Response.created(uri.getAbsolutePathBuilder().path(o.getId().toString()).build())
                       .entity(o).build();
    }

    @GET @Path("{id}")
    public PurchaseOrder get(@PathParam("id") long id) {
        return service.find(id).orElseThrow(NotFoundException::new);
    }
}

Follow a request through it. Servlet receives POST /api/orders, the REST runtime selects OrderResource.create, JSON Binding deserialises the body into a NewOrder record, and Validation checks it, turning a violation into a 400 response before your code runs. The call to service.place goes through the CDI proxy, where the transactional interceptor starts a JTA transaction. The repository insert flushes through Persistence, observers of OrderPlaced run, and the transaction commits when the method returns or rolls back on a runtime exception. The resource then returns 201 with a Location header. Persistence mapping details, lazy loading and the N+1 query problem are covered in Hibernate and JPA.

Concurrency and virtual threads

Application code in a Jakarta EE server should not create its own threads, because unmanaged threads do not carry the security identity, naming context or class loader of the request. Jakarta Concurrency provides managed executors that do. Version 3.1 adds a virtual attribute, so on Java 21 the runtime backs the executor with virtual threads; on Java 17 the flag is ignored and platform threads are used. The @Asynchronous annotation runs a CDI method on such an executor.

@ManagedExecutorDefinition(name = "java:app/concurrent/virtual", virtual = true, maxAsync = 200)
@ApplicationScoped
public class PriceLookup {

    @Inject SupplierClient client;   // blocking HTTP client

    @Asynchronous(executor = "java:app/concurrent/virtual")
    public CompletableFuture<Price> quote(String sku) {
        Price p = client.fetch(sku);              // blocks a cheap virtual thread, not a platform thread
        return Asynchronous.Result.complete(p);
    }
}

// caller: fan out, then join
List<CompletableFuture<Price>> futures = skus.stream().map(lookup::quote).toList();
List<Price> prices = futures.stream().map(CompletableFuture::join).toList();

This suits fan-out to blocking services: two hundred concurrent supplier calls cost two hundred cheap virtual threads rather than a large thread pool. Two cautions. Virtual threads do not make a database faster, so a connection pool of twenty still caps database concurrency at twenty, and each blocked call keeps its connection. And code that blocks while holding a monitor can pin the carrier thread on older JDKs. See virtual threads for how scheduling and pinning work.

Migrating from javax to jakarta

The namespace change is mechanical but total: source imports, XML descriptors such as persistence.xml and web.xml, string literals in configuration and every library on the classpath must agree. A WAR that mixes a javax.servlet filter from an old library with a jakarta.servlet runtime fails with ClassNotFoundException or with a filter that silently never runs. Not every javax package moves. javax.sql, javax.naming, javax.crypto and other packages that belong to Java SE stay as they are, which is why blind search-and-replace breaks builds.

# 1. Rewrite source imports and XML namespaces with OpenRewrite (Maven)
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

# 2. Find every javax.* that is still on the classpath and is NOT part of Java SE
mvn dependency:tree | grep -E "javax\.(servlet|persistence|ws\.rs|inject|enterprise|validation|faces|ejb|annotation)"
grep -rn "javax\.\(servlet\|persistence\|ws\.rs\|inject\|enterprise\)" src/ --include=*.java --include=*.xml

# 3. Third-party jar with no jakarta release yet: transform the binary as a stopgap
java -jar org.eclipse.transformer.cli.jar legacy-lib.jar legacy-lib-jakarta.jar

Work bottom-up: upgrade every dependency to a release built for jakarta, rewrite your own code with OpenRewrite, then grep for leftovers. Use the Eclipse Transformer on binaries only as a stopgap for an abandoned library. Spring applications make the same move when going to Spring Boot 3; Spring Boot covers that path.

Runtimes and deployment shapes

There are two ways to run Jakarta EE code. The traditional way deploys a thin WAR containing only your classes to an application server that provides every specification. It keeps artifacts small and lets operations patch the server independently, but couples your release to the server's version. The modern way packages a runnable jar with an embedded runtime: Open Liberty, WildFly with Galleon layers and Payara Micro can all trim the server to the features you use, and Quarkus and Helidon build on Core Profile APIs with fast start-up and small images. MicroProfile adds configuration, health checks, metrics, fault tolerance and OpenAPI on top of the Core Profile, and most runtimes ship both.

Failure modes

SymptomCauseFix
Deployment fails: unsatisfied or ambiguous dependencyNo bean, or two beans, match the injection typeAdd a scope annotation, a qualifier or an alternative
Transaction not started; changes not savedSelf-invocation bypassed the interceptorCall through another bean or move the boundary
ClassNotFoundException for javax classesLibrary still built against javaxUpgrade the library or transform the jar
NoSuchMethodError at runtimeAPI or implementation jar bundled in the WAR clashes with the server'sUse provided scope for jakarta API jars
LazyInitializationException in JSON outputSerialising entities after the transaction closedReturn DTOs or fetch-join what the response needs
Threads exhausted under loadBlocking calls on a small managed poolVirtual-thread executor, timeouts, bulkheads

Trade-offs

  • Standards versus pace: specifications move through committees and TCKs, so features land later than in a single-vendor framework, in exchange for multiple compatible implementations.
  • Application server versus embedded runtime: shared servers centralise patching; embedded runtimes give each service its own versions and faster start-up.
  • Jakarta Data versus hand-written Persistence code: repositories remove boilerplate, but complex queries and fetch tuning still need the EntityManager underneath.
  • Full Platform versus profiles: Messaging, Batch and Enterprise Beans are useful in older estates, but new services rarely need more than the Web or Core Profile.

What to do next

  1. Inventory your applications: Java version, javax or jakarta, and the runtime each one deploys to.
  2. For javax code, upgrade dependencies first, then run the OpenRewrite migration recipe and grep for leftovers.
  3. Target Jakarta EE 11 on Java 21 so records and virtual-thread executors are available.
  4. Rebuild one small service with CDI, REST and a Jakarta Data repository to learn the model.
  5. Audit transactional methods for self-invocation and entities leaking into responses.
  6. Choose the smallest profile and the deployment shape, server or embedded, that fits each service.
  7. Watch jakarta.ee for the Jakarta EE 12 release and its runtime certifications before planning an upgrade.
Key takeaway: Jakarta EE is the vendor-neutral set of server-side Java specifications, each with a TCK, delivered by several compatible runtimes in three nested profiles. Jakarta EE 11 requires Java 17, adds Jakarta Data repositories, record support and virtual-thread executors. Learn CDI's proxy model, because scopes, interceptors and transactions all depend on it, complete the javax-to-jakarta migration from the dependencies up, and choose the smallest profile and deployment shape that fits each service.