#Zielplattform Jakarta EE
Migration des modernisierten Kerns nach Jakarta EE mit CDI, JAX-RS, Persistence, Messaging und Transactions.
#Jakarta-EE-Zielimplementierung für den modernisierten Kern
Dieser Abschnitt zeigt, wie der zuvor extrahierte Plain-Java-Kern nicht nach Spring Boot, sondern in eine moderne Jakarta-EE-Zielarchitektur überführt werden kann.
Der wichtigste Unterschied zum Spring-Boot-Zielpfad:
Spring Boot Zielpfad:
eigenständige Anwendung mit eingebettetem Runtime-Modell
Jakarta EE Zielpfad:
standardisierte Enterprise-Anwendung auf einem Jakarta-EE-kompatiblen Server
Für ein altes Java-EE/EJB-Projekt kann Jakarta EE besonders attraktiv sein, wenn viele dieser Dinge bereits vorhanden sind:
- JTA-Transaktionen
- JPA/Persistence
- JMS/Messaging
- CDI/EJB-Container-Services
- Application-Server-Betrieb
- Security über Container
- EAR/WAR-Strukturen
- bestehendes Betriebswissen für Enterprise Server
Wichtig: Jakarta EE 11 setzt Java 17 oder höher voraus. Außerdem sind moderne Spezifikationen wie CDI, Persistence, Messaging, Transactions, RESTful Web Services und Enterprise Beans weiterhin relevant. Der Namespace ist bei modernen Jakarta-Versionen jakarta.*, nicht mehr javax.*.
#Ziel der Jakarta-EE-Migration
Das Ziel ist nicht, alles neu zu schreiben.
Das Ziel ist:
Legacy Java EE / EJB Anwendung
↓
modernisierter fachlicher Kern
↓
Jakarta EE Runtime mit jakarta.* APIs
↓
schrittweise Ablösung alter EJB-/JSP-/SOAP-/JNDI-Strukturen
Wir behalten den Kern aus den vorherigen Teilen:
application/
CreateOrderUseCase
PaymentRequestedHandler
CreateInvoiceUseCase
domain/
Order
OrderStatus
OrderId
OutboxEvent
ports/
OrderRepository
OutboxPort
PaymentGateway
AuditPort
InvoiceRepository
ProcessedMessageRepository
Und ersetzen nur die technischen Adapter:
Legacy Adapter
javax.ejb
javax.persistence
javax.jms
JNDI überall
Jakarta Adapter
jakarta.enterprise.context
jakarta.inject
jakarta.transaction
jakarta.persistence
jakarta.jms
jakarta.ws.rs
#Jakarta-EE-Zielstruktur
Eine pragmatische Zielstruktur kann so aussehen:
legacy-modernization-jakarta/
pom.xml
src/main/java/com/example/order/
JakartaApplication.java
api/
OrderResource.java
CreateOrderHttpRequest.java
CreateOrderHttpResponse.java
application/
CreateOrderApplicationService.java
PaymentApplicationService.java
InvoiceMessageApplicationService.java
domain/
Order.java
OrderId.java
OrderStatus.java
Money.java
OutboxEvent.java
ports/
OrderRepository.java
OutboxRepository.java
PaymentGateway.java
AuditPort.java
InvoiceRepository.java
ProcessedMessageRepository.java
EventPublisher.java
adapters/
persistence/
OrderJpaAdapter.java
OrderEntity.java
OutboxJpaAdapter.java
OutboxEventEntity.java
InvoiceJpaAdapter.java
ProcessedMessageJpaAdapter.java
messaging/
OrderPaidJmsPublisher.java
OrderPaidMessageListener.java
OrderPaidMessageMapper.java
payment/
SoapPaymentGateway.java
FakePaymentGateway.java
audit/
JpaAuditAdapter.java
jobs/
PaymentRequestedJob.java
OrderPaidOutboxPublisherJob.java
config/
BeanConfig.java
Wenn du aus einem alten EAR kommst, kann der erste Schritt auch weiterhin ein WAR sein:
legacy-modernization-jakarta.war
Später kannst du bei Bedarf wieder EAR-Strukturen nutzen, aber für neue Slices ist ein schlankes WAR oft einfacher.
#Maven-Grundstruktur für Jakarta EE
Ein typisches Jakarta-EE-11-Projekt nutzt die Platform API normalerweise mit provided, weil der Application Server die Implementierung bereitstellt.
<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.example</groupId>
<artifactId>legacy-modernization-jakarta</artifactId>
<version>1.0.0-SNAPSHOT</version>
<packaging>war</packaging>
<properties>
<maven.compiler.release>17</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<jakartaee.version>11.0.0</jakartaee.version>
<junit.version>5.10.2</junit.version>
</properties>
<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>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-war-plugin</artifactId>
<version>3.4.0</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
</plugin>
</plugins>
</build>
</project>
Wichtig:
provided bedeutet:
Die API ist beim Kompilieren sichtbar.
Die Runtime kommt vom Application Server.
Nicht verwechseln mit Spring Boot, wo viele Implementierungen direkt in der Anwendung enthalten sind.
#Jakarta REST Entry Point statt JSP oder SOAP
Im Legacy-System kam der Use Case vielleicht von einer JSP:
create-order.jsp
↓
JNDI Lookup
↓
OrderServiceBean
Oder von einem SOAP Endpoint:
SOAP Request
↓
OrderEndpoint
↓
OrderServiceBean
Im Jakarta-Zielpfad kann ein neuer REST Entry Point so aussehen:
package com.example.order;
import jakarta.ws.rs.ApplicationPath;
import jakarta.ws.rs.core.Application;
@ApplicationPath("/api")
public class JakartaApplication extends Application {
}
REST Resource:
package com.example.order.api;
import com.example.order.application.CreateOrderApplicationService;
import com.example.order.application.CreateOrderCommand;
import com.example.order.domain.OrderId;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
@Path("/orders")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public class OrderResource {
@Inject
CreateOrderApplicationService createOrderApplicationService;
@POST
public Response createOrder(CreateOrderHttpRequest request) {
CreateOrderCommand command = new CreateOrderCommand(
request.customerId(),
request.amount()
);
OrderId orderId = createOrderApplicationService.createOrder(command);
return Response.accepted(
new CreateOrderHttpResponse(orderId.value(), "PAYMENT_PENDING")
).build();
}
}
Request DTO:
package com.example.order.api;
import java.math.BigDecimal;
public record CreateOrderHttpRequest(
String customerId,
BigDecimal amount
) {
}
Response DTO:
package com.example.order.api;
public record CreateOrderHttpResponse(
String orderId,
String status
) {
}
Warum 202 Accepted?
Weil Zahlung und Rechnungsstellung im Outbox/Worker-Flow asynchron weiterlaufen können.
Wenn dein Legacy-UI synchrones Verhalten braucht, kannst du stattdessen 200 OK zurückgeben, aber architektonisch ist 202 Accepted bei asynchronem Processing ehrlicher.
#Application Service als Transaktionsgrenze
Im EJB-Legacy-Code lag die Transaktion direkt auf der EJB-Methode:
@TransactionAttribute(TransactionAttributeType.REQUIRED)
public Long createOrder(CreateOrderRequest request) {
...
}
Im Jakarta-Zielpfad kannst du die Transaktionsgrenze auf einen Application Service legen:
package com.example.order.application;
import com.example.order.domain.OrderId;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
@ApplicationScoped
public class CreateOrderApplicationService {
@Inject
CreateOrderUseCase createOrderUseCase;
@Transactional
public OrderId createOrder(CreateOrderCommand command) {
return createOrderUseCase.execute(command);
}
}
Wichtig:
Die Domain bleibt frei von Transaktionsannotationen.
Die Transaktion sitzt auf der Use-Case-orientierten Application-Service-Ebene.
#Plain-Java-Use-Case als CDI Bean
Der Use Case selbst kann eine CDI Bean sein:
package com.example.order.application;
import com.example.order.domain.Order;
import com.example.order.domain.OrderId;
import com.example.order.domain.OutboxEvent;
import com.example.order.ports.AuditPort;
import com.example.order.ports.OrderRepository;
import com.example.order.ports.OutboxRepository;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
@ApplicationScoped
public class CreateOrderUseCase {
@Inject
OrderRepository orderRepository;
@Inject
AuditPort auditPort;
@Inject
OutboxRepository outboxRepository;
public OrderId execute(CreateOrderCommand command) {
Order order = Order.create(command.customerId(), command.amount());
orderRepository.save(order);
auditPort.orderCreated(order.id());
outboxRepository.store(OutboxEvent.paymentRequested(order));
return order.id();
}
}
Alternative für bessere Testbarkeit:
@ApplicationScoped
public class CreateOrderUseCase {
private final OrderRepository orderRepository;
private final AuditPort auditPort;
private final OutboxRepository outboxRepository;
@Inject
public CreateOrderUseCase(
OrderRepository orderRepository,
AuditPort auditPort,
OutboxRepository outboxRepository
) {
this.orderRepository = orderRepository;
this.auditPort = auditPort;
this.outboxRepository = outboxRepository;
}
public OrderId execute(CreateOrderCommand command) {
...
}
}
Empfehlung:
Für neue Klassen Konstruktor-Injektion bevorzugen.
Für sehr alte CDI-/EJB-Umgebungen kann Field Injection als Zwischenschritt akzeptabel sein.
#Jakarta Persistence Adapter
Port:
package com.example.order.ports;
import com.example.order.domain.Order;
import com.example.order.domain.OrderId;
import java.util.Optional;
public interface OrderRepository {
void save(Order order);
void update(Order order);
Optional<Order> findById(OrderId orderId);
}
Entity:
package com.example.order.adapters.persistence;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.math.BigDecimal;
@Entity
@Table(name = "orders")
public class OrderEntity {
@Id
private String id;
@Column(name = "customer_id", nullable = false)
private String customerId;
@Column(name = "amount", nullable = false)
private BigDecimal amount;
@Column(name = "status", nullable = false)
private String status;
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getCustomerId() {
return customerId;
}
public void setCustomerId(String customerId) {
this.customerId = customerId;
}
public BigDecimal getAmount() {
return amount;
}
public void setAmount(BigDecimal amount) {
this.amount = amount;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
}
Adapter:
package com.example.order.adapters.persistence;
import com.example.order.domain.Order;
import com.example.order.domain.OrderId;
import com.example.order.ports.OrderRepository;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import java.util.Optional;
@ApplicationScoped
public class OrderJpaAdapter implements OrderRepository {
@PersistenceContext
EntityManager em;
@Override
public void save(Order order) {
em.persist(OrderEntityMapper.toEntity(order));
}
@Override
public void update(Order order) {
OrderEntity entity = em.find(OrderEntity.class, order.id().value());
if (entity == null) {
throw new IllegalStateException("Order not found: " + order.id().value());
}
entity.setStatus(order.status().name());
entity.setAmount(order.amount());
entity.setCustomerId(order.customerId());
}
@Override
public Optional<Order> findById(OrderId orderId) {
OrderEntity entity = em.find(OrderEntity.class, orderId.value());
if (entity == null) {
return Optional.empty();
}
return Optional.of(OrderEntityMapper.toDomain(entity));
}
}
Mapper:
package com.example.order.adapters.persistence;
import com.example.order.domain.Order;
import com.example.order.domain.OrderId;
import com.example.order.domain.OrderStatus;
public final class OrderEntityMapper {
private OrderEntityMapper() {
}
public static OrderEntity toEntity(Order order) {
OrderEntity entity = new OrderEntity();
entity.setId(order.id().value());
entity.setCustomerId(order.customerId());
entity.setAmount(order.amount());
entity.setStatus(order.status().name());
return entity;
}
public static Order toDomain(OrderEntity entity) {
return Order.restore(
OrderId.of(entity.getId()),
entity.getCustomerId(),
entity.getAmount(),
OrderStatus.valueOf(entity.getStatus())
);
}
}
#persistence.xml
Bei klassischer Jakarta Persistence brauchst du oft weiterhin eine persistence.xml.
Pfad:
src/main/resources/META-INF/persistence.xml
Beispiel:
<?xml version="1.0" encoding="UTF-8"?>
<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_2.xsd"
version="3.2">
<persistence-unit name="orderPU" transaction-type="JTA">
<jta-data-source>java:/jdbc/OrderDS</jta-data-source>
<class>com.example.order.adapters.persistence.OrderEntity</class>
<class>com.example.order.adapters.persistence.OutboxEventEntity</class>
<class>com.example.order.adapters.persistence.InvoiceEntity</class>
<class>com.example.order.adapters.persistence.ProcessedMessageEntity</class>
<properties>
<property name="jakarta.persistence.schema-generation.database.action" value="none"/>
</properties>
</persistence-unit>
</persistence>
Für Legacy-Migration gilt:
Schema-Generation am Anfang meistens deaktivieren.
DB-Schema kontrolliert über Migrationstool oder DBA-Prozess ändern.
#Outbox Adapter mit Jakarta Persistence
Port:
package com.example.order.ports;
import com.example.order.domain.OutboxEvent;
import java.util.List;
public interface OutboxRepository {
void store(OutboxEvent event);
List<OutboxEvent> findPending(String eventType, int limit);
void markPublished(String eventId);
void markFailed(String eventId, String errorMessage);
}
Entity:
package com.example.order.adapters.persistence;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Table;
import java.time.Instant;
@Entity
@Table(name = "outbox_event")
public class OutboxEventEntity {
@Id
private String id;
@Column(name = "aggregate_id", nullable = false)
private String aggregateId;
@Column(name = "aggregate_type", nullable = false)
private String aggregateType;
@Column(name = "event_type", nullable = false)
private String eventType;
@Lob
@Column(name = "payload", nullable = false)
private String payload;
@Column(name = "status", nullable = false)
private String status;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
@Column(name = "published_at")
private Instant publishedAt;
@Column(name = "retry_count", nullable = false)
private int retryCount;
@Lob
@Column(name = "last_error")
private String lastError;
// Getter/Setter
}
Adapter:
package com.example.order.adapters.persistence;
import com.example.order.domain.OutboxEvent;
import com.example.order.ports.OutboxRepository;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import java.time.Instant;
import java.util.List;
@ApplicationScoped
public class OutboxJpaAdapter implements OutboxRepository {
@PersistenceContext
EntityManager em;
@Override
public void store(OutboxEvent event) {
OutboxEventEntity entity = new OutboxEventEntity();
entity.setId(event.id());
entity.setAggregateId(event.aggregateId());
entity.setAggregateType(event.aggregateType());
entity.setEventType(event.eventType());
entity.setPayload(event.payload());
entity.setCreatedAt(event.createdAt());
entity.setStatus("PENDING");
entity.setRetryCount(0);
em.persist(entity);
}
@Override
public List<OutboxEvent> findPending(String eventType, int limit) {
return em.createQuery("""
select e
from OutboxEventEntity e
where e.status = 'PENDING'
and e.eventType = :eventType
order by e.createdAt
""", OutboxEventEntity.class)
.setParameter("eventType", eventType)
.setMaxResults(limit)
.getResultList()
.stream()
.map(OutboxEventEntityMapper::toDomain)
.toList();
}
@Override
public void markPublished(String eventId) {
OutboxEventEntity entity = em.find(OutboxEventEntity.class, eventId);
entity.setStatus("PUBLISHED");
entity.setPublishedAt(Instant.now());
}
@Override
public void markFailed(String eventId, String errorMessage) {
OutboxEventEntity entity = em.find(OutboxEventEntity.class, eventId);
entity.setRetryCount(entity.getRetryCount() + 1);
entity.setLastError(errorMessage);
if (entity.getRetryCount() >= 10) {
entity.setStatus("DEAD");
}
}
}
Hinweis:
Für parallele Publisher brauchst du zusätzlich Locking.
Zum Beispiel SELECT FOR UPDATE, Status PROCESSING oder DB-spezifische SKIP LOCKED-Strategien.
#PaymentRequested Job in Jakarta EE
In Jakarta EE kannst du den Worker mit EJB Timer Services bauen.
package com.example.order.jobs;
import com.example.order.application.PaymentRequestedHandler;
import com.example.order.domain.OutboxEvent;
import com.example.order.ports.OutboxRepository;
import jakarta.ejb.Schedule;
import jakarta.ejb.Singleton;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import java.util.List;
@Singleton
public class PaymentRequestedJob {
@Inject
OutboxRepository outboxRepository;
@Inject
PaymentRequestedHandler paymentRequestedHandler;
@Schedule(hour = "*", minute = "*", second = "*/30", persistent = false)
@Transactional
public void run() {
List<OutboxEvent> events = outboxRepository.findPending("PaymentRequested", 20);
for (OutboxEvent event : events) {
try {
paymentRequestedHandler.handle(event);
outboxRepository.markPublished(event.id());
} catch (Exception ex) {
outboxRepository.markFailed(event.id(), ex.getMessage());
}
}
}
}
Aber Achtung:
Diese einfache Version hält die Transaktion während des Handler-Aufrufs offen.
Wenn der Handler SOAP aufruft, ist das nicht ideal.
Besser ist eine Variante mit bewusst getrennten Transaktionsgrenzen:
Job lädt Event
↓
TX A: markiere Payment in progress
↓
SOAP Call außerhalb langer DB-Transaktion
↓
TX B: speichere Ergebnis
↓
TX C: markiere Outbox Event published/failed
#Payment Application Service mit getrennten Transaktionen
package com.example.order.application;
import com.example.order.domain.Order;
import com.example.order.domain.OrderId;
import com.example.order.domain.OutboxEvent;
import com.example.order.ports.AuditPort;
import com.example.order.ports.OrderRepository;
import com.example.order.ports.OutboxRepository;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
@ApplicationScoped
public class PaymentStateService {
@Inject
OrderRepository orderRepository;
@Inject
AuditPort auditPort;
@Inject
OutboxRepository outboxRepository;
@Transactional(Transactional.TxType.REQUIRES_NEW)
public void markPaymentInProgress(OrderId orderId) {
Order order = orderRepository.findById(orderId).orElseThrow();
if (order.isTerminalPaymentState()) {
return;
}
order.markPaymentInProgress();
orderRepository.update(order);
}
@Transactional(Transactional.TxType.REQUIRES_NEW)
public void markPaid(OrderId orderId, String providerReference) {
Order order = orderRepository.findById(orderId).orElseThrow();
if (order.isPaid()) {
return;
}
order.markPaid(providerReference);
orderRepository.update(order);
auditPort.orderPaid(order.id());
outboxRepository.store(OutboxEvent.orderPaid(order));
}
@Transactional(Transactional.TxType.REQUIRES_NEW)
public void markPaymentFailed(OrderId orderId, String reason) {
Order order = orderRepository.findById(orderId).orElseThrow();
if (order.isPaid()) {
return;
}
order.markPaymentFailed(reason);
orderRepository.update(order);
auditPort.paymentFailed(order.id());
}
@Transactional(Transactional.TxType.REQUIRES_NEW)
public void markPaymentUnknown(OrderId orderId, String reason) {
Order order = orderRepository.findById(orderId).orElseThrow();
order.markPaymentUnknown(reason);
orderRepository.update(order);
auditPort.paymentUnknown(order.id(), reason);
}
}
Damit ist die EJB-Annotation @TransactionAttribute(REQUIRES_NEW) durch Jakarta Transactions ersetzt:
EJB:
@TransactionAttribute(TransactionAttributeType.REQUIRES_NEW)
Jakarta Transactions:
@Transactional(Transactional.TxType.REQUIRES_NEW)
#Payment Worker ohne lange Datenbanktransaktion
package com.example.order.jobs;
import com.example.order.application.PaymentStateService;
import com.example.order.domain.OrderId;
import com.example.order.domain.OutboxEvent;
import com.example.order.ports.OutboxRepository;
import com.example.order.ports.PaymentGateway;
import com.example.order.ports.PaymentResult;
import jakarta.ejb.Schedule;
import jakarta.ejb.Singleton;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import java.util.List;
@Singleton
public class PaymentRequestedJob {
@Inject
OutboxRepository outboxRepository;
@Inject
PaymentStateService paymentStateService;
@Inject
PaymentGateway paymentGateway;
@Schedule(hour = "*", minute = "*", second = "*/30", persistent = false)
public void run() {
List<OutboxEvent> events = findPendingEventsInTransaction();
for (OutboxEvent event : events) {
processOne(event);
}
}
@Transactional
List<OutboxEvent> findPendingEventsInTransaction() {
return outboxRepository.findPending("PaymentRequested", 20);
}
private void processOne(OutboxEvent event) {
PaymentRequestedPayload payload = PaymentRequestedPayload.fromJson(event.payload());
OrderId orderId = OrderId.of(payload.orderId());
try {
paymentStateService.markPaymentInProgress(orderId);
PaymentResult result = paymentGateway.charge(
new PaymentCommand(
"payment-order-" + orderId.value(),
payload.customerId(),
payload.amount()
)
);
if (result.isSuccessful()) {
paymentStateService.markPaid(orderId, result.providerReference());
} else {
paymentStateService.markPaymentFailed(orderId, "provider rejected payment");
}
markPublishedInTransaction(event.id());
} catch (PaymentTimeoutException ex) {
paymentStateService.markPaymentUnknown(orderId, ex.getMessage());
markFailedInTransaction(event.id(), ex.getMessage());
} catch (Exception ex) {
markFailedInTransaction(event.id(), ex.getMessage());
}
}
@Transactional
void markPublishedInTransaction(String eventId) {
outboxRepository.markPublished(eventId);
}
@Transactional
void markFailedInTransaction(String eventId, String message) {
outboxRepository.markFailed(eventId, message);
}
}
Wichtiges CDI/EJB-Detail:
Transaktionsinterceptor greifen nicht immer bei Self-Invocation.
Wenn eine Methode derselben Klasse intern aufgerufen wird, kann je nach Runtime/Proxy-Modell der Interceptor umgangen werden.
Robuster ist deshalb oft:
PaymentRequestedJob
ruft
OutboxTransactionService
mit @Transactional Methoden
#OutboxTransactionService gegen Self-Invocation-Probleme
package com.example.order.application;
import com.example.order.domain.OutboxEvent;
import com.example.order.ports.OutboxRepository;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import java.util.List;
@ApplicationScoped
public class OutboxTransactionService {
@Inject
OutboxRepository outboxRepository;
@Transactional
public List<OutboxEvent> findPending(String eventType, int limit) {
return outboxRepository.findPending(eventType, limit);
}
@Transactional(Transactional.TxType.REQUIRES_NEW)
public void markPublished(String eventId) {
outboxRepository.markPublished(eventId);
}
@Transactional(Transactional.TxType.REQUIRES_NEW)
public void markFailed(String eventId, String message) {
outboxRepository.markFailed(eventId, message);
}
}
Dann wird der Job sauberer:
@Singleton
public class PaymentRequestedJob {
@Inject
OutboxTransactionService outboxTransactionService;
@Inject
PaymentStateService paymentStateService;
@Inject
PaymentGateway paymentGateway;
@Schedule(hour = "*", minute = "*", second = "*/30", persistent = false)
public void run() {
List<OutboxEvent> events = outboxTransactionService.findPending("PaymentRequested", 20);
for (OutboxEvent event : events) {
processOne(event);
}
}
private void processOne(OutboxEvent event) {
...
}
}
Merksatz:
Wenn Transaktionsgrenzen wichtig sind, rufe transaktionale Methoden über eine andere CDI/EJB Bean auf.
#Jakarta Messaging Publisher
Port:
package com.example.order.ports;
public interface EventPublisher {
void publishOrderPaid(String payload);
}
Jakarta Messaging Adapter:
package com.example.order.adapters.messaging;
import com.example.order.ports.EventPublisher;
import jakarta.annotation.Resource;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.jms.JMSContext;
import jakarta.jms.Queue;
@ApplicationScoped
public class OrderPaidJmsPublisher implements EventPublisher {
@Inject
JMSContext jmsContext;
@Resource(lookup = "java:/jms/queue/OrderPaidQueue")
Queue orderPaidQueue;
@Override
public void publishOrderPaid(String payload) {
jmsContext.createProducer()
.setProperty("eventType", "OrderPaid")
.send(orderPaidQueue, payload);
}
}
Unterschied zum Legacy-Stil:
Alt:
ConnectionFactory manuell öffnen
Connection manuell schließen
Session manuell verwalten
Neu:
JMSContext vom Container injizieren lassen
#OrderPaid Outbox Publisher Job
package com.example.order.jobs;
import com.example.order.application.OutboxTransactionService;
import com.example.order.domain.OutboxEvent;
import com.example.order.ports.EventPublisher;
import jakarta.ejb.Schedule;
import jakarta.ejb.Singleton;
import jakarta.inject.Inject;
import java.util.List;
@Singleton
public class OrderPaidOutboxPublisherJob {
@Inject
OutboxTransactionService outboxTransactionService;
@Inject
EventPublisher eventPublisher;
@Schedule(hour = "*", minute = "*", second = "*/20", persistent = false)
public void run() {
List<OutboxEvent> events = outboxTransactionService.findPending("OrderPaid", 50);
for (OutboxEvent event : events) {
try {
eventPublisher.publishOrderPaid(event.payload());
outboxTransactionService.markPublished(event.id());
} catch (Exception ex) {
outboxTransactionService.markFailed(event.id(), ex.getMessage());
}
}
}
}
Wichtig:
Outbox Publisher liefern normalerweise at-least-once.
Deshalb muss der Consumer idempotent sein.
#Jakarta Messaging Consumer statt Legacy MDB
Du kannst weiterhin eine MDB verwenden, aber im modernen Stil dünn halten.
package com.example.order.adapters.messaging;
import com.example.order.application.OrderPaidMessageApplicationService;
import jakarta.ejb.ActivationConfigProperty;
import jakarta.ejb.MessageDriven;
import jakarta.inject.Inject;
import jakarta.jms.Message;
import jakarta.jms.MessageListener;
import jakarta.jms.TextMessage;
@MessageDriven(activationConfig = {
@ActivationConfigProperty(
propertyName = "destinationLookup",
propertyValue = "java:/jms/queue/OrderPaidQueue"
),
@ActivationConfigProperty(
propertyName = "destinationType",
propertyValue = "jakarta.jms.Queue"
)
})
public class OrderPaidMessageListener implements MessageListener {
@Inject
OrderPaidMessageApplicationService applicationService;
@Override
public void onMessage(Message message) {
try {
TextMessage textMessage = (TextMessage) message;
OrderPaidMessage orderPaidMessage = OrderPaidMessageMapper.fromJson(
textMessage.getJMSMessageID(),
textMessage.getText()
);
applicationService.handle(orderPaidMessage);
} catch (NonRetryableMessageException ex) {
// fachlich ungültig: loggen, aber nicht retryen
// abhängig vom Server kann man hier bewusst NICHT werfen
logInvalidMessage(ex);
} catch (Exception ex) {
// technische Fehler: werfen, damit Container Redelivery auslöst
throw new RuntimeException(ex);
}
}
private void logInvalidMessage(Exception ex) {
// strukturiert loggen
}
}
Die MDB enthält nur:
- JMS Message empfangen
- Payload mappen
- Application Service aufrufen
- Retry-Entscheidung grob trennen
Sie enthält nicht:
- Rechnungslogik
- JPA Queries
- SOAP Calls
- Statusregeln
#Idempotenter Message Application Service
package com.example.order.application;
import com.example.order.adapters.messaging.OrderPaidMessage;
import com.example.order.ports.InvoiceRepository;
import com.example.order.ports.ProcessedMessageRepository;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
@ApplicationScoped
public class OrderPaidMessageApplicationService {
@Inject
ProcessedMessageRepository processedMessageRepository;
@Inject
InvoiceRepository invoiceRepository;
@Inject
CreateInvoiceUseCase createInvoiceUseCase;
@Transactional
public void handle(OrderPaidMessage message) {
if (processedMessageRepository.alreadyProcessed(message.messageId())) {
return;
}
if (invoiceRepository.existsForOrderId(message.orderId())) {
processedMessageRepository.markProcessed(message.messageId());
return;
}
createInvoiceUseCase.execute(
new CreateInvoiceCommand(message.orderId())
);
processedMessageRepository.markProcessed(message.messageId());
}
}
Das schützt gegen:
- doppelte JMS Delivery
- Outbox Retry
- Container Redelivery
- manuelles Reprocessing
#SOAP in Jakarta EE realistisch behandeln
Bei Legacy-Systemen ist SOAP oft der schwierigste Teil.
Wichtig:
Nicht jeder Jakarta-EE-11-Server liefert automatisch alle historischen SOAP/JAX-WS/JAXB-Funktionalitäten wie alte Java-EE-Server.
Deshalb gibt es drei realistische Optionen:
Option A:
SOAP Client bleibt im Legacy-Teil und wird hinter PaymentGateway gekapselt.
Option B:
SOAP Client wird mit einer server-/vendor-spezifischen Jakarta-kompatiblen Erweiterung betrieben.
Option C:
SOAP-Aufruf wird in einen separaten Adapter-Service ausgelagert.
Der Port bleibt gleich:
package com.example.order.ports;
public interface PaymentGateway {
PaymentResult charge(PaymentCommand command);
}
SOAP Adapter:
package com.example.order.adapters.payment;
import com.example.order.ports.PaymentCommand;
import com.example.order.ports.PaymentGateway;
import com.example.order.ports.PaymentResult;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class SoapPaymentGateway implements PaymentGateway {
@Override
public PaymentResult charge(PaymentCommand command) {
// Hier bewusst Adapter-Grenze halten.
// Ob die Implementierung Jakarta XML Web Services, Apache CXF,
// Metro oder einen separaten Legacy Client nutzt, bleibt innen verborgen.
return callProvider(command);
}
private PaymentResult callProvider(PaymentCommand command) {
// SOAP-spezifischer Code
throw new UnsupportedOperationException("Implement SOAP provider adapter here");
}
}
Warum so defensiv?
Weil SOAP-Migration oft nicht nur Import-Migration ist.
Es geht um WSDL, JAXB-Klassen, WS-Security, Zertifikate, Timeouts und Server-Unterstützung.
#javax → jakarta Migration kontrolliert durchführen
Alte Imports:
import javax.ejb.Stateless;
import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
import javax.jms.Message;
import javax.ws.rs.Path;
Neue Imports:
import jakarta.ejb.Stateless;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import jakarta.jms.Message;
import jakarta.ws.rs.Path;
Aber:
Nicht blind global ersetzen.
Prüfliste:
1. Eigener Code
- javax.ejb → jakarta.ejb
- javax.persistence → jakarta.persistence
- javax.transaction → jakarta.transaction
- javax.jms → jakarta.jms
- javax.ws.rs → jakarta.ws.rs
- javax.servlet → jakarta.servlet
2. XML-Dateien
- persistence.xml Namespace
- web.xml Namespace
- faces-config.xml, falls vorhanden
- beans.xml, falls vorhanden
3. Generierter Code
- JAXB-Klassen
- SOAP Client Stubs
- WSDL-Bindings
4. Third-Party Libraries
- Hibernate Version
- CXF/Metro Version
- JSON Provider
- Security Libraries
- alte Vendor Clients
5. Application Server
- Unterstützt er deine Jakarta-Version?
- Welche Features müssen aktiviert werden?
- Gibt es alte javax-Libraries im Server?
Empfohlene Reihenfolge:
1. Build auf Java 17 stabilisieren
2. Dependencies inventarisieren
3. Tests/Smoke Tests sichern
4. javax/jakarta Migration in Branch durchführen
5. App Server kompatibel wählen
6. SOAP/JAXB gesondert behandeln
7. Deployment testen
8. Regressionstests ausführen
#Jakarta-EE-Cutover-Plan für Complex Slice 001
Für den Order-Payment-Slice ergibt sich dieser konkrete Migrationsplan:
Phase 1: Kern stabilisieren
- CreateOrderUseCase Plain Java
- PaymentRequestedHandler Plain Java
- OrderPaidMessageApplicationService Plain Java/CDI-fähig
- Ports stabil
- Unit Tests vorhanden
Phase 2: Jakarta Adapter hinzufügen
- OrderJpaAdapter mit jakarta.persistence
- OutboxJpaAdapter mit jakarta.persistence
- OrderPaidJmsPublisher mit jakarta.jms
- OrderPaidMessageListener als MDB mit jakarta.ejb
- REST Resource mit jakarta.ws.rs
Phase 3: Legacy parallel betreiben
- alte JSP/SOAP/EJB Entry Points bleiben
- neuer REST Entry Point im Shadow Mode
- gleiche Use Cases oder gleiche Ports verwenden
Phase 4: Daten vergleichen
- erzeugte Orders vergleichen
- Outbox Events vergleichen
- Payment Status vergleichen
- Invoice-Erzeugung vergleichen
- Audit Logs vergleichen
Phase 5: Cutover
- Traffic für einen kleinen Nutzer-/Mandantenkreis auf neuen Entry Point
- Monitoring Outbox/Payment/Invoice aktiv
- Rollback-Pfad definiert
Phase 6: Legacy entfernen
- JSP/JNDI Lookup entfernen
- alte EJB-Fassade entfernen oder nur noch als Kompatibilitätsschicht behalten
- alte direkte JMS Sends entfernen
- alte SOAP-In-TX-Calls entfernen
Produktions-Checkliste:
| Bereich | Frage | Status |
|---|---|---|
| Build | Läuft Build reproduzierbar auf Java 17+? | offen |
| Server | Ist der Zielserver Jakarta-EE-kompatibel? | offen |
| Namespace | Sind javax/jakarta Konflikte beseitigt? | offen |
| Persistence | Läuft JTA Persistence Unit? | offen |
| Messaging | Funktionieren Queues und MDBs? | offen |
| Outbox | Werden Events zuverlässig published? | offen |
| Idempotenz | Sind Consumer idempotent? | offen |
| SOAP | Ist Payment Gateway produktiv getestet? | offen |
| Security | Sind Rollen/Principal korrekt migriert? | offen |
| Monitoring | Gibt es Alarme für PENDING/DEAD/UNKNOWN? | offen |
| Rollback | Gibt es einen Rückweg zum Legacy Entry Point? | offen |
Merksatz:
Jakarta EE ist für Legacy-Enterprise-Java oft der sanftere Zielpfad als ein kompletter Framework-Wechsel.
Aber sanfter bedeutet nicht automatisch trivial:
Der javax/jakarta Namespace, Server-Kompatibilität, SOAP/JAXB und Third-Party Libraries müssen bewusst geplant werden.
#Zusatz: Mapping-Tabelle Legacy Java EE zu Jakarta EE
| Legacy Java EE | Jakarta EE Ziel | Bemerkung |
|---|---|---|
| javax.ejb.Stateless | jakarta.ejb.Stateless oder CDI @ApplicationScoped | EJB nur behalten, wenn Container-Services nötig sind |
| javax.ejb.MessageDriven | jakarta.ejb.MessageDriven | MDB dünn halten |
| javax.persistence.EntityManager | jakarta.persistence.EntityManager | JPA-Konzept bleibt ähnlich |
| javax.transaction.UserTransaction | jakarta.transaction.UserTransaction | möglichst selten manuell nutzen |
| @TransactionAttribute(REQUIRED) | @Transactional | Propagation bewusst prüfen |
| @TransactionAttribute(REQUIRES_NEW) | @Transactional(TxType.REQUIRES_NEW) | kritisch testen |
| javax.jms.* | jakarta.jms.* | JMSContext bevorzugen |
| javax.ws.rs.* | jakarta.ws.rs.* | REST Entry Points modernisieren |
| JSP Scriptlet | REST Resource / MVC / reine View | Businesslogik entfernen |
| JNDI überall | zentrale Ressourcen/Injection | JNDI nicht im Fachcode |
| SOAP direkt in EJB | PaymentGateway Adapter | SOAP isolieren |
#Zusatz: Wann Jakarta EE statt Spring Boot?
Wähle Jakarta EE eher, wenn:
- dein Betrieb Application Server weiter nutzen will
- JTA/XA stark genutzt wird
- JMS/MDBs zentral sind
- EJB-Security oder Container-Rollen wichtig sind
- Team Java EE/Jakarta EE gut kennt
- du mit minimalerem Architekturbruch migrieren willst
Wähle Spring Boot eher, wenn:
- Team Spring als Standard nutzt
- du Self-contained Deployments willst
- du Application Server verlassen willst
- du Microservices mit eigenem Runtime-Modell bevorzugst
- bestehendes Betriebsmodell Richtung Kubernetes/Container geht
Wähle zuerst weder noch, wenn:
- Build nicht reproduzierbar ist
- Transaktionen unbekannt sind
- keine Tests existieren
- SOAP/JMS/DB-Flows nicht dokumentiert sind
Dann gilt weiter:
Erst verstehen.
Dann absichern.
Dann kapseln.
Dann Zielplattform wählen.