Migrating Off EJB and Struts

ModernLift · ·14 min read
Part 4 of 9

EJB and Struts are the two parts of a legacy Java stack that resist a simple framework upgrade. EJB binds business logic to container-managed transactions, pooling, and lifecycle, so leaving it means replacing those guarantees with explicit, framework-level equivalents rather than renaming imports. Struts is both a dead end and a documented security liability — an unpatched Apache Struts flaw caused the 2017 Equifax breach affecting roughly 147 million people (Apache Software Foundation, 2017). Both are best lifted out incrementally: extract the business logic behind a facade, rebuild it on a modern framework, and prove each slice equivalent before cutover.

Part 3 made the case that most of a Java EE application crosses the javax.* to jakarta.* line as mechanical work. This part takes the two pieces that don’t: EJB, which binds business logic to guarantees the application server provided invisibly, and Struts, which is both a dead-end web framework and a documented security liability. These are the seams where the framework migration stops being a rename and becomes real engineering — and where doing it incrementally matters most.

EJB: you are not migrating code, you are replacing guarantees

Enterprise JavaBeans were the heart of the classic Java EE model. Session beans held business logic; entity beans (in older applications) held persistence; the application server wrapped all of it in services the developer never had to write: container-managed transactions, security context propagation, instance pooling, and a managed lifecycle. A method on a session bean was transactional because the container made it so. A failure rolled back because the container caught it. Concurrency was handled because the container pooled instances.

That invisibility is exactly what makes EJB hard to leave. The business logic inside a bean often looks portable — until you notice everything it assumes is there. Lift the logic into a plain modern service and the transaction boundary is gone, the rollback semantics are gone, the pooling is gone. The code compiles and then behaves differently under load or on failure.

So migrating off EJB is the work of re-establishing each guarantee explicitly on the target framework:

  • Container-managed transactions become declarative transaction management on the modern framework (for example, Spring’s @Transactional or Jakarta Transactions).
  • Bean injection and lifecycle become the framework’s dependency injection.
  • Connection and instance pooling move to the modern runtime’s pool configuration.
  • Security context propagation is re-wired to the modern security model.

None of this is exotic, but all of it is deliberate — you are reconstructing behavior the container used to supply for free. That is why EJB extraction belongs to a careful, validated slice rather than a confident bulk rewrite: the failures it produces are behavioral (a transaction that no longer rolls back the way it did), and behavioral failures are precisely what parity validation exists to catch before they reach production.

Struts: a dead end with a track record

If EJB is hard, Struts is urgent. It is worth separating the two kinds of risk it carries.

It is a dead end. Apache Struts 1 reached end of life in 2013 (Apache Software Foundation) and receives no fixes whatsoever. Struts 2 is still maintained, but the model — action-based MVC tightly coupled to the old Java EE web tier — is a generation behind modern Java web development, and the ecosystem has moved on.

It is a documented liability. Struts has a serious vulnerability history, and one entry defines it: CVE-2017-5638, a critical remote-code-execution flaw in Struts 2 rated 10.0, patched by Apache in March 2017. Left unpatched, it was the vector for the 2017 Equifax breach, which exposed data on roughly 147 million people (NVD; Apache Software Foundation, 2017). The lesson the industry took from it is blunt: a Struts web tier is not a quietly aging component — it is a live attack surface, and an unpatched one in a regulated system is an audit finding and a breach waiting to be written, exactly the dynamic the end-of-life software risks part describes.

That combination — dead-end framework and proven attack surface — is why replacing a Struts web tier is often one of the highest-priority slices in a Java modernization, ahead of work that is merely overdue.

What each construct becomes

For structure, the mapping is fairly regular. Almost every EJB and Struts building block has a clean modern equivalent, usually on Spring Boot or Jakarta EE. The trap is that the structure moving cleanly does not mean the behavior did. So it helps to read these tables in three columns: the old construct, what it becomes, and the thing the slice actually has to prove before it is done.

EJB and Java EE constructs

Legacy constructModern equivalentWhat the slice has to prove
Stateless session beanSpring @Service or a CDI beanSame outputs and side effects, same transaction boundary around the method
Stateful session beanExplicit state (HTTP session, a cache, or a redesign to stateless)Conversation state survives across calls and never leaks between users
Entity bean (CMP or BMP)JPA entity plus a repository (Spring Data or Jakarta Persistence)Same queries and results, same lazy vs eager loading, same locking
Message-driven beanA messaging listener such as @JmsListenerSame delivery semantics, redelivery, and poison-message handling
Container-managed transactionDeclarative @Transactional or Jakarta TransactionsRollback on the same exceptions, same propagation across calls
Bean-managed transactionA programmatic transaction templateCommit and rollback happen at the same points
@EJB injection or JNDI lookupFramework dependency injectionSame wiring, resolved at startup instead of by runtime lookup
@RolesAllowed container securityFramework method securityThe same authorization decision for the same caller
ejb-jar.xml descriptorAnnotations plus typed configurationSame roles, interceptors, and resource wiring

Struts constructs

Legacy constructModern equivalentWhat the slice has to prove
Action classA @Controller or @RestController handlerSame request handled, same response body and status
ActionFormA request DTO, @ModelAttribute, or @RequestBodySame binding, same defaults, same type coercion
struts-config.xml mappingsAnnotated routes or router configurationThe same URL-to-handler map
ActionForwardA view name or a ResponseEntitySame destination, with forward vs redirect preserved
validation.xml or the ValidatorBean Validation (@Valid and constraint annotations)The same rules and the same error messages
Tiles or JSP layoutServer templating such as Thymeleaf, or a client app over RESTThe same rendered page or the same API contract
Struts 2 interceptorsFramework filters or interceptorsThe same cross-cutting behavior, in the same order
Struts tag librariesTemplate equivalents or JSON responsesThe same output the page produced before

The third column is the whole point. Every row is a place where the structure moves cleanly and the behavior might not, which is why each slice ends at prove functional equivalence, not at “it compiles.”

Lifting both out, a slice at a time

The instinct with a tangled EJB-and-Struts application is to rewrite the whole thing. Resist it: a from-scratch rewrite of a system whose behavior is undocumented is how a known-working application becomes a year-long bet, and it is the failure mode this series exists to avoid.

The incremental path applies the same loop the rest of the series uses, with a wrinkle for each seam:

  • Put a facade in front of the application. It routes each request to either the legacy Struts/EJB path or a modernized one.
  • Replace the web tier slice by slice. Take one set of Struts actions — a screen flow, a controller, a group of endpoints — rebuild it on the modern framework, and prove it returns the same responses and produces the same side effects as the Struts original before cutting its traffic over.
  • Extract EJB business logic with its guarantees intact. Move a session bean’s logic into a modern service, re-establishing the transaction, security, and pooling behavior explicitly, then prove functional equivalence — especially the failure and rollback paths, which are where the container’s invisible services used to do their work.
  • Bridge temporarily where you must. While old and new coexist, an anti-corruption layer keeps the legacy EJB model from leaking into the new services. The bridge is throwaway code, built to be deleted once the slice is fully across.

The reversal worth keeping: the parts of a Java EE app that are hardest to migrate are the parts where behavior was invisible — and invisible behavior is exactly what you must make explicit and prove, not assume.

What order to migrate in

Order is driven by risk and coupling, not by what looks easy. A defensible sequence for a tangled EJB-and-Struts estate:

  1. Security-exposed Struts endpoints first. Internet-facing actions, anything handling file uploads or unauthenticated input, the CVE-2017-5638 class of surface. These earn priority on risk alone, ahead of work that is merely overdue.
  2. Read-mostly Struts actions next. Low-risk screens and report views. They build the facade, the routing, and the parity harness on slices that cannot corrupt data, so the team learns the loop cheaply before it matters.
  3. Self-contained stateless session beans. A calculation or a single-step service with a clean boundary migrates almost mechanically once the dependency-injection and transaction wiring is in place.
  4. Transactional, multi-step session beans. The careful work. Hold these until the parity harness can exercise the failure and rollback paths, because that is where the container’s invisible guarantees did their real job.
  5. Entity beans and the shared data model. Persistence usually touches many slices at once, so it is coordinated across them rather than flipped in one, often behind the anti-corruption layer while both models coexist.
  6. Stateful and message-driven beans last. Conversational state and asynchronous delivery are the hardest to prove equivalent, so they move once the surrounding services are already modern and the harness is trusted.

The rule underneath the list: lead with the highest risk you can isolate, and defer the highest coupling until you have the tools to prove it. Struts tends to sort by exposure, EJB by how much invisible behavior a bean carried.

Where the real effort concentrates

Some EJB and Struts code is genuinely simple — a stateless session bean doing a self-contained calculation, a handful of read-only Struts actions — and migrates almost mechanically. The hard, slow, must-be-validated work concentrates where the container’s guarantees did real work (transactional, multi-step, failure-sensitive logic) and where Struts actions carry security exposure. Spend the careful effort there and treat the simple cases as the routine they are. And if a Struts tier is the live risk in your estate, prioritize it on security grounds even if the rest of the framework move can wait — the breach history makes that a defensible call, not an over-reaction.

Where this leads

EJB and Struts are bound to something larger than themselves: the heavyweight application server that hosted them. WebLogic, WebSphere, and JBoss supplied the EJB container, the JNDI resources, and the proprietary configuration the whole application leans on — and shedding that weight, while decomposing the monolith into domain services, is the deepest move in a Java modernization. Part 5, Java Monolith to Microservices, covers getting off the app server and cutting along domain boundaries, only where a real pressure justifies it.

Frequently asked questions

Why is migrating off EJB harder than the rest of a Java EE app?
Because EJB is not just an API — it is a set of guarantees the application server provided invisibly: container-managed transactions, security context, instance pooling, and bean lifecycle. Code written for EJB assumes those services exist around it. Lifting the business logic out means re-establishing each guarantee explicitly on the target framework — declarative transactions, dependency injection, connection pooling — so the behavior survives. That is design work, not a find-and-replace, which is why EJB is one of the seams the framework move can't treat mechanically.
Is Apache Struts safe to keep running?
Struts 1 reached end of life in 2013 (Apache Software Foundation) and gets no fixes at all. Struts 2 is still maintained but has a serious vulnerability history — a critical remote-code-execution flaw, CVE-2017-5638, rated 10.0, went unpatched at Equifax and caused the 2017 breach of roughly 147 million people's data (NVD; Apache Software Foundation, 2017). A Struts web tier is both a dead-end framework and a live attack surface, which makes replacing it one of the more urgent slices in a Java modernization.
How do you replace Struts without rewriting the whole UI at once?
Route requests through a facade and replace the web tier one set of actions or endpoints at a time. Each replaced slice — a screen flow, a controller, a group of endpoints — is rebuilt on the modern framework, proven to produce the same responses and side effects as the Struts original, and cut over gradually with rollback ready. The legacy Struts actions are retired as they are replaced, so the UI modernizes incrementally rather than in a single high-risk rewrite.
What should you migrate first, EJB or Struts?
Order by risk and coupling, not by which looks easier. Security-exposed Struts endpoints go first, meaning internet-facing actions, file uploads, and unauthenticated input, because they are a live attack surface and not just a dead-end framework. Read-mostly Struts screens come next, so the team builds the facade and parity harness on slices that cannot corrupt data. Then come stateless, self-contained EJB session beans, which move almost mechanically once dependency injection and transaction wiring are in place. Only after that do the transactional, multi-step session beans move, because the container's invisible rollback behavior has to be proven. Stateful and message-driven beans, plus the shared entity-bean data model, come last, because they are the hardest to prove equivalent.
All 9 parts of Java EE & Legacy Java Modernization →