#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:
EJB-Monolith unverändert nach Quarkus kopieren
Das Ziel ist:
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:
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:
- 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:
- 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:
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:
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:
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:
<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:
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:
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:
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:
package com.company.order.api;
import java.math.BigDecimal;
public class CreateOrderHttpRequest {
public String customerId;
public BigDecimal amount;
}
Response DTO:
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:
HTTP DTO
↓
CreateOrderCommand
↓
CreateOrderUseCase
#Application Service als Transaktionsgrenze
In Quarkus kannst du jakarta.transaction.Transactional nutzen:
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:
@Stateless + @TransactionAttribute(REQUIRED)
↓
@ApplicationScoped + @Transactional
Aber Achtung:
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:
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:
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:
@ApplicationScoped
public class CreateOrderUseCase {
...
}
Meine Empfehlung für Lern- und Modernisierungszwecke:
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:
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:
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:
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:
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:
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:
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:
public interface PaymentGateway {
PaymentResult charge(PaymentCommand command);
}
REST-Adapter-Beispiel:
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:
@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:
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:
@Singleton + @Schedule
In Quarkus:
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:
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.
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:
Transaktionale Methoden müssen über den CDI Proxy aufgerufen werden.
Self-Invocation vermeiden.
Schlecht:
@ApplicationScoped
public class PaymentService {
public void process() {
markPaid(); // interner Aufruf, Proxy kann umgangen werden
}
@Transactional(REQUIRES_NEW)
void markPaid() {
...
}
}
Besser:
PaymentProcessingApplicationService
↓
PaymentTransactionService.markPaid()
Also: eigene Bean für Transaktionsabschnitte.
#Outbox Publisher in Quarkus
Scheduler:
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:
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:
OutboxPublishingService
↓
OutboxPublishTransactionService.publishOne(eventId)
Das entspricht dem Muster aus Spring und Jakarta EE.
#Messaging in Quarkus
Es gibt mehrere Wege:
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:
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:
public interface OrderPaidPublisher {
void publish(String payload);
}
Reactive-Messaging-Variante als Konzept:
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:
mp.messaging.outgoing.order-paid-out.connector=smallrye-amqp
mp.messaging.outgoing.order-paid-out.address=OrderPaidQueue
Consumer:
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:
mp.messaging.incoming.order-paid-in.connector=smallrye-amqp
mp.messaging.incoming.order-paid-in.address=OrderPaidQueue
#Idempotenter Consumer in Quarkus
Application Service:
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:
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:
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:
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:
- 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:
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:
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?
- 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:
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:
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:
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
- domain/* übernehmen
- ports/* übernehmen
- usecase/* übernehmen
- Plain-Java-Tests übernehmen
Akzeptanz:
Alle Kern-Tests laufen ohne Quarkus-spezifische Infrastruktur.
#Schritt 2: Quarkus Persistence Adapter bauen
- OrderEntity
- JpaOrderRepositoryAdapter
- OutboxEventEntity
- JpaOutboxAdapter
- ProcessedMessageEntity
- InvoiceEntity
Akzeptanz:
Repository-Integrationstests laufen gegen Testcontainer oder Dev Services.
#Schritt 3: REST Entry Point bauen
- OrderResource
- Request/Response DTOs
- Fehler-Mapping
- Validation
Akzeptanz:
POST /orders erzeugt Order mit PAYMENT_PENDING
und speichert PaymentRequested Outbox Event.
#Schritt 4: Payment Worker bauen
- PaymentRequestedScheduler
- PaymentProcessingApplicationService
- PaymentTransactionService
- PaymentGateway Adapter
Akzeptanz:
Payment success → Order PAID + OrderPaid Outbox Event.
Payment failure → Order PAYMENT_FAILED.
Payment timeout → Order PAYMENT_UNKNOWN.
#Schritt 5: Outbox Publisher bauen
- OrderPaidOutboxScheduler
- OrderPaidPublisher
- Retry/Dead Status
- Metriken
Akzeptanz:
OrderPaid Outbox Event wird an Broker/Queue publiziert.
Doppelte Publikation schadet nicht.
#Schritt 6: Consumer bauen
- OrderPaidConsumer
- InvoiceMessageApplicationService
- ProcessedMessageRepository
- Idempotenztest
Akzeptanz:
Doppelte OrderPaid Message erzeugt nur eine Rechnung.
#Schritt 7: Shadow Mode
Legacy Flow läuft produktiv.
Quarkus Flow verarbeitet kopierte oder gespiegelte Requests.
Ergebnisse werden verglichen.
Vergleich:
- Statusübergänge
- DB Writes
- Audits
- Events
- Fehlerfälle
- Performance
#Schritt 8: Teil-Cutover
Nur ein kleiner Anteil oder ein interner Mandant läuft über Quarkus.
Rollback bleibt möglich.
#Schritt 9: Vollständiger Cutover
Quarkus übernimmt Entry Point.
Legacy bleibt zunächst lesend oder als Fallback verfügbar.
Nach stabiler Laufzeit wird Legacy-Code entfernt.
#Schritt 10: Cleanup
- alte EJB-Fassaden entfernen
- JSP Entry Point abschalten
- alte JMS Producer entfernen
- JNDI-Konfiguration löschen
- Deployment-Pipeline vereinfachen
Merksatz:
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