#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:

text
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:

text
- 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:

text
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:

text
application/
  CreateOrderUseCase
  PaymentRequestedHandler
  CreateInvoiceUseCase

domain/
  Order
  OrderStatus
  OrderId
  OutboxEvent

ports/
  OrderRepository
  OutboxPort
  PaymentGateway
  AuditPort
  InvoiceRepository
  ProcessedMessageRepository

Und ersetzen nur die technischen Adapter:

text
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:

text
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:

text
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.

xml
<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:

text
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:

text
create-order.jsp
  ↓
JNDI Lookup
  ↓
OrderServiceBean

Oder von einem SOAP Endpoint:

text
SOAP Request
  ↓
OrderEndpoint
  ↓
OrderServiceBean

Im Jakarta-Zielpfad kann ein neuer REST Entry Point so aussehen:

java
package com.example.order;

import jakarta.ws.rs.ApplicationPath;
import jakarta.ws.rs.core.Application;

@ApplicationPath("/api")
public class JakartaApplication extends Application {
}

REST Resource:

java
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:

java
package com.example.order.api;

import java.math.BigDecimal;

public record CreateOrderHttpRequest(
        String customerId,
        BigDecimal amount
) {
}

Response DTO:

java
package com.example.order.api;

public record CreateOrderHttpResponse(
        String orderId,
        String status
) {
}

Warum 202 Accepted?

text
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:

java
@TransactionAttribute(TransactionAttributeType.REQUIRED)
public Long createOrder(CreateOrderRequest request) {
    ...
}

Im Jakarta-Zielpfad kannst du die Transaktionsgrenze auf einen Application Service legen:

java
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:

text
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:

java
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:

java
@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:

text
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:

java
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:

java
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:

java
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:

java
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:

text
src/main/resources/META-INF/persistence.xml

Beispiel:

xml
<?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:

text
Schema-Generation am Anfang meistens deaktivieren.
DB-Schema kontrolliert über Migrationstool oder DBA-Prozess ändern.

#Outbox Adapter mit Jakarta Persistence

Port:

java
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:

java
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:

java
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:

text
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.

java
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:

text
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:

text
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

java
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:

text
EJB:
  @TransactionAttribute(TransactionAttributeType.REQUIRES_NEW)

Jakarta Transactions:
  @Transactional(Transactional.TxType.REQUIRES_NEW)

#Payment Worker ohne lange Datenbanktransaktion

java
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:

text
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:

text
PaymentRequestedJob
  ruft
OutboxTransactionService
  mit @Transactional Methoden

#OutboxTransactionService gegen Self-Invocation-Probleme

java
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:

java
@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:

text
Wenn Transaktionsgrenzen wichtig sind, rufe transaktionale Methoden über eine andere CDI/EJB Bean auf.

#Jakarta Messaging Publisher

Port:

java
package com.example.order.ports;

public interface EventPublisher {
    void publishOrderPaid(String payload);
}

Jakarta Messaging Adapter:

java
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:

text
Alt:
  ConnectionFactory manuell öffnen
  Connection manuell schließen
  Session manuell verwalten

Neu:
  JMSContext vom Container injizieren lassen

#OrderPaid Outbox Publisher Job

java
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:

text
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.

java
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:

text
- JMS Message empfangen
- Payload mappen
- Application Service aufrufen
- Retry-Entscheidung grob trennen

Sie enthält nicht:

text
- Rechnungslogik
- JPA Queries
- SOAP Calls
- Statusregeln

#Idempotenter Message Application Service

java
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:

text
- 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:

text
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:

text
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:

java
package com.example.order.ports;

public interface PaymentGateway {
    PaymentResult charge(PaymentCommand command);
}

SOAP Adapter:

java
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?

text
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:

java
import javax.ejb.Stateless;
import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
import javax.jms.Message;
import javax.ws.rs.Path;

Neue Imports:

java
import jakarta.ejb.Stateless;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import jakarta.jms.Message;
import jakarta.ws.rs.Path;

Aber:

text
Nicht blind global ersetzen.

Prüfliste:

text
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:

text
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:

text
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:

markdown
| 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:

text
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

markdown
| 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:

text
- 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:

text
- 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:

text
- Build nicht reproduzierbar ist
- Transaktionen unbekannt sind
- keine Tests existieren
- SOAP/JMS/DB-Flows nicht dokumentiert sind

Dann gilt weiter:

text
Erst verstehen.
Dann absichern.
Dann kapseln.
Dann Zielplattform wählen.
⌂ Cockpit