#Zielplattform Quarkus und Plattformvergleich

Quarkus-Implementierung und Vergleich zwischen Spring Boot, Jakarta EE und Quarkus.


#Quarkus-Zielimplementierung und Plattformvergleich

Dieser Abschnitt zeigt, wie der bereits modernisierte Kern aus Complex Slice 001 nach Quarkus migriert werden kann. Der wichtige Punkt bleibt unverändert: Der fachliche Kern wurde vorher als Plain Java strukturiert. Quarkus wird nicht benutzt, um den Legacy-Code magisch zu reparieren. Quarkus wird benutzt, um einen bereits geschnittenen Kern cloud-nativer, schneller startend und besser containerisierbar zu betreiben.

Quarkus ist besonders interessant, wenn das Ziel nicht einfach „neuer Application Server“ ist, sondern ein schlanker Service mit CDI, Jakarta REST, Hibernate ORM, Scheduler, Messaging, MicroProfile-Konzepten, Container-Deployment und optional nativer Kompilierung.


#Ziel der Quarkus-Migration

Das Ziel ist nicht:

text
EJB-Monolith unverändert nach Quarkus kopieren

Das Ziel ist:

text
Legacy Entry Points und Container-Magie entfernen
        ↓
Plain-Java-Use-Cases behalten
        ↓
Quarkus als Runtime für REST, CDI, Persistence, Scheduler und Messaging verwenden
        ↓
Outbox, Payment Worker und idempotente Consumer sauber betreiben

Für unseren Order-Payment-Slice bedeutet das:

text
Vorher:
EAR / EJB / JSP / SOAP / JMS / JTA

Zwischenstand:
Legacy EJB Fassade
Plain Java Use Cases
Ports und Adapter
Outbox Pattern
idempotenter Invoice Consumer

Quarkus-Ziel:
Quarkus REST Resource
CDI Application Services
Jakarta Transactions
Hibernate ORM / Panache oder klassisches Repository
Quarkus Scheduler
Quarkus Messaging oder JMS Adapter
Quarkus CXF / REST Client für externe Kommunikation

Das Entscheidende: Die Klassen wie CreateOrderUseCase, PaymentRequestedHandler, OrderPaidMessageHandler, Order, OrderRepository, PaymentGateway, OutboxPort bleiben fachlich fast unverändert.


#Wann Quarkus sinnvoll ist

Quarkus ist eine gute Zielplattform, wenn diese Punkte wichtig sind:

text
- Kubernetes oder Container sind Zielumgebung
- schnelle Startzeit ist relevant
- geringer Memory Footprint ist wichtig
- Services sollen kleiner und unabhängiger deployed werden
- CDI/Jakarta-Programmiermodell ist willkommen
- REST, Messaging, Scheduler und Persistence sollen in einer schlanken Runtime laufen
- du willst weg vom klassischen Application Server

Quarkus ist nicht automatisch der beste Weg, wenn:

text
- sehr viele Remote EJB Clients existieren
- das System massiv auf Stateful EJBs basiert
- XA/JMS/EJB-Semantik sehr stark und schwer testbar ist
- das Team bereits tief in Spring Boot investiert ist
- SOAP/WS-Security extrem komplex ist und auf App-Server-spezifischen Features basiert
- ein einfacher Jakarta-EE-Server-Upgrade weniger Risiko hat

Faustregel:

text
Wenn du den fachlichen Kern schon sauber extrahiert hast,
ist Quarkus eine sehr gute Zielplattform.

Wenn du noch mitten in EJB/JNDI/JTA/JSP-Magie steckst,
ist Quarkus zu früh.

#Quarkus-Zielstruktur

Eine mögliche Zielstruktur:

text
legacy-modernized-quarkus/
  pom.xml
  src/
    main/
      java/
        com/company/order/
          OrderApplication.java

          api/
            OrderResource.java
            CreateOrderHttpRequest.java
            CreateOrderHttpResponse.java

          application/
            CreateOrderApplicationService.java
            PaymentProcessingApplicationService.java
            OutboxPublishingService.java
            InvoiceMessageApplicationService.java

          domain/
            Order.java
            OrderId.java
            OrderStatus.java
            Money.java

          ports/
            OrderRepository.java
            PaymentGateway.java
            AuditPort.java
            OutboxPort.java
            InvoiceRepository.java
            ProcessedMessageRepository.java

          adapters/
            persistence/
              OrderEntity.java
              OrderJpaRepository.java
              JpaOrderRepositoryAdapter.java
              OutboxEventEntity.java
              JpaOutboxAdapter.java

            payment/
              QuarkusSoapPaymentGateway.java
              QuarkusRestPaymentGateway.java

            messaging/
              OrderPaidPublisher.java
              OrderPaidConsumer.java

            audit/
              JpaAuditAdapter.java

            scheduler/
              PaymentRequestedScheduler.java
              OrderPaidOutboxScheduler.java

      resources/
        application.properties
        import.sql

    test/
      java/
        com/company/order/
          CreateOrderUseCaseTest.java
          PaymentRequestedHandlerTest.java
          OrderResourceTest.java
          OutboxPublisherTest.java

Die Struktur entspricht weiterhin der Trennung:

text
api          = Inbound Adapter
application  = Transaktions- und Use-Case-Orchestrierung
 domain      = Fachmodell
ports        = Schnittstellen nach außen
adapters     = technische Implementierungen

#Maven-Grundstruktur für Quarkus

Beispiel-pom.xml:

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.company</groupId>
    <artifactId>legacy-modernized-quarkus</artifactId>
    <version>1.0.0-SNAPSHOT</version>

    <properties>
        <maven.compiler.release>17</maven.compiler.release>
        <quarkus.platform.group-id>io.quarkus.platform</quarkus.platform.group-id>
        <quarkus.platform.artifact-id>quarkus-bom</quarkus.platform.artifact-id>
        <quarkus.platform.version>3.x.x</quarkus.platform.version>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>${quarkus.platform.group-id}</groupId>
                <artifactId>${quarkus.platform.artifact-id}</artifactId>
                <version>${quarkus.platform.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <dependencies>
        <dependency>
            <groupId>io.quarkus</groupId>
            <artifactId>quarkus-rest</artifactId>
        </dependency>

        <dependency>
            <groupId>io.quarkus</groupId>
            <artifactId>quarkus-jackson</artifactId>
        </dependency>

        <dependency>
            <groupId>io.quarkus</groupId>
            <artifactId>quarkus-hibernate-orm-panache</artifactId>
        </dependency>

        <dependency>
            <groupId>io.quarkus</groupId>
            <artifactId>quarkus-jdbc-postgresql</artifactId>
        </dependency>

        <dependency>
            <groupId>io.quarkus</groupId>
            <artifactId>quarkus-narayana-jta</artifactId>
        </dependency>

        <dependency>
            <groupId>io.quarkus</groupId>
            <artifactId>quarkus-scheduler</artifactId>
        </dependency>

        <dependency>
            <groupId>io.quarkus</groupId>
            <artifactId>quarkus-smallrye-fault-tolerance</artifactId>
        </dependency>

        <dependency>
            <groupId>io.quarkus</groupId>
            <artifactId>quarkus-smallrye-health</artifactId>
        </dependency>

        <dependency>
            <groupId>io.quarkus</groupId>
            <artifactId>quarkus-micrometer</artifactId>
        </dependency>

        <dependency>
            <groupId>io.quarkus</groupId>
            <artifactId>quarkus-junit5</artifactId>
            <scope>test</scope>
        </dependency>

        <dependency>
            <groupId>io.rest-assured</groupId>
            <artifactId>rest-assured</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>${quarkus.platform.group-id}</groupId>
                <artifactId>quarkus-maven-plugin</artifactId>
                <version>${quarkus.platform.version}</version>
                <extensions>true</extensions>
            </plugin>
        </plugins>
    </build>
</project>

Hinweis: Bei echten Projekten versionierst du die Quarkus-Version bewusst und aktualisierst sie nicht blind. Die Migration sollte reproduzierbar sein.


#application.properties

Beispiel-Konfiguration:

properties
quarkus.application.name=legacy-modernized-order
quarkus.http.port=8080

quarkus.datasource.db-kind=postgresql
quarkus.datasource.username=order_app
quarkus.datasource.password=secret
quarkus.datasource.jdbc.url=jdbc:postgresql://localhost:5432/orderdb

quarkus.hibernate-orm.database.generation=validate
quarkus.hibernate-orm.log.sql=false

order.payment.scheduler.enabled=true
order.payment.scheduler.every=30s
order.outbox.publisher.every=20s

payment.provider.url=https://payment.example.internal/ws
payment.provider.timeout=10s

quarkus.log.category."com.company.order".level=INFO

Wichtiger Unterschied zur Legacy-Welt:

text
Alt:
JNDI Datasource im Application Server
Queue im Admin Console Setup
Properties in Deployment Deskriptoren

Quarkus:
application.properties / Env Vars / Config Maps / Secrets

Du ersetzt also nicht nur APIs, sondern auch Betriebsmodell und Konfiguration.


#REST Resource statt JSP/SOAP Entry Point

Quarkus REST Resource:

java
package com.company.order.api;

import com.company.order.application.CreateOrderApplicationService;
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) {
        String orderId = createOrderApplicationService.createOrder(request);

        return Response
                .accepted(new CreateOrderHttpResponse(orderId, "PAYMENT_PENDING"))
                .build();
    }
}

Request DTO:

java
package com.company.order.api;

import java.math.BigDecimal;

public class CreateOrderHttpRequest {
    public String customerId;
    public BigDecimal amount;
}

Response DTO:

java
package com.company.order.api;

public class CreateOrderHttpResponse {
    public String orderId;
    public String status;

    public CreateOrderHttpResponse(String orderId, String status) {
        this.orderId = orderId;
        this.status = status;
    }
}

Der alte JSP/SOAP-Request wird nicht direkt in den Use Case geschoben. Du mappst weiterhin:

text
HTTP DTO
  ↓
CreateOrderCommand
  ↓
CreateOrderUseCase

#Application Service als Transaktionsgrenze

In Quarkus kannst du jakarta.transaction.Transactional nutzen:

java
package com.company.order.application;

import com.company.order.api.CreateOrderHttpRequest;
import com.company.order.domain.OrderId;
import com.company.order.usecase.CreateOrderCommand;
import com.company.order.usecase.CreateOrderUseCase;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;

@ApplicationScoped
public class CreateOrderApplicationService {

    @Inject
    CreateOrderUseCase createOrderUseCase;

    @Transactional
    public String createOrder(CreateOrderHttpRequest request) {
        OrderId orderId = createOrderUseCase.execute(
                new CreateOrderCommand(request.customerId, request.amount)
        );

        return orderId.value();
    }
}

Mapping von EJB:

text
@Stateless + @TransactionAttribute(REQUIRED)
        ↓
@ApplicationScoped + @Transactional

Aber Achtung:

text
Nicht blind übertragen.
Erst prüfen:
- REQUIRES_NEW
- NOT_SUPPORTED
- checked Exceptions
- Rollback-Regeln
- Self-Invocation
- Security Context

#Plain-Java-Use-Case als CDI Bean

Du kannst den Use Case entweder komplett frameworkfrei halten und manuell produzieren oder ihn als CDI Bean annotieren.

Variante A: Use Case bleibt komplett sauber:

java
public class CreateOrderUseCase {
    private final OrderRepository orderRepository;
    private final AuditPort auditPort;
    private final OutboxPort outboxPort;

    public CreateOrderUseCase(
            OrderRepository orderRepository,
            AuditPort auditPort,
            OutboxPort outboxPort
    ) {
        this.orderRepository = orderRepository;
        this.auditPort = auditPort;
        this.outboxPort = outboxPort;
    }

    public OrderId execute(CreateOrderCommand command) {
        Order order = Order.create(command.customerId(), command.amount());
        orderRepository.save(order);
        auditPort.orderCreated(order.id());
        outboxPort.store(OutboxEvent.paymentRequested(order));
        return order.id();
    }
}

CDI Producer:

java
package com.company.order.config;

import com.company.order.ports.AuditPort;
import com.company.order.ports.OrderRepository;
import com.company.order.ports.OutboxPort;
import com.company.order.usecase.CreateOrderUseCase;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.inject.Produces;
import jakarta.inject.Inject;

@ApplicationScoped
public class UseCaseProducer {

    @Inject
    OrderRepository orderRepository;

    @Inject
    AuditPort auditPort;

    @Inject
    OutboxPort outboxPort;

    @Produces
    @ApplicationScoped
    CreateOrderUseCase createOrderUseCase() {
        return new CreateOrderUseCase(orderRepository, auditPort, outboxPort);
    }
}

Variante B: Use Case direkt als CDI Bean:

java
@ApplicationScoped
public class CreateOrderUseCase {
    ...
}

Meine Empfehlung für Lern- und Modernisierungszwecke:

text
Use Cases zuerst frameworkfrei halten.
CDI nur an den Rändern verwenden.

#Persistence Adapter mit Hibernate ORM / Panache

Quarkus kann klassisches Jakarta Persistence oder Panache verwenden. Für Legacy-Migrationen ist klassisches Repository-Mapping oft klarer, weil du die vorhandenen Entity-Klassen kontrolliert übernehmen kannst.

Entity:

java
package com.company.order.adapters.persistence;

import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
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 extends PanacheEntityBase {

    @Id
    public String id;

    @Column(name = "customer_id", nullable = false)
    public String customerId;

    @Column(name = "amount", nullable = false)
    public BigDecimal amount;

    @Column(name = "status", nullable = false)
    public String status;
}

Port-Adapter:

java
package com.company.order.adapters.persistence;

import com.company.order.domain.Order;
import com.company.order.domain.OrderId;
import com.company.order.ports.OrderRepository;
import jakarta.enterprise.context.ApplicationScoped;
import java.util.Optional;

@ApplicationScoped
public class JpaOrderRepositoryAdapter implements OrderRepository {

    @Override
    public void save(Order order) {
        OrderEntity entity = OrderEntityMapper.toEntity(order);
        entity.persist();
    }

    @Override
    public void update(Order order) {
        OrderEntity entity = OrderEntity.findById(order.id().value());

        if (entity == null) {
            throw new IllegalStateException("Order not found: " + order.id().value());
        }

        entity.customerId = order.customerId();
        entity.amount = order.amount();
        entity.status = order.status().name();
    }

    @Override
    public Optional<Order> findById(OrderId orderId) {
        OrderEntity entity = OrderEntity.findById(orderId.value());

        if (entity == null) {
            return Optional.empty();
        }

        return Optional.of(OrderEntityMapper.toDomain(entity));
    }
}

Mapper:

java
package com.company.order.adapters.persistence;

import com.company.order.domain.Order;
import com.company.order.domain.OrderId;
import com.company.order.domain.OrderStatus;

public final class OrderEntityMapper {

    private OrderEntityMapper() {
    }

    public static OrderEntity toEntity(Order order) {
        OrderEntity entity = new OrderEntity();
        entity.id = order.id().value();
        entity.customerId = order.customerId();
        entity.amount = order.amount();
        entity.status = order.status().name();
        return entity;
    }

    public static Order toDomain(OrderEntity entity) {
        return Order.restore(
                OrderId.of(entity.id),
                entity.customerId,
                entity.amount,
                OrderStatus.valueOf(entity.status)
        );
    }
}

#Outbox Adapter in Quarkus

Entity:

java
package com.company.order.adapters.persistence;

import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Table;
import java.time.Instant;
import java.util.List;

@Entity
@Table(name = "outbox_event")
public class OutboxEventEntity extends PanacheEntityBase {

    @Id
    public String id;

    @Column(name = "aggregate_id", nullable = false)
    public String aggregateId;

    @Column(name = "aggregate_type", nullable = false)
    public String aggregateType;

    @Column(name = "event_type", nullable = false)
    public String eventType;

    @Lob
    @Column(name = "payload", nullable = false)
    public String payload;

    @Column(name = "status", nullable = false)
    public String status;

    @Column(name = "created_at", nullable = false)
    public Instant createdAt;

    @Column(name = "published_at")
    public Instant publishedAt;

    @Column(name = "retry_count", nullable = false)
    public int retryCount;

    @Lob
    @Column(name = "last_error")
    public String lastError;

    public static List<OutboxEventEntity> findPending(String eventType, int limit) {
        return find("status = ?1 and eventType = ?2 order by createdAt", "PENDING", eventType)
                .page(0, limit)
                .list();
    }
}

Adapter:

java
package com.company.order.adapters.persistence;

import com.company.order.outbox.OutboxEvent;
import com.company.order.ports.OutboxPort;
import jakarta.enterprise.context.ApplicationScoped;

@ApplicationScoped
public class JpaOutboxAdapter implements OutboxPort {

    @Override
    public void store(OutboxEvent event) {
        OutboxEventEntity entity = new OutboxEventEntity();
        entity.id = event.id();
        entity.aggregateId = event.aggregateId();
        entity.aggregateType = event.aggregateType();
        entity.eventType = event.eventType();
        entity.payload = event.payload();
        entity.status = "PENDING";
        entity.createdAt = event.createdAt();
        entity.retryCount = 0;
        entity.persist();
    }
}

Wichtig:

text
OutboxEvent wird innerhalb derselben @Transactional-Methode persistiert
wie die fachliche Änderung.

#Payment Gateway in Quarkus

Für neue HTTP-basierte Provider kannst du einen REST Client verwenden. Für echte SOAP-Legacy-Provider kannst du einen CXF-basierten Adapter oder einen bestehenden generierten SOAP Client hinter dem Port behalten.

Port bleibt gleich:

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

REST-Adapter-Beispiel:

java
package com.company.order.adapters.payment;

import com.company.order.ports.PaymentGateway;
import com.company.order.payment.PaymentCommand;
import com.company.order.payment.PaymentResult;
import jakarta.enterprise.context.ApplicationScoped;
import org.eclipse.microprofile.faulttolerance.CircuitBreaker;
import org.eclipse.microprofile.faulttolerance.Retry;
import org.eclipse.microprofile.faulttolerance.Timeout;

import java.time.temporal.ChronoUnit;

@ApplicationScoped
public class QuarkusRestPaymentGateway implements PaymentGateway {

    private final PaymentProviderClient client;

    public QuarkusRestPaymentGateway(PaymentProviderClient client) {
        this.client = client;
    }

    @Override
    @Timeout(value = 10, unit = ChronoUnit.SECONDS)
    @Retry(maxRetries = 2)
    @CircuitBreaker(requestVolumeThreshold = 10, failureRatio = 0.5, delay = 30, delayUnit = ChronoUnit.SECONDS)
    public PaymentResult charge(PaymentCommand command) {
        PaymentProviderResponse response = client.charge(
                new PaymentProviderRequest(
                        command.idempotencyKey(),
                        command.customerId(),
                        command.amount()
                )
        );

        if (response.successful()) {
            return PaymentResult.success(response.providerReference());
        }

        return PaymentResult.failure();
    }
}

SOAP-Adapter-Beispiel als Konzept:

java
@ApplicationScoped
public class QuarkusSoapPaymentGateway implements PaymentGateway {

    private final LegacyPaymentPort soapPort;

    public QuarkusSoapPaymentGateway(LegacyPaymentPort soapPort) {
        this.soapPort = soapPort;
    }

    @Override
    public PaymentResult charge(PaymentCommand command) {
        try {
            LegacyPaymentResponse response = soapPort.charge(
                    command.idempotencyKey(),
                    command.customerId(),
                    command.amount()
            );

            if (response.isSuccessful()) {
                return PaymentResult.success(response.getProviderReference());
            }

            return PaymentResult.failure();
        } catch (Exception e) {
            throw new PaymentProviderException("SOAP payment failed", e);
        }
    }
}

Wichtiger Punkt:

text
Auch in Quarkus darf Payment nicht wieder direkt im CreateOrder-Use-Case
innerhalb einer langen DB-Transaktion landen.

#PaymentRequested Scheduler in Quarkus

In EJB hattest du:

text
@Singleton + @Schedule

In Quarkus:

java
package com.company.order.adapters.scheduler;

import com.company.order.application.PaymentProcessingApplicationService;
import io.quarkus.scheduler.Scheduled;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;

@ApplicationScoped
public class PaymentRequestedScheduler {

    @Inject
    PaymentProcessingApplicationService paymentProcessingApplicationService;

    @Scheduled(every = "{order.payment.scheduler.every}", identity = "payment-requested-scheduler")
    void processPaymentRequestedEvents() {
        paymentProcessingApplicationService.processPendingPaymentRequests();
    }
}

Application Service:

java
package com.company.order.application;

import com.company.order.adapters.persistence.OutboxEventEntity;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import java.util.List;

@ApplicationScoped
public class PaymentProcessingApplicationService {

    @Inject
    PaymentTransactionService paymentTransactionService;

    @Inject
    PaymentCallService paymentCallService;

    public void processPendingPaymentRequests() {
        List<OutboxEventEntity> events = OutboxEventEntity.findPending("PaymentRequested", 20);

        for (OutboxEventEntity event : events) {
            processOne(event);
        }
    }

    void processOne(OutboxEventEntity event) {
        paymentTransactionService.markPaymentInProgress(event.aggregateId);

        PaymentCallResult result = paymentCallService.callProvider(event.payload);

        if (result.successful()) {
            paymentTransactionService.markPaymentPaid(event.aggregateId, result.providerReference());
        } else if (result.unknown()) {
            paymentTransactionService.markPaymentUnknown(event.aggregateId, result.reason());
        } else {
            paymentTransactionService.markPaymentFailed(event.aggregateId);
        }

        paymentTransactionService.markOutboxEventPublished(event.id);
    }
}

#Transaktionsgrenzen in Quarkus

Quarkus nutzt jakarta.transaction.Transactional. Du kannst Propagation über Transactional.TxType steuern.

java
package com.company.order.application;

import jakarta.enterprise.context.ApplicationScoped;
import jakarta.transaction.Transactional;

@ApplicationScoped
public class PaymentTransactionService {

    @Transactional(Transactional.TxType.REQUIRES_NEW)
    public void markPaymentInProgress(String orderId) {
        ...
    }

    @Transactional(Transactional.TxType.REQUIRES_NEW)
    public void markPaymentPaid(String orderId, String providerReference) {
        ...
    }

    @Transactional(Transactional.TxType.REQUIRES_NEW)
    public void markPaymentFailed(String orderId) {
        ...
    }

    @Transactional(Transactional.TxType.REQUIRES_NEW)
    public void markPaymentUnknown(String orderId, String reason) {
        ...
    }

    @Transactional(Transactional.TxType.REQUIRES_NEW)
    public void markOutboxEventPublished(String eventId) {
        ...
    }
}

Wichtige Regel:

text
Transaktionale Methoden müssen über den CDI Proxy aufgerufen werden.
Self-Invocation vermeiden.

Schlecht:

java
@ApplicationScoped
public class PaymentService {

    public void process() {
        markPaid(); // interner Aufruf, Proxy kann umgangen werden
    }

    @Transactional(REQUIRES_NEW)
    void markPaid() {
        ...
    }
}

Besser:

text
PaymentProcessingApplicationService
        ↓
PaymentTransactionService.markPaid()

Also: eigene Bean für Transaktionsabschnitte.


#Outbox Publisher in Quarkus

Scheduler:

java
package com.company.order.adapters.scheduler;

import com.company.order.application.OutboxPublishingService;
import io.quarkus.scheduler.Scheduled;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;

@ApplicationScoped
public class OrderPaidOutboxScheduler {

    @Inject
    OutboxPublishingService outboxPublishingService;

    @Scheduled(every = "{order.outbox.publisher.every}", identity = "order-paid-outbox-publisher")
    void publishOrderPaidEvents() {
        outboxPublishingService.publishPendingOrderPaidEvents();
    }
}

Publishing Service:

java
package com.company.order.application;

import com.company.order.adapters.persistence.OutboxEventEntity;
import com.company.order.ports.OrderPaidPublisher;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import java.time.Instant;
import java.util.List;

@ApplicationScoped
public class OutboxPublishingService {

    @Inject
    OrderPaidPublisher orderPaidPublisher;

    public void publishPendingOrderPaidEvents() {
        List<OutboxEventEntity> events = OutboxEventEntity.findPending("OrderPaid", 50);

        for (OutboxEventEntity event : events) {
            publishOne(event);
        }
    }

    @Transactional(Transactional.TxType.REQUIRES_NEW)
    void publishOne(OutboxEventEntity event) {
        try {
            orderPaidPublisher.publish(event.payload);

            event.status = "PUBLISHED";
            event.publishedAt = Instant.now();
        } catch (Exception e) {
            event.retryCount = event.retryCount + 1;
            event.lastError = e.getMessage();

            if (event.retryCount >= 10) {
                event.status = "DEAD";
            }
        }
    }
}

Auch hier gilt: In einem echten System kann publishOne() als interne Methode problematisch sein, wenn du sicher REQUIRES_NEW brauchst. Besser ist eine separate Bean:

text
OutboxPublishingService
        ↓
OutboxPublishTransactionService.publishOne(eventId)

Das entspricht dem Muster aus Spring und Jakarta EE.


#Messaging in Quarkus

Es gibt mehrere Wege:

text
1. Quarkus JMS Extension, wenn echte JMS-Kompatibilität gebraucht wird
2. SmallRye Reactive Messaging mit AMQP/Kafka/RabbitMQ
3. Camel Quarkus, wenn Integration Routes wichtig sind
4. HTTP Callback / REST, wenn Messaging fachlich ersetzt werden soll

Für Legacy-JMS-Migration gilt:

text
Wenn alte Queues und Broker bleiben müssen:
Starte mit JMS-kompatiblem Adapter.

Wenn du ohnehin Plattformwechsel machst:
Prüfe AMQP/Kafka/RabbitMQ über Reactive Messaging.

Port:

java
public interface OrderPaidPublisher {
    void publish(String payload);
}

Reactive-Messaging-Variante als Konzept:

java
package com.company.order.adapters.messaging;

import com.company.order.ports.OrderPaidPublisher;
import io.smallrye.reactive.messaging.MutinyEmitter;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import org.eclipse.microprofile.reactive.messaging.Channel;

@ApplicationScoped
public class ReactiveOrderPaidPublisher implements OrderPaidPublisher {

    @Inject
    @Channel("order-paid-out")
    MutinyEmitter<String> emitter;

    @Override
    public void publish(String payload) {
        emitter.sendAndAwait(payload);
    }
}

application.properties:

properties
mp.messaging.outgoing.order-paid-out.connector=smallrye-amqp
mp.messaging.outgoing.order-paid-out.address=OrderPaidQueue

Consumer:

java
package com.company.order.adapters.messaging;

import com.company.order.application.InvoiceMessageApplicationService;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import org.eclipse.microprofile.reactive.messaging.Incoming;

@ApplicationScoped
public class OrderPaidConsumer {

    @Inject
    InvoiceMessageApplicationService invoiceMessageApplicationService;

    @Incoming("order-paid-in")
    public void consume(String payload) {
        invoiceMessageApplicationService.handleOrderPaid(payload);
    }
}

Konfiguration:

properties
mp.messaging.incoming.order-paid-in.connector=smallrye-amqp
mp.messaging.incoming.order-paid-in.address=OrderPaidQueue

#Idempotenter Consumer in Quarkus

Application Service:

java
package com.company.order.application;

import com.company.order.invoice.OrderPaidMessage;
import com.company.order.invoice.OrderPaidMessageParser;
import com.company.order.usecase.OrderPaidMessageHandler;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;

@ApplicationScoped
public class InvoiceMessageApplicationService {

    @Inject
    OrderPaidMessageHandler handler;

    @Transactional
    public void handleOrderPaid(String payload) {
        OrderPaidMessage message = OrderPaidMessageParser.parse(payload);
        handler.handle(message);
    }
}

Handler bleibt fachlich:

java
public class OrderPaidMessageHandler {

    private final ProcessedMessageRepository processedMessageRepository;
    private final InvoiceRepository invoiceRepository;
    private final CreateInvoiceUseCase createInvoiceUseCase;

    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 Ziel ist nicht „Quarkus Consumer“, sondern:

text
idempotenter Consumer,
egal ob MDB, Spring @JmsListener, Jakarta Messaging oder Quarkus Reactive Messaging.

#Health Checks und Monitoring

Outbox und Payment brauchen Betriebsfähigkeit.

Health Check:

java
package com.company.order.health;

import com.company.order.adapters.persistence.OutboxEventEntity;
import jakarta.enterprise.context.ApplicationScoped;
import org.eclipse.microprofile.health.HealthCheck;
import org.eclipse.microprofile.health.HealthCheckResponse;
import org.eclipse.microprofile.health.Readiness;

@Readiness
@ApplicationScoped
public class OutboxReadinessCheck implements HealthCheck {

    @Override
    public HealthCheckResponse call() {
        long deadCount = OutboxEventEntity.count("status", "DEAD");

        if (deadCount > 0) {
            return HealthCheckResponse
                    .named("outbox")
                    .down()
                    .withData("deadEvents", deadCount)
                    .build();
        }

        return HealthCheckResponse
                .named("outbox")
                .up()
                .build();
    }
}

Wichtige Metriken:

text
- outbox.pending.count
- outbox.dead.count
- outbox.oldest.pending.age
- payment.unknown.count
- payment.success.count
- payment.failure.count
- invoice.duplicates.ignored.count
- message.processing.duration

Bei Outbox gilt:

text
Ohne Monitoring ist Outbox nur eine versteckte Queue in der Datenbank.

#Native Image und Legacy-Grenzen

Quarkus kann JVM-Mode und Native-Mode. Für Legacy-Migrationen solltest du nicht sofort Native Image als Ziel erzwingen.

Gute Reihenfolge:

text
1. Erst JVM Mode stabil machen
2. Tests und Observability aufbauen
3. Reflection/JAXB/SOAP-Probleme identifizieren
4. Dann Native Image prüfen

Warum vorsichtig?

text
- JAXB / SOAP / Reflection können zusätzliche Konfiguration brauchen
- alte Libraries sind eventuell nicht native-image-freundlich
- dynamische Classloading-Muster können brechen
- Performance-Profil ist anders

Empfehlung:

text
Quarkus-Migration ≠ sofort Native Image.
Quarkus im JVM Mode ist bereits ein sinnvoller Modernisierungsschritt.

#Plattformvergleich Spring Boot vs Jakarta EE vs Quarkus

Kriterium Spring Boot Jakarta EE Quarkus
Einstieg für Spring-Team sehr gut mittel mittel
Nähe zu Java EE Legacy mittel sehr hoch hoch bei Jakarta APIs, niedriger bei EJB-Features
EJB-Ersatz Spring Services CDI/EJB/CDI Services CDI/ApplicationScoped Services
Transaktionen Spring @Transactional Jakarta Transactions / EJB CMT Jakarta Transactions / Narayana
REST Spring MVC/WebFlux Jakarta REST Jakarta REST / RESTEasy Reactive
Persistence Spring Data/JPA Jakarta Persistence Hibernate ORM/Panache/JPA
Messaging Spring JMS/Kafka/Rabbit Jakarta Messaging Reactive Messaging/JMS/Camel
Scheduler @Scheduled EJB Timer / Managed Scheduled Executor Quarkus Scheduler / Quartz
App-Server nötig nein meistens ja, außer Servlet/Jakarta-Runtime Varianten nein
Container/Kubernetes gut abhängig vom Server sehr stark
Native Image möglich, aber selektiv nicht typisch starkes Zielbild
Legacy SOAP möglich sehr gut bei klassischem Stack möglich, aber genau prüfen
Big-Bang-Risiko mittel niedrig bis mittel mittel bis hoch, wenn Kern nicht vorher getrennt wurde

Pragmatische Empfehlung:

text
Wenn du möglichst wenig Bruch willst:
Jakarta EE.

Wenn dein Team Spring kann und App-Server weg soll:
Spring Boot.

Wenn Cloud/Kubernetes, Startzeit und schlanke Services stark zählen:
Quarkus.

Für unser Complex Slice 001:

text
Phase 1: Plain Java Kern extrahieren
Phase 2: Outbox und Idempotenz einführen
Phase 3a: Spring Boot Adapter bauen
Phase 3b: Jakarta EE Adapter bauen
Phase 3c: Quarkus Adapter bauen
Phase 4: Plattform mit Tests und Betriebskriterien vergleichen

#Quarkus-Cutover-Plan für Complex Slice 001

Ein realistischer Cutover läuft nicht in einem Schritt.

#Schritt 1: Kern portieren

text
- domain/* übernehmen
- ports/* übernehmen
- usecase/* übernehmen
- Plain-Java-Tests übernehmen

Akzeptanz:

text
Alle Kern-Tests laufen ohne Quarkus-spezifische Infrastruktur.

#Schritt 2: Quarkus Persistence Adapter bauen

text
- OrderEntity
- JpaOrderRepositoryAdapter
- OutboxEventEntity
- JpaOutboxAdapter
- ProcessedMessageEntity
- InvoiceEntity

Akzeptanz:

text
Repository-Integrationstests laufen gegen Testcontainer oder Dev Services.

#Schritt 3: REST Entry Point bauen

text
- OrderResource
- Request/Response DTOs
- Fehler-Mapping
- Validation

Akzeptanz:

text
POST /orders erzeugt Order mit PAYMENT_PENDING
und speichert PaymentRequested Outbox Event.

#Schritt 4: Payment Worker bauen

text
- PaymentRequestedScheduler
- PaymentProcessingApplicationService
- PaymentTransactionService
- PaymentGateway Adapter

Akzeptanz:

text
Payment success → Order PAID + OrderPaid Outbox Event.
Payment failure → Order PAYMENT_FAILED.
Payment timeout → Order PAYMENT_UNKNOWN.

#Schritt 5: Outbox Publisher bauen

text
- OrderPaidOutboxScheduler
- OrderPaidPublisher
- Retry/Dead Status
- Metriken

Akzeptanz:

text
OrderPaid Outbox Event wird an Broker/Queue publiziert.
Doppelte Publikation schadet nicht.

#Schritt 6: Consumer bauen

text
- OrderPaidConsumer
- InvoiceMessageApplicationService
- ProcessedMessageRepository
- Idempotenztest

Akzeptanz:

text
Doppelte OrderPaid Message erzeugt nur eine Rechnung.

#Schritt 7: Shadow Mode

text
Legacy Flow läuft produktiv.
Quarkus Flow verarbeitet kopierte oder gespiegelte Requests.
Ergebnisse werden verglichen.

Vergleich:

text
- Statusübergänge
- DB Writes
- Audits
- Events
- Fehlerfälle
- Performance

#Schritt 8: Teil-Cutover

text
Nur ein kleiner Anteil oder ein interner Mandant läuft über Quarkus.
Rollback bleibt möglich.

#Schritt 9: Vollständiger Cutover

text
Quarkus übernimmt Entry Point.
Legacy bleibt zunächst lesend oder als Fallback verfügbar.
Nach stabiler Laufzeit wird Legacy-Code entfernt.

#Schritt 10: Cleanup

text
- alte EJB-Fassaden entfernen
- JSP Entry Point abschalten
- alte JMS Producer entfernen
- JNDI-Konfiguration löschen
- Deployment-Pipeline vereinfachen

Merksatz:

text
Quarkus ist nicht der erste Schritt der Modernisierung.
Quarkus ist ein guter Zielruntime-Schritt, nachdem du den Kern bereits modernisierbar gemacht hast.

#Referenzen für Quarkus-Zielimplementierung und Plattformvergleich

  • Quarkus Getting Started / JDK 17+ Voraussetzung: https://quarkus.io/guides/getting-started
  • Quarkus Java 17 Baseline ab Quarkus 3.7: https://quarkus.io/blog/java-17/
  • Quarkus Transactions Guide: https://quarkus.io/guides/transaction
  • Quarkus Scheduler Guide: https://quarkus.io/guides/scheduler-reference
  • Quarkus JMS Guide: https://quarkus.io/guides/jms
  • Quarkus Messaging Guide: https://quarkus.io/guides/messaging
  • Quarkus Hibernate ORM with Panache: https://quarkus.io/guides/hibernate-orm-panache
  • Quarkus CXF Extension: https://quarkus.io/extensions/io.quarkiverse.cxf/quarkus-cxf/
  • Quarkus Fault Tolerance Guide: https://quarkus.io/guides/smallrye-fault-tolerance
⌂ Cockpit