A Jakarta EE application on javax.* under JBoss EAP 7 needed to move to jakarta.* under EAP 8. The migration went the way these migrations are supposed to go: namespace rewrite, dependency BOM swap, descriptor uplift, framework version bumps. Every module compiled. The full test suite, hundreds of unit and integration tests, passed clean on every gate.
Then the EAR failed to deploy.
The gap between green and working
A compile gate proves the code is syntactically valid against the new API surface. A unit test gate proves business logic still behaves given the inputs it was written to expect. Neither gate touches the thing that actually decides whether an EJB module system boots: the application server’s own module graph, its own default library wiring, its own idea of which JARs it still ships.
That’s not a tooling gap you close by writing more tests. It’s structural: the application’s own classpath (what mvn test sees) and the server’s classpath (what the server sees at deploy time) are two different dependency graphs, and a major server version bump can change the second one without touching the first. Two findings from this migration make the point concretely.
Finding 1: the static module that stopped existing
One integration module in the application depended on a legacy vendor library that, in turn, needed the old org.apache.commons.lang (pre-3.x) package and Netty. Both dependencies were declared scope=provided in the POM: the reasoning, presumably made years earlier, was that the application server already ships these as static modules, so there’s no reason to bundle them.
That reasoning was correct for JBoss EAP 7. It stopped being correct for EAP 8.
WildFly (EAP 8’s open-source upstream) dropped the standalone org.apache.commons.lang module outright, and replaced the old monolithic io.netty module with a set of per-artifact modules (io.netty.netty-common, io.netty.netty-buffer, and so on: there’s no plain io.netty module anymore). None of this shows up anywhere in application code. It shows up in the server’s modules/ directory, which mvn test never looks at.
The failure mode was blunt: every deployed unit that touched the affected module failed to load, with org.jboss.modules.ModuleNotFoundException: org.apache.commons.lang cascading through the whole EAR. Nothing in the build had regressed. The server the code was going to run on had quietly changed underneath it.
The fix, once found, was mechanical: drop scope=provided, let the dependencies bundle into the module’s own JAR, and remove the now-dead module references from jboss-deployment-structure.xml. But finding it required actually trying to boot the application on a real Jakarta EE 10 server. No amount of additional unit testing would have surfaced it, because the defect wasn’t in the code under test. It was in an assumption about an environment the tests never touched.
Finding 2: the annotation that had always been wrong
The second regression is subtler, and more interesting, because it involves a bug that had been sitting in the codebase for years without ever causing a problem.
Several openapi-generator-produced model classes carried dual annotations: a @JsonProperty for JSON serialization and JAXB annotations (@XmlElement, @XmlAttribute) for XML. In at least one class, these disagreed:
@JsonProperty("accountRef")
@XmlElement(name = "customerId")
private String accountRef;
customerId belonged to an entirely different field on a sibling type. It was a copy-paste artifact from the generator, and it had been wrong since the day that class was generated.
It had also never mattered. Under RESTEasy 3.x, the Jackson provider read @JsonProperty and nothing else. The JAXB annotation was dead weight: syntactically present, semantically ignored.
RESTEasy 6.2 changed that. EAP 8’s dependency graph pulls in jackson-module-jakarta-xmlbind-annotations, which registers a JAXB-aware annotation introspector alongside Jackson’s own. Once that module is on the classpath, Jackson starts reading @XmlElement/@XmlAttribute names too, and for fields where the two annotations disagreed, the wrong one started winning. A SOAP request that had always deserialized fine under the old stack now failed with UnrecognizedPropertyException, because Jackson was matching against customerId, not accountRef.
Nothing about this required the migration to introduce a new bug. The bug was already there. What changed was that a downstream library upgrade turned on an annotation-processing path that had been silently disabled for years, and that path chose to expose an existing defect instead of continuing to hide it.
This is the harder class of regression to catch, because there’s no error to grep for in advance and no compile-time signal at all: two annotations, both syntactically valid, disagreeing about a property name. A code reviewer skimming a @JsonProperty/@XmlElement pair has no reason to suspect anything is wrong unless they’re specifically checking that the two agree, which is not a check most reviewers know to run until they’ve been burned by it once.
Why the test suite couldn’t have caught either one
Both failures share a root cause: they depend on facts that live outside the application’s own source tree. The first depended on which static modules a specific version of an application server ships. The second depended on which annotation introspector a specific version of a JAX-RS provider registers by default. Neither fact is expressed anywhere javac or a unit test runner can see it. A unit test with a mocked JAX-RS layer will happily deserialize test fixtures using whatever Jackson configuration is on the test classpath, which is not necessarily the configuration the real server will construct at runtime.
You can’t write a unit test for "the server still ships this module" or "the provider’s default introspection order didn’t change," because the assertion isn’t about your code. It’s about the runtime you haven’t started yet.
The fix that costs almost nothing
The eventual answer was unglamorous: stand up a real Jakarta EE 10 server, deploy the actual EAR, and watch what happens. The specific server doesn’t have to be the licensed product. WildFly is EAP 8’s direct open-source upstream, distributed as a plain Docker image, and running it costs nothing beyond a few minutes of setup: a container, a Postgres instance for the datasources, and enough JNDI configuration to satisfy the application’s resource lookups.
That one step surfaced both regressions immediately. The first appeared as a ModuleNotFoundException in the boot log before the deployment even reached the "starting persistence unit" phase. The second appeared the moment a real SOAP request round-tripped through the deployed endpoint instead of a mocked one. Neither needed a sophisticated test framework, just a server that hadn’t been told in advance what the "correct" answer was supposed to be.
If you don’t have access to the actual target product, an open-source upstream is close enough: WildFly for EAP, GlassFish or Payara for other Jakarta EE stacks. Either catches this entire class of bug for the cost of a Docker container, run once against the real EAR before the migration ships, not "eventually" in a staging environment. The alternative is finding out in the deployment window, which is a strictly worse place to learn that a static module you never thought about stopped existing.