Every enterprise has one: a Java EE application that has quietly earned its
keep for a decade. It builds into an EAR, it wants an application server you
download as a ZIP, it authenticates with a form and a j_security_check, and
it imports everything from javax.*. It works. Nobody wants to touch it. And
it is exactly the kind of thing that becomes impossible to deploy the day
someone asks for “just put it in a container on OpenShift.”
This post walks the whole road from the “before” to the “after” using a small but deliberately realistic demo app — APPreciate, an internal kudos-and-achievements board. The journey lives in three repositories:
- ozimakov-rh/jee-legacy-app — the starting point: Java 8, Java EE 8, form authentication, an EAR you deploy to WildFly 13.
- ozimakov-rh/modernize-javaee — the working repo where the transformation actually happened, commit by commit.
- ozimakov-rh/jee-container-app — the destination: Java 17, Jakarta EE 10, OpenID Connect, a self-contained WildFly image built with Galleon, deployed to OpenShift with Helm.
The trick to a modernization like this is that it is not a rewrite. The business logic — the EJBs, the CDI events, the JPA entities — barely changes. What changes is everything around the code: the namespace, the runtime, the authentication mechanism, and the packaging. Let’s follow it in that order.
The specimen: APPreciate
APPreciate is a peer-recognition app. Users send each other kudos; sending and receiving kudos unlocks achievements (“First Kudos”, “Five Kudos”, and so on). It is a multi-module Maven EAR with the classic Java EE layering:
jee-legacy-app
├── common — entities (Kudos, Achievement) + service interfaces + a CDI event
├── core-ejb — stateless EJBs: KudosService, AchievementService + JPA repos
├── user-webapp — the end-user servlets/JSPs (send & list kudos)
├── admin-webapp — the moderator servlets/JSPs (delete kudos/achievements)
├── integration — a JAX-RS module exposing achievements over REST
└── ear — packages all of the above into one deployable archive
The heart of it is a stateless session bean that creates a kudos and fires a CDI event; an observer in another EJB reacts to that event and re-evaluates the recipient’s achievements. This is textbook Java EE, and it is worth quoting because this code survives the entire modernization essentially untouched:
@Stateless(name = "kudosService")
@Remote(KudosService.class)
public class KudosServiceImpl implements KudosService {
@EJB(beanName = "jpa_kudos_repo")
private KudosRepository kudosRepository;
@Inject
private Event<KudosCreatedEvent> event;
@Override
public Kudos createKudos(String userFrom, String userTo, String description) {
Kudos kudos = Kudos.builder()
.id(Math.abs((new Random()).nextLong()))
.userFrom(userFrom)
.userTo(userTo)
.description(description)
.creationDate(new Date())
.build();
kudosRepository.add(kudos);
// publish event
event.fire(new KudosCreatedEvent(kudos));
return kudos;
}
// ...
}
The achievement bean simply @Observes that event:
// listen to new kudos
@Override
public void onEvent(@Observes KudosCreatedEvent kudosCreatedEvent) {
refreshAchievements(kudosCreatedEvent.getKudos().getUserTo());
refreshAchievements(kudosCreatedEvent.getKudos().getUserFrom());
}
Keep this code in mind. By the end of the post it will be running in a container on OpenShift, behind Keycloak, provisioned into a trimmed-down WildFly image — and the two methods above will read exactly the same. The whole point of the exercise is that the value of the application is in this logic, and modernization is about moving it to a place where it can keep running for another decade.
Where we start: Java 8, Java EE 8, and an application server you download
The legacy app targets Java 8 and pulls in the whole javaee-api umbrella:
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>javax</groupId>
<artifactId>javaee-api</artifactId>
<version>8.0.1</version>
</dependency>
</dependencies>
</dependencyManagement>
Every import is javax.* — javax.ejb.Stateless, javax.enterprise.event.Event,
javax.persistence.Entity, javax.ws.rs.Path. The README’s deployment story is
the giveaway that this is a product of its era:
Tested with Wildfly / JBoss EAP 13 (download)
Build by running
./mvnw package
In other words: download a 200 MB application server ZIP, unpack it, drop your
EAR into standalone/deployments/, and hope the server’s module versions match
what you compiled against. Authentication is servlet form-based auth, wired
entirely in web.xml:
<login-config>
<auth-method>FORM</auth-method>
<form-login-config>
<form-login-page>/login.jsp</form-login-page>
<form-error-page>/login.jsp</form-error-page>
</form-login-config>
</login-config>
<security-constraint>
<web-resource-collection>
<web-resource-name>root-context</web-resource-name>
<url-pattern>*.jsp</url-pattern>
<url-pattern>/servlet/*</url-pattern>
</web-resource-collection>
<auth-constraint>
<role-name>Operator</role-name>
</auth-constraint>
</security-constraint>
The login page posts to the servlet container’s magic j_security_check
endpoint with j_username / j_password:
<form method="POST" action="j_security_check">
<input type="text" name="j_username" id="login">
<input type="password" name="j_password" id="password">
<input type="submit" value="Login" class="button">
</form>
Identity here lives inside the application server. Users, passwords, and the
Operator role are configured in WildFly’s security realm. That is fine until
you have a second application, or a mobile client, or an auditor who wants SSO —
at which point “the app server owns the user directory” becomes the thing you
need to unwind.
So the modernization has four fronts, and we’ll take them one at a time:
Java 8 → Java 17
javax.* → jakarta.* (Jakarta EE 10)
FORM auth → OpenID Connect (Keycloak)
EAR on a ZIP'd → provisioned WildFly image → OpenShift + Helm
application server
Step 1 — Java 8 to Java 17
The cheapest win first. Bumping the compiler is a two-line change, and because the codebase avoids anything removed between 8 and 17, nothing else moves:
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
</properties>
The CI workflow follows along — this is the whole diff to the GitHub Actions
build, from java-version: '8' to '17':
- name: Set up JDK 17
uses: actions/setup-java@v3
with:
java-version: '17'
distribution: 'adopt'
- name: Build with Maven
run: ./mvnw --batch-mode package
Doing this first matters: Jakarta EE 10 and current WildFly both require Java 11+, so the JDK bump is the gate that unlocks every later step.
Step 2 — javax.* to jakarta.*: crossing the namespace divide
This is the change everyone dreads and it is, mechanically, the simplest and
most tedious of the lot. When Java EE moved to the Eclipse Foundation it became
Jakarta EE, and in Jakarta EE 9 the API packages were renamed wholesale from
javax.* to jakarta.*. There is no compatibility shim — a javax.ejb.Stateless
and a jakarta.ejb.Stateless are different types. You either run on the old
namespace or the new one.
In the demo, the whole dependency stanza is swapped from the standalone
javaee-api to WildFly’s own BOM, which pins a consistent Jakarta EE 10 stack:
<properties>
<wildfly.version>27.0.0.Final</wildfly.version>
<wildfly.galleon.datasources.version>3.0.0.Final</wildfly.galleon.datasources.version>
<wildfly.galleon.cloud.version>2.0.0.Final</wildfly.galleon.cloud.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.wildfly.bom</groupId>
<artifactId>wildfly-ee-with-tools</artifactId>
<version>${wildfly.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
Then every import flips. The Kudos entity is representative — the annotations
and the code are identical, only the package changes:
// before (jee-legacy-app)
import javax.persistence.Entity;
import javax.persistence.Id;
// after (jee-container-app)
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
Same story in the EJB layer — javax.ejb, javax.enterprise.event,
javax.inject all become jakarta.*:
// KudosServiceImpl, after
import jakarta.ejb.EJB;
import jakarta.ejb.Remote;
import jakarta.ejb.Stateless;
import jakarta.enterprise.event.Event;
import jakarta.inject.Inject;
And in the JAX-RS module — now Jakarta REST — the resource moves from
javax.ws.rs to jakarta.ws.rs:
// AchievementsIntegrationResource, after
import jakarta.ejb.EJB;
import jakarta.ejb.Stateless;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
@Stateless
@Path("/achievements")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public class AchievementsIntegrationResource {
@EJB
AchievementService achievementService;
@GET
public List<Achievement> getAchievements() {
return achievementService.listAllAchievements();
}
// ...
}
For a codebase this size the transformation is a find-and-replace you do by hand; for a real one, tools like the Eclipse Transformer or OpenRewrite rewrite the imports (and even the bytecode of third-party jars) for you. Either way, the important insight is that this step is pure renaming — the annotations, the injection points, the JPA mappings, the servlet contracts are all unchanged between Java EE 8 and Jakarta EE 10. The methods you saw at the top of the post still compile verbatim.
One small runtime detail rides along with the WildFly upgrade: the demo’s
persistence unit switches from the server’s built-in ExampleDS to the
datasource name provided by the Galleon datasources pack we’ll add in Step 4:
<!-- before --> <jta-data-source>java:jboss/datasources/ExampleDS</jta-data-source>
<!-- after --> <jta-data-source>java:jboss/datasources/H2DatabaseDS</jta-data-source>
Step 3 — from form login to OpenID Connect
Now the interesting part. We want to stop storing users in the application
server and start delegating authentication to an identity provider —
Keycloak — over OpenID Connect. WildFly ships an
Elytron OIDC client that makes this almost entirely declarative, which means
the KudosServlet and JSPs still just call request.getUserPrincipal();
they neither know nor care that the identity now comes from an ID token.
The web.xml shrinks. FORM becomes OIDC, and the login.jsp /
j_security_check machinery is deleted entirely — the redirect to Keycloak is
handled by the container:
<security-constraint>
<web-resource-collection>
<web-resource-name>root-context</web-resource-name>
<url-pattern>*.jsp</url-pattern>
<url-pattern>/servlet/*</url-pattern>
</web-resource-collection>
<auth-constraint>
<role-name>*</role-name>
</auth-constraint>
</security-constraint>
<login-config>
<auth-method>OIDC</auth-method>
</login-config>
The provider details live in an oidc.json next to the web.xml in each web
module (user-webapp, admin-webapp, and integration):
{
"provider-url": "https://sso.apps.zimakov.net/auth/realms/master",
"ssl-required": "external",
"client-id": "jee-app",
"public-client": true,
"confidential-port": 0,
"principal-attribute": "preferred_username"
}
principal-attribute: preferred_username is what makes the rest of the app keep
working unchanged: after login, request.getUserPrincipal().getName() returns
the Keycloak username, exactly as the JSPs already expect:
<a href="logout.jsp" class="r-button">Logout (<%=request.getUserPrincipal().getName()%>)</a>
...
List<Kudos> kudosList = kudosService.listKudos(request.getUserPrincipal().getName());
The full login flow is now the standard OIDC authorization-code dance, with WildFly acting as the relying party:
Browser WildFly (Elytron OIDC) Keycloak
│ GET /user-webapp │ │
│─────────────────────────▶│ no session │
│ 302 → Keycloak /auth │ │
│◀─────────────────────────│ │
│ GET /auth?client_id=jee-app&response_type=code ───▶│
│ │ login form / SSO │
│◀───────────────────────────────────────────────────│
│ redirect_uri?code=... ─▶│ exchange code for tokens│
│ │────────────────────────▶│
│ │◀── id_token + access ────│
│ 302 → /user-webapp │ principal = preferred_ │
│◀─────────────────────────│ username │
Identity has left the building — it now lives in Keycloak, where it can be shared across every app, backed by an enterprise user directory, and audited in one place. The application server is out of the user-management business.
Step 4 — packaging: from “deploy an EAR onto a downloaded server” to a self-contained image
Here is where the deployment model actually changes. In the legacy world you have two artifacts that must agree: your EAR, and a WildFly 13 install someone provisioned by hand. In the container world you want one immutable artifact that carries a right-sized server and the app together.
WildFly’s Galleon provisioning system,
driven by the wildfly-maven-plugin, does exactly this. Instead of installing
the full server, you declare the layers your app needs and Galleon assembles
a trimmed server containing only those. In the demo this is a Maven profile on
the ear module:
<profile>
<id>wildfly-image</id>
<build>
<plugins>
<plugin>
<groupId>org.wildfly.plugins</groupId>
<artifactId>wildfly-maven-plugin</artifactId>
<configuration>
<feature-packs>
<feature-pack>
<location>org.wildfly:wildfly-galleon-pack:${wildfly.version}</location>
</feature-pack>
<feature-pack>
<location>org.wildfly:wildfly-datasources-galleon-pack:${wildfly.galleon.datasources.version}</location>
</feature-pack>
<feature-pack>
<location>org.wildfly.cloud:wildfly-cloud-galleon-pack:${wildfly.galleon.cloud.version}</location>
</feature-pack>
</feature-packs>
<image>
<jdk-version>17</jdk-version>
<docker-binary>podman</docker-binary>
<name>${build.finalName}</name>
<tag>latest</tag>
</image>
<layers>
<layer>cloud-server</layer>
<layer>ejb</layer>
<layer>remoting</layer>
<layer>jsf</layer>
<layer>jpa</layer>
<layer>h2database-driver</layer>
<layer>h2database-datasource</layer>
<layer>h2database-default-datasource</layer>
<layer>elytron-oidc-client</layer>
</layers>
<galleon-options>
<jboss-fork-embedded>true</jboss-fork-embedded>
</galleon-options>
<runtime-name>ROOT.ear</runtime-name>
</configuration>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>image</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</profile>
Read that <layers> block as a bill of materials for the runtime. ejb,
jpa, and jsf are the capabilities the app uses; h2database-* provisions
and binds the H2DatabaseDS datasource the persistence unit now references;
elytron-oidc-client is the OIDC support from Step 3; and cloud-server is the
container-tuned base profile. Everything WildFly ships that this app doesn’t
use never makes it into the image. The README’s deployment instruction collapses
from “download a ZIP and drop in an EAR” to a single command:
./mvnw -Pwildfly-build package
The output is a container image — built here with podman — with the EAR
provisioned as ROOT.ear inside a purpose-built WildFly. There is no external
server to match versions with anymore; the artifact is the server plus the
app. That is the real payoff of the whole exercise: an immutable, self-contained
unit you can run anywhere a container runs.
Step 5 — onto OpenShift with Helm
The last mile is deployment. The repo carries a second profile, openshift,
which runs the same Galleon provisioning but produces a server directory (the
package goal instead of image) and copies it up to where OpenShift’s
source-to-image / Helm build expects it:
<profile>
<id>openshift</id>
<build>
<plugins>
<plugin>
<groupId>org.wildfly.plugins</groupId>
<artifactId>wildfly-maven-plugin</artifactId>
<configuration>
<!-- same feature-packs and layers as wildfly-build -->
<runtime-name>ROOT.ear</runtime-name>
</configuration>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>package</goal>
</goals>
</execution>
</executions>
</plugin>
<!-- maven-resources-plugin copies target/ up a level for the build -->
</plugins>
</build>
</profile>
Deployment itself is described by a tiny helm.yaml that points the WildFly
Helm chart at the Git repo and asks for a route:
build:
uri: https://github.com/ozimakov-rh/jee-container-app
deploy:
env:
route:
host: jee.apps.zimakov.net
That is the entire deployment descriptor. The
WildFly Helm chart clones the repo,
runs the Maven build with the openshift profile to provision the trimmed
server, layers the EAR on top, builds the image inside the cluster, and exposes
it at jee.apps.zimakov.net. The app that started life as an EAR you hand-copied
onto a downloaded server is now a declaratively-built, OIDC-secured workload on
Kubernetes.
Before and after
The same application, the same EJBs and JPA entities and CDI events — landed in a completely different operational world:
Legacy (jee-legacy-app) | Modernized (jee-container-app) | |
|---|---|---|
| Java | 8 | 17 |
| Platform | Java EE 8 (javax.*) | Jakarta EE 10 (jakarta.*) |
| Server | WildFly 13, downloaded ZIP | WildFly 27, provisioned by Galleon |
| Auth | Servlet FORM + j_security_check | OpenID Connect via Keycloak |
| Datasource | ExampleDS (server built-in) | H2DatabaseDS (Galleon datasources pack) |
| Artifact | EAR dropped into deployments/ | Self-contained container image |
| Deploy | Manual copy onto a server | OpenShift + Helm (helm.yaml) |
| Build (CI) | JDK 8 | JDK 17 |
What the story is really about
If you scroll back up to the KudosServiceImpl and the @Observes method from
the very beginning, you will notice they never appear in a “changed” diff. The
kudos still get created, the event still fires, the achievement observer still
reacts. That is not an accident — it is the entire thesis of modernization.
The work was never about the business logic. It was about:
- Lifting the JDK so newer platforms are even an option (Step 1).
- Renaming the namespace to get onto a supported, maintained platform — mechanical, tool-assisted, and unglamorous (Step 2).
- Externalizing identity so the app server stops owning your users (Step 3).
- Reshaping the artifact from “code that needs a server” into “a server and the code, together, immutable” (Step 4).
- Declaring the deployment so a cluster can build and run it from a few lines of YAML (Step 5).
None of those steps required rewriting APPreciate. Each was a well-scoped, independently-shippable move — which is exactly why you can do this to a real application without a two-year rewrite project. Walk the four fronts one at a time, keep the tests green between each, and the decade-old EAR ends up running on Kubernetes with its logic intact.
The three repos are public if you want to diff them yourself: jee-legacy-app (the before), modernize-javaee (the commit history of the change), and jee-container-app (the after).