What is EJB — and Why Does It Matter in 2025?
EJB (Enterprise JavaBeans) is a server-side component model that was the dominant way to build enterprise Java applications from roughly 1999 to 2010. An EJB is a CDI bean with additional container- managed services baked in: distributed transactions, declarative security, connection pooling, instance pooling, remote invocation, and scheduling — all without writing a line of infrastructure code.
EJBs are not dead. They are dead in new projects. Banking
systems, insurance platforms, government portals, and telecom backends
built between 2000 and 2015 run on WildFly, Payara, and WebSphere serving
millions of transactions daily — and they are full of EJBs. A senior Java
developer entering one of those environments without knowing what
@Stateless, @TransactionAttribute, or
@MessageDriven mean is walking in blind.
| Concern | EJB answer | Spring answer |
|---|---|---|
| Transactions | @TransactionAttribute on EJB methods |
@Transactional on any Spring bean |
| Security | @RolesAllowed, @DenyAll |
@PreAuthorize via Spring Security |
| Async execution | @Asynchronous on EJB method |
@Async on any Spring bean |
| Scheduling | @Schedule on @Singleton EJB |
@Scheduled on any Spring bean |
| Distributed TX | JTA, built-in to EJB container | JTA via external config, or local TX only |
| Requires | Full Jakarta EE application server | Spring Boot with embedded Tomcat |
Spring won on simplicity and developer experience. EJB 3.0 (2006) and the shift to annotations made EJBs dramatically easier than EJB 2.x, but Spring had already won the mindshare. Today, EJB Lite (no remote, no JMS) is available in the Web Profile — it's not entirely gone, but no new project should start with it when Spring exists.
Why EJB 2.x Was Painful — and Why EJB 3 Came Too Late
/*
* EJB 2.x (1999–2006) — what developers actually had to write:
*
* 1. A Remote interface (what remote callers see)
* 2. A Local interface (what local callers see)
* 3. A Home interface (factory for the bean)
* 4. The bean implementation class (extending EntityBean or SessionBean)
* 5. A deployment descriptor (ejb-jar.xml) — hundreds of lines of XML
* 6. Vendor-specific XML for each app server (jboss.xml, weblogic-ejb-jar.xml...)
*
* For a simple CRUD service: 5+ files, ~500 lines of boilerplate.
* For a domain with 20 entities: 100+ files before writing business logic.
*
* EJB 3.0 (2006) replaced all of this with:
*/
@Stateless // that's it. One annotation.
public class ProductService {
@PersistenceContext
private EntityManager em;
public Product find(Long id) {
return em.find(Product.class, id);
}
}
/*
* EJB 3 was genuinely good. The problem: Spring had already won.
* By 2006, most greenfield enterprise Java was Spring-based.
* EJB 3's elegance arrived at market too late to reclaim developers.
*/
Session Bean Types — What You'll See in Legacy Code
@Stateless — the workhorse (most common)
@Stateless
public class OrderService {
@PersistenceContext
private EntityManager em;
@Inject
private PaymentGateway gateway;
// Transaction starts automatically when this method is called.
// If it returns normally → COMMIT. If it throws RuntimeException → ROLLBACK.
public Order placeOrder(Cart cart, PaymentDetails payment) {
Order order = new Order(cart);
em.persist(order); // persisted in the same transaction
gateway.charge(payment, cart.total()); // if this throws, order.persist() rolls back too
return order;
}
}
/*
* The container maintains a POOL of @Stateless instances.
* Under load: pool grows. At rest: pool shrinks.
* No client holds a reference to a specific instance between calls.
* Equivalent to Spring's @Service (singleton by default, stateless by convention).
*/
@Stateful — one instance per client conversation
@Stateful
public class ShoppingCart {
private final List<CartItem> items = new ArrayList<>();
public void add(Product product, int qty) {
items.add(new CartItem(product, qty));
}
public BigDecimal total() {
return items.stream()
.map(CartItem::subtotal)
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
@Remove // client calls this to signal "I'm done, destroy this bean"
public Order checkout(PaymentDetails payment) {
// final order processing
return orderService.placeOrder(items, payment);
}
@PreDestroy
public void cleanup() {
// called by container after @Remove or on session timeout
}
}
Each client reference holds a dedicated instance. If clients don't call
the @Remove method (because they crash, navigate away, or
the developer simply forgot), the container accumulates unreachable
@Stateful instances until the session timeout fires —
typically 30 minutes. Under moderate load with a generous timeout,
this is a heap pressure issue that manifests as a slow memory leak.
When reading legacy code, look for @Stateful beans without
a corresponding @Remove call on every exit path.
@Singleton — one shared instance with controlled concurrency
@Singleton
@Startup // eagerly initialized at deployment, not on first access
public class AppConfigCache {
private Map<String, String> config;
@PostConstruct
public void init() {
config = loadFromDatabase(); // runs once at startup
}
// @Lock(LockType.READ) — multiple threads can call simultaneously (default for reads)
@Lock(LockType.READ)
public String get(String key) {
return config.get(key);
}
// @Lock(LockType.WRITE) — exclusive access, blocks all reads and writes
@Lock(LockType.WRITE)
public void refresh() {
config = loadFromDatabase();
}
}
/*
* Spring equivalent: @Component + @ApplicationScope (CDI) or a Spring singleton bean
* with explicit synchronization or ReadWriteLock.
* @Singleton EJB gives you the read/write lock semantics declaratively.
*/
Transaction Management — The Feature EJBs Did Best
Declarative transaction management is the feature that made EJBs genuinely valuable — and it's the one Spring copied most directly. Every EJB method has a transaction attribute that controls how it participates in an existing transaction or creates a new one:
@Stateless
public class PaymentService {
// REQUIRED (default): join existing TX, or start a new one if none exists
@TransactionAttribute(TransactionAttributeType.REQUIRED)
public void processPayment(Order order) { ... }
// REQUIRES_NEW: always start a NEW transaction, suspend any existing one
// Use for audit logging that must commit even if the calling TX rolls back
@TransactionAttribute(TransactionAttributeType.REQUIRES_NEW)
public void logPaymentAttempt(Order order, String result) { ... }
// NOT_SUPPORTED: suspend any existing TX, run without one
// Use for operations that must NOT participate in a TX (some bulk reads)
@TransactionAttribute(TransactionAttributeType.NOT_SUPPORTED)
public List<Order> exportAll() { ... }
// MANDATORY: must be called within an existing TX, throws if none
@TransactionAttribute(TransactionAttributeType.MANDATORY)
public void deductInventory(Order order) { ... }
}
Spring's @Transactional(propagation = Propagation.REQUIRES_NEW)
maps directly to EJB's
@TransactionAttribute(REQUIRES_NEW). The vocabulary is
different but the semantics are identical — Spring modelled its
transaction abstraction on EJB's declarative model. If you understand
one, you understand the other. The key advantage of Spring's version:
it works on any Spring bean, not just EJBs.
Calling an EJB method from within the same class does not go through
the container proxy — transaction and security annotations are silently
ignored. This is identical to Spring's
@Transactional self-invocation problem covered in
Spring Data JPA. In EJB legacy
code, this is one of the most common bugs: a method annotated
REQUIRES_NEW is called directly from another method in
the same bean and doesn't actually start a new transaction because
the call never went through the proxy.
Message-Driven Beans — Async Processing via JMS
A Message-Driven Bean (MDB) is an EJB that listens to a
JMS queue or topic and processes messages asynchronously. It's the Jakarta
EE answer to "I need to process work off the request thread without polling."
In modern systems this role is filled by Kafka consumers or Spring's
@JmsListener — but MDBs are widespread in legacy banking
and payment systems.
import jakarta.ejb.MessageDriven;
import jakarta.jms.*;
@MessageDriven(activationConfig = {
@ActivationConfigProperty(propertyName = "destinationType", propertyValue = "jakarta.jms.Queue"),
@ActivationConfigProperty(propertyName = "destinationLookup", propertyValue = "java:/jms/queue/OrderQueue"),
@ActivationConfigProperty(propertyName = "acknowledgeMode", propertyValue = "Auto-acknowledge")
})
public class OrderProcessingMDB implements MessageListener {
@Inject
private OrderService orderService;
@Override
public void onMessage(Message message) {
try {
TextMessage textMsg = (TextMessage) message;
Long orderId = Long.parseLong(textMsg.getText());
orderService.processOrder(orderId);
// container auto-acknowledges on successful return
} catch (JMSException e) {
throw new RuntimeException("Failed to process message", e);
// RuntimeException causes the container to NOT acknowledge → message redelivered
}
}
}
Spring's @JmsListener is the direct replacement in
Spring Boot projects: same concept (method called when a message
arrives), same JMS integration, but configured through Spring's
JmsListenerContainerFactory rather than the EJB
container's activation config XML. The acknowledgement semantics
are identical — successful return acknowledges, uncaught exception
triggers redelivery (depending on the acknowledgement mode).
Scheduling and Async — Still Useful Patterns
// @Schedule — declarative cron scheduling on @Singleton EJBs
@Singleton
public class MaintenanceScheduler {
@Schedule(hour = "2", minute = "0", persistent = false)
public void dailyCleanup() { ... } // every night at 02:00
@Schedule(minute = "*/15", hour = "*", persistent = false)
public void healthCheck() { ... } // every 15 minutes
}
// Spring equivalent: @Scheduled(cron = "0 0 2 * * *") on any @Component
// @Asynchronous — fire-and-forget or Future-based async
@Stateless
public class ReportService {
@Asynchronous
public Future<String> generateReport(Long userId) {
String report = buildExpensiveReport(userId);
return new AsyncResult<>(report); // EJB wrapper, not java.util.concurrent
}
}
// Caller can block on the result or check completion
Future<String> future = reportService.generateReport(userId);
// ... do other work ...
String report = future.get(); // blocks until ready
// Spring equivalent: @Async returning CompletableFuture<String>
By default, EJB timers are persistent — the container
stores them in a database so they survive server restarts. A daily job
that was supposed to run at 02:00 while the server was down will fire
immediately on restart. For most maintenance tasks this is wrong
behaviour: a catch-up cleanup run at 09:47 on Monday because the server
was restarted causes data anomalies. Setting
persistent = false makes the timer ephemeral — it's
simply missed if the server is down, and resumes on the next scheduled
occurrence. Always set explicitly.
When to Use EJB Today — and When Absolutely Not
| Scenario | Use EJB? | Why |
|---|---|---|
| New Spring Boot project | No | Spring provides all the same features with less operational complexity and a much larger ecosystem |
| New Quarkus project | Partially | Quarkus supports CDI fully and JAX-RS; EJB Lite is supported but CDI + MicroProfile is the idiomatic path |
| Maintaining legacy WildFly / WebSphere app | Yes — work within what's there | Migrating a 500k-line EJB codebase to Spring is a multi-year project; understand the existing patterns |
| Need distributed (XA) transactions across multiple databases | EJB container JTA is still strong here | Spring can do JTA too, but the EJB container's JTA integration is battle-tested in financial systems |
| Government / regulated systems with Jakarta EE certification requirements | Sometimes mandated | Some procurement specs explicitly require a certified Jakarta EE server — Spring doesn't certify |
Interview Questions
Q: What is the difference between @Stateless, @Stateful, and @Singleton EJBs?
@Stateless: the container pools multiple instances, any of
which can handle any request — no state is maintained between calls.
@Stateful: one instance per client, state is preserved across
method calls until the client calls a @Remove method.
@Singleton: one instance shared by all clients for the
application's lifetime, with container-managed read/write locking
via @Lock.
Q: What does @TransactionAttribute(REQUIRES_NEW) do?
It forces the method to always start a brand-new transaction, suspending
any existing transaction the caller may be running in. The new transaction
commits or rolls back independently of the caller's transaction. Common
use: audit logging that must persist even when the calling transaction
rolls back.
Q: Why did Spring replace EJB as the dominant enterprise Java framework, despite EJB 3 being a genuine improvement?
EJB 3.0 (2006) eliminated most of the EJB 2.x pain — annotations replaced
XML, POJOs replaced required interfaces, @PersistenceContext
replaced manual JNDI lookups. The problem was timing: Spring had already
achieved critical mass between 2003 and 2005, during the EJB 2.x dark age.
Spring also offered a lighter operational footprint (no full app server
required), easier unit testing (POJO-based, no container needed), and a
broader abstraction surface (data access, web MVC, batch, security — all
integrated). By the time EJB 3 was good, switching from it had higher
switching costs than staying with Spring.
Q: What is the self-invocation trap in EJBs, and why does it also affect Spring @Transactional?
EJB container services (transactions, security) are applied through a
container-generated proxy. When a method inside the same EJB calls another
method in the same class, the call bypasses the proxy entirely — it goes
directly to this. The container's interceptors never run, so
@TransactionAttribute and @RolesAllowed are
silently ignored. Spring's @Transactional has the identical
problem for the identical reason: Spring AOP also uses a proxy, and
this.someMethod() bypasses it. The fix in both cases is to
inject a reference to self via the container, or restructure to avoid
self-calls across transactional boundaries.
Q: What is persistent = false on @Schedule, and what happens when you omit it?
Without persistent = false, the EJB container persists timer
metadata to a database. On server restart, any timers that should have
fired while the server was down are fired immediately on startup —
regardless of what time it is. For a 02:00 nightly cleanup this means
it runs at 09:47 Monday morning after a weekend maintenance restart,
potentially conflicting with live business operations. Setting
persistent = false makes the timer ephemeral — missed
firings are simply skipped.