EJB — Enterprise JavaBeans

What EJBs were, why Spring replaced them, what still survives in legacy systems, and how to recognise them when you encounter them

← Back to Index

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.

EJB vs Spring — the same problem, different answers
ConcernEJB answerSpring 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
    }
}
@Stateful is the EJB type most likely to cause memory problems in legacy code

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 @Transactional has the same propagation types — same mental model

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.

The self-invocation trap — same in EJB and Spring

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
        }
    }
}
MDB vs Spring @JmsListener — same model, different wiring

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>
persistent = false on @Schedule — what it means and why it matters

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

ScenarioUse 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

🎓 Junior level

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.

🔥 Senior level

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.