The Ghost of Deployment Descriptors Past

A few weeks ago I sat down to build a small clinic-appointment backend on WildFly, just to scratch an itch and relearn Jakarta EE properly. Nothing dramatic — a REST API, a booking flow, some business rules, and a small Vue 3 frontend on top so I'd have something to click through while I tested it. The kind of thing I'd normally reach for Spring Boot to knock out in an afternoon.

Except I didn't reach for Spring Boot. I reached for EJB.

And the second I typed @Stateless into an empty Java file, a very specific kind of dread crawled up my spine. Because the last time I wrote a line of EJB code with any seriousness, it was EJB 2.1, and it was not a good time.

If you never lived through that era, let me set the scene. To write what should have been a five-line "create a customer" method, you needed:

  • A home interface, so the container could tell you how to create or find your bean.
  • A remote (or local) interface, so callers could actually talk to it.
  • A bean implementation class that had to implement lifecycle callbacks it almost never used (ejbCreate, ejbActivate, ejbPassivate, ejbRemove — hello darkness, my old friend).
  • An XML deployment descriptor (ejb-jar.xml) wiring the interfaces to the implementation, declaring transaction attributes, and generally repeating everything you'd already written in Java, but in angle brackets.
  • If you were unlucky enough to use CMP entity beans for persistence, a second vendor-specific XML file mapping fields to columns, because the spec left enough gaps that every app server (WebLogic, WebSphere, JBoss) filled them in its own special way.

A trivial CRUD operation could sprawl across five files. Container-Managed Persistence entity beans were so notoriously painful that "just use Hibernate directly and skip CMP entirely" was common, accepted advice — inside a spec whose entire job was persistence. People didn't leave Java EE because they hated Java. They left because EJB 2.x had turned "write a business object" into a small bureaucracy.

So I did what a lot of people did: I left. Spring Framework showed up promising Plain Old Java Objects, dependency injection without a home interface in sight, and @Transactional on a method instead of an XML block. I spent most of the following decade there — Spring, then Spring Boot, autoconfiguration, embedded Tomcat, application.properties doing in one line what used to take a deployment descriptor. Somewhere in the last couple of years I also poked at Quarkus, drawn in by the "supersonic subatomic" pitch — sub-second live reload, GraalVM native images, the sense that the JVM startup tax was finally being paid down.

So — genuine question, going into this clinic project — was I about to relive 2007? Or had EJB actually caught up while I wasn't looking?

First Fork in the Road: EJB 3 or EJB 4?

Before writing a single bean, I had to answer a boring-sounding but surprisingly consequential question: which EJB version?

Here's the short version of EJB's history, as I pieced it back together:

  • EJB 1.x / 2.x (1998–2003ish): the bureaucracy described above. Home/remote interfaces, CMP entity beans, javax.ejb.*.
  • EJB 3.0 (2006): the reset button. Annotations replaced deployment descriptors for the common case. @Stateless, @Stateful, @MessageDriven beans became plain classes with an annotation on top. JPA was introduced as a completely new, much saner persistence model, explicitly designed to replace CMP.
  • EJB 3.1 (2009): the "you don't even need an interface" release — the no-interface view, plus @Singleton beans and an embeddable container for testing.
  • EJB 3.2 (2013): mostly pruning and cleanup — some of the crustier optional pieces (like the entity bean component contract, if you can believe it survived that long as "optional") were marked for removal, async session bean invocation got polished.
  • The Jakarta rename (2017–2019): Oracle handed Java EE to the Eclipse Foundation. Trademark reasons meant the javax.* namespace couldn't keep being evolved under the old name, so the specs were rebranded Jakarta EE, and eventually (Jakarta EE 9, 2020) every javax.* package was mechanically renamed to jakarta.*.
  • Jakarta EJB 4.0 (2022, part of Jakarta EE 10): the namespace flip finally lands inside EJB itself (jakarta.ejb.*), plus a genuine pruning pass — CMP/BMP entity beans and a few other legacy corners are gone for good. This is also the version this article — and the WildFly 35 sample project it's built on — actually uses.

Here's the twist that surprised me: EJB 4.0 isn't a big conceptual leap from 3.2. It's not "EJB 3 was good, EJB 4 reinvents it again." It's much closer to "EJB 3.2, tidied up, renamed, with the truly dead weight finally thrown out." If you already know @Stateless, @Stateful, and @MessageDriven, you already know EJB 4.0. The import changes from javax.ejb to jakarta.ejb, a couple of legacy APIs vanish, and that's essentially the delta.

Which made the actual decision easy: for anything new, there is no reason to target EJB 3.x today. Modern app servers — WildFly, Payara, Open Liberty, TomEE — are all building against the jakarta.* namespace now. Picking EJB 3.x for a new project in 2024+ would be deliberately choosing the legacy on-ramp. The only reason to stay on 3.x is that you're stuck maintaining something old that hasn't been ported yet — and that's a migration problem, not a new project decision.

So: EJB 4.0, on WildFly 35, targeting Jakarta EE 10. Onward.

Okay, But Is It Actually Nicer to Write Now?

Enormously, yes. A @Stateless session bean in 2024 looks like this:

@Stateless
public class CustomerManagementService {
    @PersistenceContext(unitName = "customerMgmtPU")
    private EntityManager em;

    @Transactional
    public Customer createCustomer(Long clinicId, String fullName, String username, String email, String phone) {
        // ...validate, persist, return
    }
}

Enter fullscreen mode Exit fullscreen mode

No home interface. No remote interface (unless you deliberately want a remote one, for actual distributed calls across JVMs — most people never do). No deployment descriptor for this. It's a plain class with two annotations, injected wherever it's needed via @Inject or @EJB, and the container hands you connection pooling, thread-safety (it manages a pool of instances so you never worry about concurrent calls to the same object), and transaction demarcation for free.

If you've been living in Spring Boot, this will feel extremely familiar, just with different spelling:

Spring Jakarta EE / EJB
@Service / @Component @Stateless (or a CDI @ApplicationScoped bean)
@Autowired @Inject (CDI) or @EJB (EJB-specific lookup)
@Transactional @Transactional (yes, actually the same annotation package in modern Jakarta EE — jakarta.transaction.Transactional)
@RestController + @GetMapping JAX-RS @Path + @GET resource class
Spring Data repository @PersistenceContext EntityManager + JPQL (more manual, but the same underlying JPA)

The mental model transfers almost one-to-one. Which, honestly, was the biggest surprise of this whole exercise — I expected to be relearning a foreign paradigm, and instead I was mostly relearning vocabulary.

Where it stops feeling like Spring Boot is packaging and deployment. This project ships as an EAR (Enterprise Archive) — a WAR for the REST layer plus several EJB JARs, one per business domain, all bundled together and deployed to a running WildFly instance. There's no embedded server, no single fat executable jar you java -jar and go. You build, you drop the EAR into WildFly's deployments/ folder (or let a deployment scanner pick it up), and the server hosts it. Compared to Spring Boot's "it's just a jar" simplicity, or Quarkus's aggressive dev-mode hot reload, this is a heavier loop — more ceremony in the Maven multi-module setup, a real datasource to configure on the app server itself (WildFly's CLI, not a one-line application.properties), and a slower edit-deploy-test cycle. That tradeoff is real, and it's the main thing that still feels dated. But it buys you something Spring Boot and Quarkus don't hand you automatically: a shared, centrally-managed runtime where multiple applications can be deployed side by side under one operations team's control — which is exactly the world a lot of regulated enterprises still live in, and exactly the use case EJB and application servers were built for.

Message-Driven Beans: Async Without the Config Headache

The part I'd genuinely half-forgotten existed: Message-Driven Beans. An MDB is a bean that doesn't get invoked by a caller at all — it gets invoked by the container whenever a message shows up on a queue or topic. No REST call, no method call, just: message arrives, container spins up (or reuses) a pooled instance, and calls onMessage.

In the clinic project, every time an appointment is booked, cancelled, or rescheduled, the business-logic bean publishes a small JSON event onto a JMS queue. A separate MDB picks it up asynchronously and turns it into a durable audit-log entry:

@JMSDestinationDefinition(
        name = "java:jboss/exported/jms/queue/AppointmentEvents",
        interfaceName = "jakarta.jms.Queue"
)
@MessageDriven(activationConfig = {
        @ActivationConfigProperty(propertyName = "destinationType", propertyValue = "jakarta.jms.Queue"),
        @ActivationConfigProperty(propertyName = "destination", propertyValue = "java:jboss/exported/jms/queue/AppointmentEvents")
})
public class AppointmentEventMDB implements MessageListener {
    @Override
    public void onMessage(Message message) {
        // decode the event, persist an audit entry
        // failures here don't block the original booking transaction
    }
}

Enter fullscreen mode Exit fullscreen mode

What struck me is how little of this I had to configure by hand. The queue itself is declared right there in an annotation (@JMSDestinationDefinition) — no separate resource-adapter XML, no manual JNDI binding step. The container manages the listener's thread pool, its transaction (each message delivery gets its own container-managed transaction by default), and its lifecycle. I didn't write a single line of "here's how many concurrent consumers to run" boilerplate.

The closest things I've used since are Spring's @JmsListener (functionally similar, a bit more configuration around the connection factory and listener container) and Quarkus's SmallRye Reactive Messaging (@Incoming/@Outgoing, which leans further into a reactive-streams style and is genuinely lovely for Kafka-shaped problems). MDBs are older, less flashy, and tied specifically to JMS rather than being transport-agnostic — but for "decouple this side-effect from the main transaction, and let the container deal with concurrency," it's still a remarkably small amount of code for what it does. The audit trail in this project can fail to write without ever rolling back the appointment booking that triggered it, purely because the two run in separate transactions, on separate threads, and neither one has to know the other exists.

Stateful Session Beans: Conversation, Held By the Server

The other bean type this project leans on, and the one I think is genuinely underrated: Stateful Session Beans.

A @Stateless bean is anonymous — any pooled instance can handle any call, because it holds no per-client state between invocations. A @Stateful bean is the opposite: the container hands you your own dedicated instance, and it remembers instance fields across multiple method calls, for as long as your conversation with it lasts.

The clinic project uses this for a multi-step booking wizard. A REST client starts a session, then makes a sequence of calls — pick a doctor, pick a schedule, pick a time, add notes, confirm — without ever having to resend everything it already told the server:

@Stateful
@ConcurrencyManagement(ConcurrencyManagementType.CONTAINER)
public class BookingSessionBean {
    private Long clinicId;
    private Long doctorId;
    private Long scheduleId;
    private LocalTime selectedTime;
    // ...

    @Lock(LockType.WRITE)
    public void selectDoctor(Long doctorId) {
        this.doctorId = doctorId;
        // reset anything that depended on the previous doctor
    }

    @Lock(LockType.WRITE)
    @Remove
    public Appointment confirmBooking(String createdBy) {
        // uses everything accumulated across prior calls
        // @Remove: the container destroys this bean instance afterward
    }
}

Enter fullscreen mode Exit fullscreen mode

Each REST call in the wizard resolves to the same bean instance (tracked via a session id, held in a small in-memory registry in this sample — in a real deployment, an app server can passivate an idle stateful bean to disk and reactivate it later, entirely transparently to the client). @Lock(LockType.WRITE) tells the container "serialize concurrent calls to this instance," which matters because a stateful bean is, definitionally, shared mutable state scoped to one conversation. And @Remove marks the method that ends the conversation — after confirmBooking runs, the container discards the instance; there's nothing left to passivate.

I don't think I'd reach for this every day — plenty of "multi-step form" problems are perfectly well solved by just... storing a draft row in the database and updating it, which is honestly the more portable, horizontally-scalable choice (this sample's in-memory registry, worth saying plainly, means an in-progress booking is lost if the server restarts — a real tradeoff, not a hidden one). But for a short-lived, single-server, "walk me through these five steps" conversation, having the container hold your state for you — with real concurrency control and a defined end-of-life hook — is a genuinely different tool than anything Spring or Quarkus hand you out of the box. Both of those ecosystems would push you toward an explicit session store (Redis, a database row, a signed client-side token) for the same problem; EJB just... has a bean type for it.

So, EJB 2 Survivor's Verdict

Coming back to this after a decade-plus of Spring and a Quarkus flirtation, here's where I landed:

EJB today is not EJB 2. The bureaucracy is gone. Annotations replaced almost all of the XML. JPA replaced CMP with something people actually choose to use voluntarily. A @Stateless service bean reads like a Spring @Service with a different import line. If you know modern Spring or Quarkus, you already know 90% of the concepts here — dependency injection, declarative transactions, annotation-driven REST endpoints — you're just relearning the spelling.

EJB 4.0 over EJB 3.x, no contest, for anything new. The gap between them is a namespace flip and some cleanup, not a redesign — so there's no "wait and see" argument for staying on 3.x the way there might be for a genuinely risky major version bump. If your target server supports Jakarta EE 10 (WildFly 27+, recent Payara, recent Open Liberty), there's no reason to write against the older, javax-based spec today.

What still costs you, relative to Spring Boot or Quarkus, is the operational loop, not the programming model — a real app server to configure, an EAR to build and deploy, a slower inner development cycle than Quarkus's live reload or Spring Boot's java -jar. That's the genuine, non-nostalgic tradeoff, and it's worth knowing about before you pick this stack for a new project versus a mature one already living on an app server.

And the two bean types I'd half-forgotten — MDBs and Stateful Session Beans — turned out to be the most interesting part of revisiting this. Not because they're better than a Kafka consumer or a Redis-backed session store, but because they solve the same problems with less code than I expected, when the container itself is a good enough fit for what you're building.

If you want to poke at the actual code behind any of this — the full clinic-appointment backend, the MDB, the stateful booking wizard, the multi-module Maven setup, and the Vue 3 frontend that talks to it — it's on GitHub: ykpraveen/ejb-wildfly-sample.

I came in expecting to relive 2007. I left with a renovated opinion of a technology I'd written off. Turns out it wasn't EJB that aged badly — it was EJB 2.