Since Jakarta EE 10, especially with Jakarta Concurrency 3.0, we can use CDI to substitute a @Stateless EJB. Jakarta Concurrency 3.0 delivers the new @Asynchronous annotation as well as the CronTrigger helper to substitute EJB's @Asynchronous and @Schedule. The @Transactional annotation and its Transactional.TxType enum, substituting EJB's @TransactionAttribute and TransactionAttributeType, are not new; they arrived with JTA 1.2 in Java EE 7 and merely moved from the javax.transaction package to jakarta.transaction in Jakarta Transactions 2.0. Declaratively transactional CDI beans were thus possible long before; what was missing until Jakarta EE 10 is everything else a @Stateless bean does.
In case you wish to migrate away from EJB to CDI, or simply need to know the "canonical" CDI approach to transactional business service beans, then you may find this guideline helpful.
Here's how the average stateless bean in EJB looks like (note: method arguments and return types are omitted for brevity):
package com.example;
import jakarta.ejb.EJB;
import jakarta.ejb.Stateless;
import jakarta.ejb.TransactionAttribute;
import jakarta.ejb.TransactionAttributeType;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
@Stateless
public class StatelessBeanInEJB {
@PersistenceContext
private EntityManager entityManager;
// The @TransactionAttribute(TransactionAttributeType.REQUIRED) annotation is optional; this is the default already.
public void transactionalMethod() {
// ...
}
@TransactionAttribute(TransactionAttributeType.NOT_SUPPORTED)
public void nonTransactionalMethod() {
// ...
}
@TransactionAttribute(TransactionAttributeType.REQUIRES_NEW)
public void independentTransactionalMethod() {
// ...
}
@TransactionAttribute(TransactionAttributeType.SUPPORTS)
public void optionalTransactionalMethod() {
// ...
}
}
And here's the equivalent in CDI, with help of the in Jakarta Transactions 2.0 introduced Transactional.TxType enum, and demonstrating that you can perfectly fine continue using the EntityManager the same way:
package com.example;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import jakarta.transaction.Transactional;
import jakarta.transaction.Transactional.TxType;
@ApplicationScoped
public class StatelessBeanInCDI {
@PersistenceContext
private EntityManager entityManager;
@Transactional // The annotation value TxType.REQUIRED is optional; this is the default already.
public void transactionalMethod() {
// ...
}
@Transactional(TxType.NOT_SUPPORTED)
public void nonTransactionalMethod() {
// ...
}
@Transactional(TxType.REQUIRES_NEW)
public void independentTransactionalMethod() {
// ...
}
@Transactional(TxType.SUPPORTS)
public void optionalTransactionalMethod() {
// ...
}
}
There is one asymmetry between the two examples above which they do not show by themselves: a method without any annotation does not mean the same thing in both worlds. In EJB, a public method of a @Stateless bean without @TransactionAttribute gets REQUIRED, so it is always transactional. In CDI, a method without @Transactional gets no interceptor at all, so it runs in whatever transaction context the caller happens to have: none when it is invoked directly, but the caller's transaction when it is invoked from another transactional service. A mechanical migration which replaces @Stateless by @ApplicationScoped and leaves the methods untouched therefore silently drops the transaction from every method which relied on the EJB default.
This also changes when @Transactional(TxType.NOT_SUPPORTED) is worth writing down. In EJB it is never optional, as leaving it away gives you REQUIRED. In CDI it is optional as long as the method is only ever invoked outside a transaction, and it becomes mandatory as soon as another service can invoke it from within one and you want that transaction suspended for the duration. Mind that this only counts for invocations arriving through the bean's proxy; an invocation of another method of the same instance bypasses the interceptor, so the annotation does nothing there. That is not new in CDI, EJB's @TransactionAttribute has always had the same blind spot.
Rollback behavior needs a second look as well. The defaults match: an unchecked exception rolls the transaction back and a checked one does not. How you deviate from it does not match. EJB has @ApplicationException(rollback = true) on the exception class, which CDI @Transactional does not know about at all; its equivalent is @Transactional(rollbackOn = YourException.class) on the method, and dontRollbackOn for the other direction. So every @ApplicationException in your code base quietly stops having any effect, without anything failing to compile. While you're at it, grep for catch (EJBException as well: EJB wraps a system exception at the bean boundary and @Transactional rethrows yours as it is, and a MANDATORY or NEVER violation now throws TransactionalException instead of EJBTransactionRequiredException.
Noted should be that @Stateless has one more feature in EJB: it's pooled. There's no such equivalent in CDI and there is also not really a need for it as the stateless CDI bean has been marked @ApplicationScoped and CDI instances are unsynchronized while EJB instances are synchronized. In case you need a business service bean which potentially holds state, and therefore you're forced to mark it @RequestScoped, and you want to reduce the cost of construction, then you could consider using the @Pooled annotation of the OmniServices utility library for this.
Another reason to use pooling would be "throttling", so that the back-end access is kind of secured behind a FIFO queue at business service bean level (even though it could also be throttled at HTTP server, JDBC connection pool, and DB level). There's also no such equivalent in CDI, but it might be good to know that the upcoming Jakarta Concurrency 3.2 for Jakarta EE 12 may according to issue 136 offer a @MaxConcurrency annotation for that. My advice is, measuring is knowing. Hardware and JVM (garbage collection especially) capabilities have made so much progress since the introduction of EJB (1998!) that one should wonder whether throttling at business service level and/or reducing the constructor calls is still absolutely necessary these days.
Also noted should be that @Transactional is also supported as a class-level annotation. So that opens the possibility to create a new CDI @Stereotype annotation such as @Service which basically combines @ApplicationScoped and @Transactional into a single annotation, much closer to the behavior of @Stateless. Given the asymmetry explained above, that is also the safest thing to migrate to: a class-level @Transactional restores the "transactional unless stated otherwise" default which you had in EJB, and the methods which do not want one keep saying so with TxType.NOT_SUPPORTED exactly like they already did.
This one does unfortunately not ride along with the migration, and it fails silently. The @RolesAllowed, @PermitAll, @DenyAll and @RunAs annotations live in the neutral jakarta.annotation.security package, but they are only defined and enforced by Jakarta Enterprise Beans. CDI has no interceptor for them. Change a @Stateless bean to an @ApplicationScoped bean and these annotations basically degrade to a comment: it still compiles, it still reads as if the method is protected, but it lets everybody through.
@Stateless
public class StatelessBeanInEJB {
@RolesAllowed("ADMIN")
public void deleteEverything() {
// ...
}
}
The portable replacement is the SecurityContext of Jakarta Security, which you can inject anywhere CDI reaches, and a plain check at the top of the method:
package com.example;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.security.enterprise.SecurityContext;
@ApplicationScoped
public class StatelessBeanInCDI {
@Inject
private SecurityContext securityContext;
public void deleteEverything() {
if (!securityContext.isCallerInRole("ADMIN")) {
throw new SecurityException("Deleting everything requires the ADMIN role.");
}
// ...
}
}
If you'd rather keep it declarative, then you can perfectly fine keep @RolesAllowed itself. You cannot put @InterceptorBinding on it, as you do not own the annotation, but a CDI extension can add a binding of your own to every type and method which carries it. Your beans then keep exactly the annotation they already had in EJB, and the day Jakarta Security delivers this out of the box you throw away three classes and change nothing else. Start with the binding, which needs no members at all, as the roles are read from the standard annotation later on:
package com.example;
import static java.lang.annotation.ElementType.METHOD;
import static java.lang.annotation.ElementType.TYPE;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import jakarta.enterprise.util.AnnotationLiteral;
import jakarta.interceptor.InterceptorBinding;
@InterceptorBinding
@Retention(RUNTIME)
@Target({ TYPE, METHOD })
public @interface RolesAllowedBinding {
final class Literal extends AnnotationLiteral<RolesAllowedBinding> implements RolesAllowedBinding {
public static final Literal INSTANCE = new Literal();
}
}
Then the extension which puts that binding wherever @RolesAllowed appears. The @WithAnnotations keeps the observer from being invoked for every single class in the application:
package com.example;
import jakarta.annotation.security.RolesAllowed;
import jakarta.enterprise.event.Observes;
import jakarta.enterprise.inject.spi.Extension;
import jakarta.enterprise.inject.spi.ProcessAnnotatedType;
import jakarta.enterprise.inject.spi.WithAnnotations;
public class RolesAllowedExtension implements Extension {
public <T> void bindRolesAllowed(@Observes @WithAnnotations(RolesAllowed.class) ProcessAnnotatedType<T> event) {
var configurator = event.configureAnnotatedType();
if (configurator.getAnnotated().isAnnotationPresent(RolesAllowed.class)) {
configurator.add(RolesAllowedBinding.Literal.INSTANCE);
}
configurator.filterMethods(method -> method.isAnnotationPresent(RolesAllowed.class))
.forEach(method -> method.add(RolesAllowedBinding.Literal.INSTANCE));
}
}
Which is registered the usual way, as a single line holding the fully qualified class name in META-INF/services/jakarta.enterprise.inject.spi.Extension. And finally the interceptor, which reads the roles from the standard annotation, on the method first and on the declaring class second, exactly like EJB did:
package com.example;
import static jakarta.interceptor.Interceptor.Priority.APPLICATION;
import static java.util.Arrays.stream;
import jakarta.annotation.Priority;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Inject;
import jakarta.interceptor.AroundInvoke;
import jakarta.interceptor.Interceptor;
import jakarta.interceptor.InvocationContext;
import jakarta.security.enterprise.SecurityContext;
@Interceptor
@RolesAllowedBinding
@Priority(APPLICATION)
public class RolesAllowedInterceptor {
@Inject
private SecurityContext securityContext;
@AroundInvoke
public Object checkRoles(InvocationContext context) throws Exception {
var method = context.getMethod();
var rolesAllowed = method.getAnnotation(RolesAllowed.class);
if (rolesAllowed == null) {
rolesAllowed = method.getDeclaringClass().getAnnotation(RolesAllowed.class);
}
if (stream(rolesAllowed.value()).noneMatch(securityContext::isCallerInRole)) {
throw new SecurityException("Requires one of roles " + String.join(", ", rolesAllowed.value()) + ".");
}
return context.proceed();
}
}
The @Priority enables it application-wide, so there is no <interceptors> entry needed in beans.xml. The bean is now the EJB one with only the scope annotation swapped, which is what you wanted from a migration guide in the first place:
@ApplicationScoped
public class StatelessBeanInCDI {
@RolesAllowed("ADMIN")
public void deleteEverything() {
// ...
}
}
@PermitAll and @DenyAll fit in the very same extension; add them to the @WithAnnotations and branch on them in the interceptor. And in case you'd rather not ship an extension at all, then declare an @InterceptorBinding annotation of your own carrying the role names as a member instead, and mind to mark that member @Nonbinding, or the interceptor is only applied to methods which name exactly the same roles as the interceptor class itself.
One detail worth knowing when the same bean is also transactional: the @Transactional interceptor sits at priority PLATFORM_BEFORE + 200, and smaller values run first, so at APPLICATION priority your role check runs inside the transaction and a denied invocation begins and rolls back one for nothing. Give the interceptor a priority below 200 to have it run before the transaction starts.
Which is, in the end, exactly what Jakarta Security intends to deliver out of the box; issue 295 is scheduled for Jakarta Security 5.0, part of Jakarta EE 12, and the direction there is to keep the existing @RolesAllowed annotation and let a CDI extension register the interceptor for it. Some servers already ship a proprietary annotation which does exactly that today, so do check yours before writing your own.
In case you're now thinking that you have definitely seen @RolesAllowed work outside an EJB, then that was most likely on a Jakarta REST resource. That is the REST implementation applying it, not CDI, and it stops at the resource: the business service bean which the resource delegates to is not covered, and neither is any caller which never passes through REST, such as a Faces backing bean or a scheduled job.
Whichever you pick, do not let it be the only line of defense. Coarse-grained authorization belongs in the web layer anyway, as a <security-constraint> in web.xml or as @ServletSecurity on the servlet, and that part keeps working exactly as before. As to @RunAs, there is no CDI equivalent at all, not even a hand-written one, as there is no standard way to swap the caller principal for the duration of an invocation. In case you depend on it, then that specific bean is a reason to keep an EJB around.
Here's how the average @Asynchronous methods in EJB look like:
package com.example;
import java.util.concurrent.Future;
import jakarta.ejb.AsyncResult;
import jakarta.ejb.Asynchronous;
import jakarta.ejb.EJB;
import jakarta.ejb.Stateless;
import jakarta.ejb.TransactionAttribute;
import jakarta.ejb.TransactionAttributeType;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
@Stateless
public class StatelessBeanInEJB {
@PersistenceContext
private EntityManager entityManager;
@Asynchronous
@TransactionAttribute(TransactionAttributeType.REQUIRES_NEW)
public Future<Void> asyncTransactionalMethod(YourEntity yourEntity) {
// ...
return new AsyncResult<>(null);
}
@Asynchronous
@TransactionAttribute(TransactionAttributeType.NOT_SUPPORTED)
public Future<YourEntity> asyncNonTransactionalMethod() {
// ...
return new AsyncResult<>(yourEntity);
}
}
And here's the equivalent in CDI, with help of the in Jakarta Concurrency 3.0 introduced @Asynchronous annotation:
package com.example;
import java.util.concurrent.CompletableFuture;
import jakarta.enterprise.concurrent.Asynchronous;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import jakarta.transaction.Transactional;
import jakarta.transaction.Transactional.TxType;
@ApplicationScoped
public class StatelessBeanInCDI {
@PersistenceContext
private EntityManager entityManager;
@Asynchronous
@Transactional(TxType.REQUIRES_NEW)
public CompletableFuture<Void> asyncTransactionalMethod(YourEntity yourEntity) {
// ...
return Asynchronous.Result.complete(null);
}
@Asynchronous
@Transactional(TxType.NOT_SUPPORTED)
public CompletableFuture<YourEntity> asyncNonTransactionalMethod() {
// ...
return Asynchronous.Result.complete(yourEntity);
}
}
Be careful with IDE autocomplete when importing the @Asynchronous annotation! In order to get it to work in a CDI managed bean, it needs to come from the jakarta.enterprise.concurrent package instead of the jakarta.ejb package. Also note that an asynchronous transactional method must always start with transaction type REQUIRES_NEW.
Here's how the average startup bean in EJB looks like:
package com.example;
import jakarta.annotation.PostConstruct;
import jakarta.ejb.Singleton;
import jakarta.ejb.Startup;
@Startup
@Singleton
public class StartupBeanInEJB {
@PostConstruct
public void init() {
// ...
}
}
And here's the equivalent in CDI, with help of the in CDI 4.0 introduced Startup event:
package com.example;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.event.Observes;
import jakarta.enterprise.event.Startup;
@ApplicationScoped
public class StartupBeanInCDI {
public void init(@Observes Startup startup) {
// ...
}
}
You need to keep in mind that the @ApplicationScoped is not read-write locked, unlike @Singleton, even though many people don't need it and simply unlock the @Singleton via an additional @ConcurrencyManagement(BEAN) annotation. In case read-write locking is actually a technical requirement, generally to avoid DB deadlocks at business service level, or to avoid multiple instances of the same scheduled job being running at the same time, then you might want to consider using the @Lock annotation of the OmniServices utility library for this. An equivalent of the @Lock annotation is namely also lacking in CDI, but there's currently an open issue to include it in the upcoming Jakarta Concurrency 3.2 for Jakarta EE 12: issue 135.
In case the startup CDI bean happens to be part of a WAR instead of a JAR, and you happen to already use OmniFaces, then you could also use its @Eager instead of @Observes Startup.
Here's how the average background task scheduler in EJB looks like:
package com.example;
import jakarta.ejb.Schedule;
import jakarta.ejb.Singleton;
@Singleton
public class ScheduledTasksBeanInEJB {
@Schedule(hour="0", minute="0", second="0", persistent=false)
public void someDailyJob() {
// ... runs daily at midnight
}
@Schedule(hour="*", minute="0", second="0", persistent=false)
public void someHourlyJob() {
// ... runs every hour of the day
}
@Schedule(hour="*", minute="*/15", second="0", persistent=false)
public void someQuarterlyJob() {
// ... runs every 15th minute of the hour
}
@Schedule(hour="*", minute="*", second="*/5", persistent=false)
public void someFiveSecondelyJob() {
// ... runs every 5th second of the minute
}
}
And here's the equivalent in CDI, with help of the in Jakarta Concurrency 3.0 introduced CronTrigger helper:
package com.example;
import java.time.ZoneId;
import jakarta.annotation.Resource;
import jakarta.enterprise.concurrent.CronTrigger;
import jakarta.enterprise.concurrent.ManagedScheduledExecutorService;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.event.Observes;
import jakarta.enterprise.event.Startup;
@ApplicationScoped
public class ScheduledTasksBeanInCDI {
@Resource
private ManagedScheduledExecutorService scheduler;
public void init(@Observes Startup startup) {
scheduler.schedule(this::someDailyJob, new CronTrigger(ZoneId.systemDefault()).hours("0").minutes("0").seconds("0"));
scheduler.schedule(this::someHourlyJob, new CronTrigger(ZoneId.systemDefault()).hours("*").minutes("0").seconds("0"));
scheduler.schedule(this::someQuarterlyJob, new CronTrigger(ZoneId.systemDefault()).hours("*").minutes("*/15").seconds("0"));
scheduler.schedule(this::someFiveSecondelyJob, new CronTrigger(ZoneId.systemDefault()).hours("*").minutes("*").seconds("*/5"));
}
public void someDailyJob() {
// ... runs daily at midnight
}
public void someHourlyJob() {
// ... runs every hour of the day
}
public void someQuarterlyJob() {
// ... runs every 15th minute of the hour
}
public void someFiveSecondelyJob() {
// ... runs every 5th second of the minute
}
}
Yeah, it's remarkably more verbose. Fortunately this will be improved in Jakarta Concurrency 3.1, part of Jakarta EE 11. You can then simply use @Asynchronous(runAt = @Schedule(...)) on the methods like below, theoretically:
package com.example;
import jakarta.enterprise.concurrent.Asynchronous;
import jakarta.enterprise.concurrent.Schedule;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class ScheduledTasksBeanInCDI {
@Asynchronous(runAt = @Schedule())
public void someDailyJob() {
// ... runs daily at midnight
}
@Asynchronous(runAt = @Schedule(hours = {}))
public void someHourlyJob() {
// ... runs every hour of the day
}
@Asynchronous(runAt = @Schedule(hours = {}, minutes = { 0, 15, 30, 45 }))
public void someQuarterlyJob() {
// ... runs every 15th minute of the hour
}
@Asynchronous(runAt = @Schedule(hours = {}, minutes = {}, seconds = { 0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55 }))
public void someFiveSecondelyJob() {
// ... runs every 5th second of the minute
}
}
It also supports a cron expression string, following the rules of CronTrigger API:
package com.example;
import jakarta.enterprise.concurrent.Asynchronous;
import jakarta.enterprise.concurrent.Schedule;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class ScheduledTasksBeanInCDI {
@Asynchronous(runAt = @Schedule(cron = "0 0 0 * * *"))
public void someDailyJob() {
// ... runs daily at midnight
}
@Asynchronous(runAt = @Schedule(cron = "0 0 * * * *"))
public void someHourlyJob() {
// ... runs every hour of the day
}
@Asynchronous(runAt = @Schedule(cron = "0 */15 * * * *"))
public void someQuarterlyJob() {
// ... runs every 15th minute of the hour
}
@Asynchronous(runAt = @Schedule(cron = "*/5 * * * * *"))
public void someFiveSecondelyJob() {
// ... runs every 5th second of the minute
}
}
To reiterate, you need to keep in mind that the @ApplicationScoped is not read-write locked, unlike @Singleton. In case that is a technical requirement, then you might want to consider using the @Lock annotation of the OmniServices utility library for this.
It has never had any use in web based applications, it was only useful in CORBA/RMI which is completely obsoleted by web services these days, so forget about it. You'd best migrate it to a stateless @ApplicationScoped bean, if necessary in combination with a @SessionScoped or even @ViewScoped managed bean to keep track of client's stateful data.