#Jakarta-EE-Schwerpunkt: Enterprise-Beispielprojekt

Dieses Dokument ergänzt den Spring/Jakarta-Lernpfad um ein Jakarta-zentriertes Beispielprojekt. Der Fokus liegt nicht auf Spring Boot, sondern auf den Standards der Jakarta EE Platform: JAX-RS, CDI, JPA, JTA, Bean Validation, Jakarta Security, Jakarta Messaging und WAR-Deployment auf einem Application Server.

#Ziel des Beispiels

Das Beispielprojekt heißt Jakarta Retail Banking Platform. Es simuliert eine kleine Banking-Plattform mit mehreren deploybaren WAR-Modulen.

Lernziele:

  • Jakarta EE als serverbasierte Enterprise-Plattform verstehen
  • WAR-Module mit Maven strukturieren
  • REST APIs mit JAX-RS bauen
  • Fachlogik mit CDI Application Services kapseln
  • Datenzugriff über JPA und EntityManager umsetzen
  • Transaktionen mit JTA steuern
  • Validierung mit Bean Validation nutzen
  • Rollen mit Jakarta Security und @RolesAllowed absichern
  • asynchrone Integration mit Jakarta Messaging und MDB erklären
  • Application-Server-Ressourcen wie Datasource und JMS Queue bewusst modellieren

#1. Architekturüberblick

#1.1 Laufzeitmodell

API Client / Browser
        |
        +--> customer-app.war       JAX-RS + CDI + JPA
        |
        +--> account-app.war        JAX-RS + CDI + JPA
        |
        +--> transfer-app.war       JAX-RS + CDI + JTA + JMS
                                      |
                                      v
                              PaymentEvents Queue
                                      |
                                      v
                            notification-app.war
                            MDB + JPA + REST Admin API

Anders als bei Spring Boot bringt hier nicht jede Anwendung ihren eingebetteten Server mit. Die WAR-Dateien werden auf einen Jakarta-EE-kompatiblen Application Server deployed. Der Server stellt HTTP Runtime, CDI Container, JPA Provider, JTA Transaction Manager, Security-Integration, Datasources und Messaging-Infrastruktur bereit.

#1.2 Module

Modul Typ Verantwortung
domain-kernel JAR Value Objects wie Money, Iban, DomainEvent
api-contracts JAR DTOs und Event-Verträge
customer-app WAR Kundenverwaltung
account-app WAR Konten und Salden
transfer-app WAR Überweisungsprozess, Transaktion, JMS Event
notification-app WAR Message-Driven Bean und Notification Log

#2. Projektstruktur

jakarta-retail-banking-platform/
├── pom.xml
├── domain-kernel/
├── api-contracts/
├── customer-app/
├── account-app/
├── transfer-app/
├── notification-app/
├── docker-compose.yml
├── deployments/wildfly/configure-wildfly.cli
├── docs/
└── scripts/smoke-test.sh

#2.1 Warum mehrere WARs?

Mehrere WARs zeigen ein Enterprise-Szenario, in dem fachliche Kontexte getrennt deployt werden. Das ist nicht zwingend eine Microservice-Architektur, aber ein guter Übergang zwischen klassischem Application-Server-Deployment und serviceorientierter Architektur.

Vorteile:

  • getrennte Context Roots
  • klare fachliche Grenzen
  • gemeinsame Serverressourcen
  • Thin-WAR-Deployment
  • weniger Framework-spezifische Kopplung

Nachteile:

  • mehr Serverkonfiguration
  • Deployment-Reihenfolge und JNDI-Namen müssen stimmen
  • lokale Entwicklung ist aufwendiger als bei Spring Boot

#3. Maven Parent-POM

Die Root-POM verwaltet Versionen, Module und Plugin-Standards. Die wichtigste Dependency ist jakarta.jakartaee-api mit Scope provided. Das bedeutet: Die APIs sind beim Kompilieren sichtbar, aber die Implementierung kommt zur Laufzeit vom Application Server.

<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.acme.banking</groupId>
    <artifactId>jakarta-retail-banking-platform</artifactId>
    <version>${revision}</version>
    <packaging>pom</packaging>

    <name>Jakarta Retail Banking Platform</name>
    <description>Jakarta-EE-Schwerpunktprojekt mit mehreren WAR-Modulen.</description>

    <modules>
        <module>domain-kernel</module>
        <module>api-contracts</module>
        <module>customer-app</module>
        <module>account-app</module>
        <module>transfer-app</module>
        <module>notification-app</module>
    </modules>

    <properties>
        <revision>1.0.0-SNAPSHOT</revision>
        <java.version>17</java.version>
        <jakartaee.version>10.0.0</jakartaee.version>
        <junit.version>5.11.4</junit.version>
        <maven.compiler.release>${java.version}</maven.compiler.release>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>jakarta.platform</groupId>
                <artifactId>jakarta.jakartaee-api</artifactId>
                <version>${jakartaee.version}</version>
                <scope>provided</scope>
            </dependency>
            <dependency>
                <groupId>org.junit.jupiter</groupId>
                <artifactId>junit-jupiter</artifactId>
                <version>${junit.version}</version>
                <scope>test</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <build>
        <pluginManagement>
            <plugins>
                <plugin>
                    <groupId>org.apache.maven.plugins</groupId>
                    <artifactId>maven-compiler-plugin</artifactId>
                    <version>3.14.0</version>
                    <configuration>
                        <release>${java.version}</release>
                        <parameters>true</parameters>
                    </configuration>
                </plugin>
                <plugin>
                    <groupId>org.apache.maven.plugins</groupId>
                    <artifactId>maven-war-plugin</artifactId>
                    <version>3.4.0</version>
                    <configuration>
                        <failOnMissingWebXml>false</failOnMissingWebXml>
                    </configuration>
                </plugin>
                <plugin>
                    <groupId>org.apache.maven.plugins</groupId>
                    <artifactId>maven-surefire-plugin</artifactId>
                    <version>3.5.3</version>
                    <configuration>
                        <useModulePath>false</useModulePath>
                    </configuration>
                </plugin>
                <plugin>
                    <groupId>org.apache.maven.plugins</groupId>
                    <artifactId>maven-enforcer-plugin</artifactId>
                    <version>3.5.0</version>
                    <executions>
                        <execution>
                            <id>enforce-java-and-maven</id>
                            <goals>
                                <goal>enforce</goal>
                            </goals>
                            <configuration>
                                <rules>
                                    <requireJavaVersion>
                                        <version>[17,)</version>
                                    </requireJavaVersion>
                                    <requireMavenVersion>
                                        <version>[3.9,)</version>
                                    </requireMavenVersion>
                                </rules>
                            </configuration>
                        </execution>
                    </executions>
                </plugin>
            </plugins>
        </pluginManagement>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-enforcer-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

#3.1 Warum provided?

Bei Jakarta EE gehören viele Implementierungen zum Server. Wenn ein WAR seine eigenen Implementierungen für JPA, CDI, JAX-RS oder JMS mitbringt, kann es zu Classloading-Konflikten kommen. Deshalb werden die Jakarta APIs typischerweise mit provided eingebunden.

#3.2 Warum Jakarta EE 10 im Beispiel?

Das Beispiel nutzt Jakarta EE 10, weil dafür produktive Application-Server-Unterstützung breit verfügbar ist. Jakarta EE 11 ist wichtig für den Lernpfad, aber für ein möglichst nachvollziehbares Serverbeispiel ist Jakarta EE 10 derzeit pragmatischer.


#4. JAX-RS: REST Boundary

JAX-RS Resources bilden die äußere Grenze der Anwendung. Sie nehmen HTTP Requests entgegen, validieren DTOs und delegieren an CDI Services.

#4.1 TransferResource

package com.acme.banking.transfer.boundary;

import com.acme.banking.contracts.CreateTransferRequest;
import com.acme.banking.contracts.TransferResponse;
import com.acme.banking.transfer.control.TransferService;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Inject;
import jakarta.validation.Valid;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.net.URI;
import java.util.List;

@Path("/transfers")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public class TransferResource {
    @Inject
    TransferService service;

    @GET
    @RolesAllowed({"USER", "ADMIN"})
    public List<TransferResponse> all() {
        return service.all();
    }

    @POST
    @RolesAllowed("USER")
    public Response request(@Valid CreateTransferRequest request) {
        TransferResponse response = service.requestTransfer(request);
        return Response.created(URI.create("/transfers/" + response.id())).entity(response).build();
    }
}

Wichtige Punkte:

  • @Path("/transfers") definiert den REST-Pfad.
  • @Consumes und @Produces definieren JSON als Ein- und Ausgabeformat.
  • @Valid aktiviert Bean Validation für das Request-DTO.
  • @RolesAllowed("USER") schützt den Endpunkt deklarativ.
  • Die Resource enthält keine Geschäftslogik, sondern delegiert an TransferService.

#5. CDI und JTA: Application Service

CDI verwaltet die Lebenszyklen der Services. JTA sorgt dafür, dass der Use Case transaktional ausgeführt wird.

#5.1 TransferService

package com.acme.banking.transfer.control;

import com.acme.banking.contracts.CreateTransferRequest;
import com.acme.banking.contracts.PaymentRequestedEvent;
import com.acme.banking.contracts.TransferResponse;
import com.acme.banking.transfer.entity.TransferEntity;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import java.time.Instant;
import java.util.List;
import java.util.UUID;

@ApplicationScoped
public class TransferService {
    @Inject
    TransferRepository repository;

    @Inject
    PaymentEventPublisher publisher;

    public List<TransferResponse> all() {
        return repository.findAll().stream().map(this::toResponse).toList();
    }

    @Transactional
    public TransferResponse requestTransfer(CreateTransferRequest request) {
        TransferEntity transfer = TransferEntity.requested(
                request.sourceAccountId(),
                request.targetAccountId(),
                request.amount(),
                request.currency(),
                request.reference()
        );
        repository.save(transfer);
        transfer.markPaymentRequested();

        publisher.publish(new PaymentRequestedEvent(
                UUID.randomUUID(),
                Instant.now(),
                transfer.getId(),
                transfer.getSourceAccountId(),
                transfer.getTargetAccountId(),
                transfer.getAmount(),
                transfer.getCurrency(),
                transfer.getReference()
        ));

        return toResponse(transfer);
    }

    private TransferResponse toResponse(TransferEntity t) {
        return new TransferResponse(
                t.getId(), t.getSourceAccountId(), t.getTargetAccountId(),
                t.getAmount(), t.getCurrency(), t.getReference(), t.getStatus(), t.getCreatedAt()
        );
    }
}

#5.2 Erklärung

Der Service macht mehrere Dinge in einer Transaktion:

  1. Er erstellt eine neue Überweisung.
  2. Er persistiert die Überweisung über JPA.
  3. Er ändert den Status.
  4. Er veröffentlicht ein JMS Event.
  5. Er gibt eine REST Response zurück.

@Transactional ist hier der zentrale Enterprise-Mechanismus. Wenn beim Persistieren oder Senden des Events ein Fehler auftritt, kann die Transaktion zurückgerollt werden.


#6. JPA: Persistence Context

JPA wird im Application Server integriert betrieben. Der EntityManager wird über @PersistenceContext injiziert.

#6.1 persistence.xml

<persistence xmlns="https://jakarta.ee/xml/ns/persistence"
             xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
             xsi:schemaLocation="https://jakarta.ee/xml/ns/persistence https://jakarta.ee/xml/ns/persistence/persistence_3_1.xsd"
             version="3.1">
    <persistence-unit name="bankingPU" transaction-type="JTA">
        <jta-data-source>java:jboss/datasources/BankingDS</jta-data-source>
        <properties>
            <property name="jakarta.persistence.schema-generation.database.action" value="none" />
            <property name="hibernate.default_schema" value="banking" />
        </properties>
    </persistence-unit>
</persistence>

#6.2 JNDI Datasource

Die Anwendung referenziert java:jboss/datasources/BankingDS. Diese Datasource wird nicht im WAR erstellt, sondern im Application Server. Genau das ist ein zentraler Jakarta-EE-Unterschied gegenüber vielen Spring-Boot-Projekten.


#7. Jakarta Messaging: Queue und MDB

Das Transfer-Modul sendet ein PaymentRequestedEvent an eine JMS Queue. Das Notification-Modul verarbeitet dieses Event asynchron mit einer Message-Driven Bean.

#7.1 PaymentEventConsumer

package com.acme.banking.notification.messaging;

import com.acme.banking.notification.control.NotificationLogRepository;
import com.acme.banking.notification.entity.NotificationLogEntity;
import jakarta.ejb.ActivationConfigProperty;
import jakarta.ejb.MessageDriven;
import jakarta.inject.Inject;
import jakarta.jms.Message;
import jakarta.jms.MessageListener;
import jakarta.jms.TextMessage;
import jakarta.transaction.Transactional;

@MessageDriven(activationConfig = {
        @ActivationConfigProperty(propertyName = "destinationLookup", propertyValue = "java:/jms/queue/PaymentEvents"),
        @ActivationConfigProperty(propertyName = "destinationType", propertyValue = "jakarta.jms.Queue")
})
public class PaymentEventConsumer implements MessageListener {
    @Inject
    NotificationLogRepository repository;

    @Override
    @Transactional
    public void onMessage(Message message) {
        try {
            String eventType = message.getStringProperty("eventType");
            String payload = ((TextMessage) message).getText();
            repository.save(NotificationLogEntity.received(eventType, payload));
        } catch (Exception ex) {
            throw new IllegalStateException("could not process payment event", ex);
        }
    }
}

#7.2 Warum MDB?

Eine Message-Driven Bean ist ein serververwalteter Consumer. Der Application Server kümmert sich um:

  • Listener-Lifecycle
  • Threading
  • Verbindung zum Messaging Provider
  • Transaktionsintegration
  • Redelivery-Verhalten

Das ist besonders nützlich, wenn man klassische Enterprise-Messaging-Patterns lernen will.


#8. Jakarta Security

Das Beispiel enthält eine einfache Demo-Identity-Store-Implementierung. Sie ist nur für Lernzwecke gedacht.

package com.acme.banking.transfer.security;

import jakarta.enterprise.context.ApplicationScoped;
import jakarta.security.enterprise.credential.Credential;
import jakarta.security.enterprise.credential.UsernamePasswordCredential;
import jakarta.security.enterprise.identitystore.CredentialValidationResult;
import jakarta.security.enterprise.identitystore.IdentityStore;
import java.util.Set;

@ApplicationScoped
public class DemoIdentityStore implements IdentityStore {
    @Override
    public CredentialValidationResult validate(Credential credential) {
        if (credential instanceof UsernamePasswordCredential login) {
            String user = login.getCaller();
            String password = login.getPasswordAsString();
            if ("admin".equals(user) && "admin".equals(password)) {
                return new CredentialValidationResult(user, Set.of("ADMIN", "USER"));
            }
            if ("user".equals(user) && "user".equals(password)) {
                return new CredentialValidationResult(user, Set.of("USER"));
            }
        }
        return CredentialValidationResult.INVALID_RESULT;
    }
}

#8.1 Rollenmodell

Rolle Bedeutung
USER darf Überweisungen anlegen und Daten lesen
ADMIN darf administrative Endpunkte nutzen

In einer echten Enterprise-Anwendung würde man statt Hardcoding einen Identity Provider verwenden, zum Beispiel LDAP, OIDC, Keycloak oder eine vom Server verwaltete Security Realm.


#9. Application-Server-Konfiguration

#9.1 Serverressourcen

Für das Beispiel werden mindestens diese Ressourcen benötigt:

Ressource JNDI Name Zweck
Datasource java:jboss/datasources/BankingDS Verbindung zu PostgreSQL
JMS Queue java:/jms/queue/PaymentEvents asynchrone Payment Events

#9.2 WildFly CLI Auszug

/subsystem=datasources/data-source=BankingDS:add(
  jndi-name=java:jboss/datasources/BankingDS,
  driver-name=postgresql,
  connection-url=jdbc:postgresql://localhost:5432/banking,
  user-name=banking,
  password=banking,
  min-pool-size=2,
  max-pool-size=20
)

jms-queue add --queue-address=PaymentEvents --entries=java:/jms/queue/PaymentEvents

#10. Build und Deployment

#10.1 Build

mvn clean package

#10.2 Infrastruktur starten

docker compose up -d postgres adminer

#10.3 WARs deployen

cp customer-app/target/customer-app.war          $WILDFLY_HOME/standalone/deployments/
cp account-app/target/account-app.war            $WILDFLY_HOME/standalone/deployments/
cp transfer-app/target/transfer-app.war          $WILDFLY_HOME/standalone/deployments/
cp notification-app/target/notification-app.war  $WILDFLY_HOME/standalone/deployments/

#10.4 Smoke Test

bash scripts/smoke-test.sh

#11. Vergleich zu Spring Boot

Thema Jakarta EE Beispiel Spring Boot Beispiel
Runtime externer Application Server eingebetteter Server
Deployment WAR meist ausführbares JAR oder Container Image
Dependency Scope Jakarta APIs meist provided Starter bringen Implementierungen mit
Transaktionen JTA/serververwaltet Spring Transaction Manager
Security Jakarta Security / Server Realm Spring Security
Messaging Jakarta Messaging / MDB Spring for Kafka, JMS, AMQP
Konfiguration Server/JNDI + Deskriptoren application.yml, Config Server, ENV
Betriebsmodell zentraler Server cloud-native App-Instanzen

#12. Übungen

#12.1 Grundlagen

  • Baue das Projekt mit mvn clean package.
  • Öffne die Root-POM und finde alle Module.
  • Erkläre, warum jakarta.jakartaee-api nicht in das WAR gepackt werden sollte.
  • Suche alle Stellen, an denen @Inject verwendet wird.
  • Suche alle Stellen, an denen @Transactional verwendet wird.

#12.2 REST und Validation

  • Ergänze im customer-app einen Endpunkt GET /customers/{id}.
  • Ergänze im CreateTransferRequest eine maximale Referenzlänge.
  • Erzeuge absichtlich einen ungültigen Request und prüfe die Serverantwort.

#12.3 JPA

  • Ergänze eine NamedQuery, um aktive Konten zu suchen.
  • Füge ein Feld createdAt zu CustomerEntity hinzu.
  • Erkläre, warum Schema-Migrationen in Produktion nicht über schema-generation laufen sollten.

#12.4 Messaging

  • Ergänze ein zweites Event TransferRejectedEvent.
  • Erweitere den Consumer so, dass er unterschiedliche eventType-Werte verarbeitet.
  • Simuliere einen Fehler im Consumer und beobachte Redelivery-Verhalten des Servers.

#12.5 Security

  • Ersetze die Demo-Identity-Store-Logik durch eine serververwaltete Realm-Konfiguration.
  • Ergänze eine Rolle AUDITOR, die nur lesen darf.
  • Prüfe, welche Endpunkte mit ADMIN, USER und ohne Login erreichbar sind.

#13. Erweiterungsideen

#13.1 Jakarta Batch

Ein Batch-Modul könnte nächtliche Kontoauszüge erzeugen. Dazu würde man Jakarta Batch mit Job-XML und Batchlet/Chunk-Step einsetzen.

#13.2 Jakarta Mail

Das Notification-Modul könnte echte E-Mail-Benachrichtigungen versenden.

#13.3 Jakarta Concurrency

Für serververwaltete Hintergrundaufgaben kann Jakarta Concurrency verwendet werden, statt eigene Threads zu starten.

#13.4 Jakarta Faces

Ein optionales admin-console.war könnte mit Jakarta Faces eine klassische serverseitige Admin-Oberfläche bereitstellen.

#13.5 MicroProfile ergänzen

Für Cloud-native Anforderungen kann man später MicroProfile Config, Health, Metrics, OpenAPI, Fault Tolerance und JWT ergänzen. Das wäre dann ein Jakarta-EE-plus-MicroProfile-Projekt.


#14. Prüfungsfragen

  1. Warum ist jakarta.jakartaee-api im Scope provided?
  2. Was ist der Unterschied zwischen @Inject und @PersistenceContext?
  3. Warum liegt die Geschäftslogik nicht direkt in der JAX-RS Resource?
  4. Welche Rolle spielt JTA bei mehreren Datenbankoperationen?
  5. Warum ist eine JMS Queue für asynchrone Integration geeignet?
  6. Was macht eine Message-Driven Bean anders als ein normaler CDI Service?
  7. Was bedeutet JNDI in diesem Projekt?
  8. Welche Risiken entstehen durch Hardcoded Demo-User?
  9. Warum braucht ein WAR-Projekt einen Application Server?
  10. Wann würdest du Spring Boot statt Jakarta EE wählen?

#15. Download-Inhalt

Das ZIP enthält:

jakarta-retail-banking-platform/
├── README.md
├── pom.xml
├── domain-kernel/
├── api-contracts/
├── customer-app/
├── account-app/
├── transfer-app/
├── notification-app/
├── docker-compose.yml
├── deployments/wildfly/configure-wildfly.cli
├── docs/architecture.md
├── docs/adr/0001-use-jakarta-ee-full-platform.md
├── docs/run-on-wildfly.md
└── scripts/smoke-test.sh

#16. Lernreihenfolge für dieses Beispiel

  1. README.md lesen
  2. Root-pom.xml verstehen
  3. api-contracts und domain-kernel anschauen
  4. customer-app als einfachstes WAR analysieren
  5. account-app mit Entity-Logik analysieren
  6. transfer-app als transaktionalen Use Case verstehen
  7. notification-app und MDB nachvollziehen
  8. Serverressourcen in deployments/wildfly/configure-wildfly.cli prüfen
  9. Übungen schrittweise umsetzen
  10. Optional MicroProfile ergänzen