#Komplexer Durchstich: Order, Payment, Outbox und Invoice
Dieses Kapitel nutzt einen realistischen End-to-End-Schnitt durch ein Legacy-System: Bestellung, Zahlung, JMS, MDB und Rechnung.
#Complex Slice 001: Order + Payment + Invoice
Strukturierte Ausgabe: Bereinigte Kapitelstruktur für das Beispiel-Slice. Hauptkapitel sind gruppiert, Vorlagen und Runbooks sind als Unterkapitel markiert.
Dieses Beispiel simuliert ein komplexes altes Java-Enterprise-System mit:
- JSP als Web-Entry
- Servlet als Controller
- Stateless EJB als Application Service
- JPA / EntityManager
- JTA Container Managed Transactions
REQUIRES_NEWAudit-Service- SOAP Payment Client
- JMS Producer
- Message Driven Bean als Consumer
- Invoice-Erzeugung
- klassische Risiken: SOAP innerhalb Transaktion, JMS Side Effects, unklare Rollbacks, doppelte Messages
#Inhaltsverzeichnis
- Abschnitt I – Legacy-Ausgangslage und Risikoanalyse
- Abschnitt II – Erster Modernisierungsschnitt
- Abschnitt III – Plattformmigration und Mini-Repo
- Abschnitt IV – Qualität, Betrieb, Security und Datenbank
- Abschnitt V – Deployment, Roadmap und Abschluss
#Legacy-Ausgangslage
#Fachlicher Use Case
Ein Benutzer legt über eine JSP eine Bestellung an. Das System:
- validiert den Kunden,
- speichert Bestellung und Positionen,
- schreibt Audit-Log in eigener Transaktion,
- ruft synchron einen externen Payment-SOAP-Service auf,
- aktualisiert den Zahlungsstatus,
- sendet eine JMS-Message
OrderCreated, - eine MDB erzeugt später eine Rechnung.
#JSP Entry Point
<%@ page import="javax.naming.InitialContext" %>
<%@ page import="com.acme.order.ejb.OrderServiceRemote" %>
<%@ page import="com.acme.order.dto.CreateOrderRequest" %>
<%
String customerId = request.getParameter("customerId");
String sku = request.getParameter("sku");
int quantity = Integer.parseInt(request.getParameter("quantity"));
CreateOrderRequest req = new CreateOrderRequest();
req.setCustomerId(customerId);
req.setSku(sku);
req.setQuantity(quantity);
InitialContext ctx = new InitialContext();
OrderServiceRemote service = (OrderServiceRemote) ctx.lookup(
"java:global/legacy-ear/order-ejb/OrderServiceBean!com.acme.order.ejb.OrderServiceRemote"
);
String orderId = service.createOrder(req);
response.sendRedirect("order-success.jsp?id=" + orderId);
%>
#Probleme
- Businesslogik / Servicezugriff direkt in JSP
- JNDI Lookup in UI
- keine saubere Validierung
- keine Fehlerbehandlung
- UI kennt EJB Remote Interface
- schwer testbar
#Legacy EJB: OrderServiceBean
package com.acme.order.ejb;
import com.acme.audit.ejb.AuditServiceLocal;
import com.acme.customer.ejb.CustomerServiceLocal;
import com.acme.order.dto.CreateOrderRequest;
import com.acme.order.entity.OrderEntity;
import com.acme.order.entity.OrderItemEntity;
import com.acme.payment.soap.PaymentFault;
import com.acme.payment.soap.PaymentPort;
import com.acme.payment.soap.PaymentRequest;
import com.acme.payment.soap.PaymentResponse;
import javax.annotation.Resource;
import javax.ejb.EJB;
import javax.ejb.SessionContext;
import javax.ejb.Stateless;
import javax.ejb.TransactionAttribute;
import javax.ejb.TransactionAttributeType;
import javax.jms.Connection;
import javax.jms.ConnectionFactory;
import javax.jms.MessageProducer;
import javax.jms.Queue;
import javax.jms.Session;
import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
import javax.xml.ws.WebServiceRef;
@Stateless
public class OrderServiceBean implements OrderServiceRemote, OrderServiceLocal {
@PersistenceContext(unitName = "orderPU")
private EntityManager em;
@EJB
private CustomerServiceLocal customerService;
@EJB
private AuditServiceLocal auditService;
@WebServiceRef
private PaymentPort paymentPort;
@Resource(mappedName = "java:/jms/queue/OrderCreatedQueue")
private Queue orderCreatedQueue;
@Resource(mappedName = "java:/ConnectionFactory")
private ConnectionFactory connectionFactory;
@Resource
private SessionContext sessionContext;
@Override
@TransactionAttribute(TransactionAttributeType.REQUIRED)
public String createOrder(CreateOrderRequest request) {
try {
if (!customerService.exists(request.getCustomerId())) {
throw new IllegalArgumentException("Customer not found");
}
OrderEntity order = new OrderEntity();
order.setCustomerId(request.getCustomerId());
order.setStatus("NEW");
order.setPaymentStatus("PENDING");
em.persist(order);
OrderItemEntity item = new OrderItemEntity();
item.setOrder(order);
item.setSku(request.getSku());
item.setQuantity(request.getQuantity());
em.persist(item);
auditService.writeAudit("ORDER_CREATED", order.getId());
PaymentRequest paymentRequest = new PaymentRequest();
paymentRequest.setOrderId(order.getId());
paymentRequest.setCustomerId(order.getCustomerId());
paymentRequest.setAmount(request.getAmount());
PaymentResponse paymentResponse = paymentPort.charge(paymentRequest);
if (paymentResponse.isAccepted()) {
order.setPaymentStatus("PAID");
} else {
order.setPaymentStatus("DECLINED");
sessionContext.setRollbackOnly();
throw new PaymentDeclinedException("Payment declined");
}
sendOrderCreatedMessage(order.getId());
return order.getId();
} catch (PaymentFault ex) {
sessionContext.setRollbackOnly();
throw new PaymentProviderException("Payment provider failed", ex);
} catch (RuntimeException ex) {
sessionContext.setRollbackOnly();
throw ex;
}
}
private void sendOrderCreatedMessage(String orderId) {
try (Connection connection = connectionFactory.createConnection();
Session session = connection.createSession(true, Session.AUTO_ACKNOWLEDGE)) {
MessageProducer producer = session.createProducer(orderCreatedQueue);
producer.send(session.createTextMessage(orderId));
} catch (Exception e) {
throw new IllegalStateException("Could not send OrderCreated message", e);
}
}
}
#AuditService mit REQUIRES_NEW
package com.acme.audit.ejb;
import com.acme.audit.entity.AuditLogEntity;
import javax.ejb.Stateless;
import javax.ejb.TransactionAttribute;
import javax.ejb.TransactionAttributeType;
import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
@Stateless
public class AuditServiceBean implements AuditServiceLocal {
@PersistenceContext(unitName = "auditPU")
private EntityManager em;
@Override
@TransactionAttribute(TransactionAttributeType.REQUIRES_NEW)
public void writeAudit(String eventType, String referenceId) {
AuditLogEntity log = new AuditLogEntity();
log.setEventType(eventType);
log.setReferenceId(referenceId);
log.setCreatedBy("system");
em.persist(log);
}
}
#Wichtig
Audit wird committed, auch wenn createOrder() später rollbackt.
#MDB: InvoiceMessageBean
package com.acme.invoice.ejb;
import javax.ejb.ActivationConfigProperty;
import javax.ejb.EJB;
import javax.ejb.MessageDriven;
import javax.jms.Message;
import javax.jms.MessageListener;
import javax.jms.TextMessage;
@MessageDriven(activationConfig = {
@ActivationConfigProperty(propertyName = "destinationType", propertyValue = "javax.jms.Queue"),
@ActivationConfigProperty(propertyName = "destination", propertyValue = "java:/jms/queue/OrderCreatedQueue")
})
public class InvoiceMessageBean implements MessageListener {
@EJB
private InvoiceServiceLocal invoiceService;
@Override
public void onMessage(Message message) {
try {
TextMessage textMessage = (TextMessage) message;
String orderId = textMessage.getText();
invoiceService.createInvoiceForOrder(orderId);
} catch (Exception e) {
throw new RuntimeException("Invoice message processing failed", e);
}
}
}
#InvoiceService ohne Idempotenz
package com.acme.invoice.ejb;
import com.acme.invoice.entity.InvoiceEntity;
import com.acme.order.entity.OrderEntity;
import javax.ejb.Stateless;
import javax.ejb.TransactionAttribute;
import javax.ejb.TransactionAttributeType;
import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
@Stateless
public class InvoiceServiceBean implements InvoiceServiceLocal {
@PersistenceContext(unitName = "orderPU")
private EntityManager em;
@Override
@TransactionAttribute(TransactionAttributeType.REQUIRED)
public void createInvoiceForOrder(String orderId) {
OrderEntity order = em.find(OrderEntity.class, orderId);
InvoiceEntity invoice = new InvoiceEntity();
invoice.setOrderId(orderId);
invoice.setCustomerId(order.getCustomerId());
invoice.setStatus("CREATED");
em.persist(invoice);
}
}
#Problem
Wenn dieselbe JMS Message zweimal verarbeitet wird, entstehen zwei Rechnungen.
#Call Chain
create-order.jsp
↓
JNDI Lookup OrderServiceRemote
↓
OrderServiceBean#createOrder
[EJB REQUIRED, startet Haupttransaktion]
↓
CustomerServiceLocal#exists
[EJB, wahrscheinlich REQUIRED/SUPPORTS]
↓
EntityManager.persist(OrderEntity)
[DB Write: ORDERS]
↓
EntityManager.persist(OrderItemEntity)
[DB Write: ORDER_ITEMS]
↓
AuditServiceBean#writeAudit
[EJB REQUIRES_NEW, eigene Transaktion]
↓
PaymentPort#charge
[externer SOAP Call innerhalb Haupttransaktion]
↓
order.setPaymentStatus(...)
[DB Update]
↓
sendOrderCreatedMessage
[JMS Send innerhalb oder neben Transaktion, abhängig vom Provider/Setup]
↓
InvoiceMessageBean#onMessage
[MDB, neue Transaktion]
↓
InvoiceServiceBean#createInvoiceForOrder
[DB Write: INVOICE]
#Transaction Map
| Schritt | Komponente | TX-Verhalten | Risiko |
|---|---|---|---|
| Bestellung anlegen | OrderServiceBean#createOrder | REQUIRED | Haupttransaktion |
| Audit schreiben | AuditServiceBean#writeAudit | REQUIRES_NEW | Audit bleibt trotz Rollback |
| SOAP Payment | PaymentPort#charge | innerhalb Haupt-TX | lange DB-Locks, inkonsistente externe Effekte |
| JMS senden | sendOrderCreatedMessage | unklar / JMS Session transacted | Verhalten prüfen |
| Rechnung erstellen | InvoiceMessageBean + InvoiceServiceBean | neue MDB-TX | Duplikate möglich |
#Risikoanalyse
| Risiko | Beschreibung | Schwere | Aktion |
|---|---|---|---|
| SOAP innerhalb DB-Transaktion | Payment Call hält Transaktion offen | hoch | Adapter + später Outbox/Saga prüfen |
| Audit REQUIRES_NEW | anderes Commit-Verhalten als Haupt-TX | hoch | Charakterisierungstest schreiben |
| JMS Send unklar | DB/JMS Konsistenz nicht garantiert | hoch | XA oder Outbox prüfen |
| MDB nicht idempotent | doppelte Rechnungen möglich | hoch | Idempotency Key einführen |
| Businesslogik in JSP | UI kennt EJB/JNDI | mittel | Servlet/Controller einführen |
| Entity = Fachmodell | Statusstrings überall | mittel | Domain Enum + Mapper |
| setRollbackOnly manuell | versteckte Rollback-Semantik | hoch | Exception-/Rollback-Matrix |
#Erster sicherer Modernisierungsschnitt
Nicht zuerst Spring Boot, nicht zuerst Jakarta Migration, nicht zuerst REST.
Zuerst:
- JSP-Logik in Servlet verschieben.
CreateOrderCommandeinführen.CreateOrderUseCaseals Plain Java extrahieren.- Ports definieren:
OrderRepositoryCustomerValidatorPaymentGatewayAuditPortOrderEventPublisher
- Legacy Adapter bauen:
- JPA Repository
- EJB Audit Adapter
- SOAP Payment Gateway
- JMS Order Event Publisher
- EJB bleibt als Transaktions-Fassade.
#Zielstruktur
com.acme.order
application
CreateOrderUseCase.java
CreateOrderCommand.java
CreateOrderResult.java
domain
Order.java
OrderId.java
OrderItem.java
OrderStatus.java
PaymentStatus.java
ports
OrderRepository.java
CustomerValidator.java
PaymentGateway.java
AuditPort.java
OrderEventPublisher.java
adapters
persistence
JpaOrderRepository.java
OrderEntityMapper.java
payment
SoapPaymentGateway.java
messaging
JmsOrderEventPublisher.java
audit
EjbAuditAdapter.java
legacy
OrderServiceBean.java
OrderServlet.java
#Modernisierter Kern: Command
package com.acme.order.application;
import java.math.BigDecimal;
public class CreateOrderCommand {
private final String customerId;
private final String sku;
private final int quantity;
private final BigDecimal amount;
public CreateOrderCommand(String customerId, String sku, int quantity, BigDecimal amount) {
this.customerId = customerId;
this.sku = sku;
this.quantity = quantity;
this.amount = amount;
}
public String customerId() {
return customerId;
}
public String sku() {
return sku;
}
public int quantity() {
return quantity;
}
public BigDecimal amount() {
return amount;
}
}
#Domain-Modell
package com.acme.order.domain;
import java.util.UUID;
public class Order {
private final OrderId id;
private final String customerId;
private final String sku;
private final int quantity;
private OrderStatus status;
private PaymentStatus paymentStatus;
private Order(OrderId id, String customerId, String sku, int quantity) {
this.id = id;
this.customerId = customerId;
this.sku = sku;
this.quantity = quantity;
this.status = OrderStatus.NEW;
this.paymentStatus = PaymentStatus.PENDING;
}
public static Order create(String customerId, String sku, int quantity) {
if (customerId == null || customerId.isBlank()) {
throw new IllegalArgumentException("customerId is required");
}
if (sku == null || sku.isBlank()) {
throw new IllegalArgumentException("sku is required");
}
if (quantity <= 0) {
throw new IllegalArgumentException("quantity must be positive");
}
return new Order(new OrderId(UUID.randomUUID().toString()), customerId, sku, quantity);
}
public void markPaid() {
this.paymentStatus = PaymentStatus.PAID;
}
public void markPaymentDeclined() {
this.paymentStatus = PaymentStatus.DECLINED;
}
public OrderId id() {
return id;
}
public String customerId() {
return customerId;
}
public String sku() {
return sku;
}
public int quantity() {
return quantity;
}
public OrderStatus status() {
return status;
}
public PaymentStatus paymentStatus() {
return paymentStatus;
}
}
package com.acme.order.domain;
public enum OrderStatus {
NEW,
CANCELLED
}
package com.acme.order.domain;
public enum PaymentStatus {
PENDING,
PAID,
DECLINED
}
package com.acme.order.domain;
public class OrderId {
private final String value;
public OrderId(String value) {
this.value = value;
}
public String value() {
return value;
}
}
#Ports
package com.acme.order.ports;
import com.acme.order.domain.Order;
import com.acme.order.domain.OrderId;
import java.util.Optional;
public interface OrderRepository {
void save(Order order);
Optional<Order> findById(OrderId id);
}
package com.acme.order.ports;
public interface CustomerValidator {
boolean exists(String customerId);
}
package com.acme.order.ports;
import java.math.BigDecimal;
public interface PaymentGateway {
PaymentResult charge(String orderId, String customerId, BigDecimal amount);
}
package com.acme.order.ports;
public class PaymentResult {
private final boolean accepted;
public PaymentResult(boolean accepted) {
this.accepted = accepted;
}
public boolean accepted() {
return accepted;
}
}
package com.acme.order.ports;
import com.acme.order.domain.OrderId;
public interface AuditPort {
void orderCreated(OrderId orderId);
}
package com.acme.order.ports;
import com.acme.order.domain.OrderId;
public interface OrderEventPublisher {
void publishOrderCreated(OrderId orderId);
}
#Use Case
package com.acme.order.application;
import com.acme.order.domain.Order;
import com.acme.order.domain.OrderId;
import com.acme.order.ports.AuditPort;
import com.acme.order.ports.CustomerValidator;
import com.acme.order.ports.OrderEventPublisher;
import com.acme.order.ports.OrderRepository;
import com.acme.order.ports.PaymentGateway;
import com.acme.order.ports.PaymentResult;
public class CreateOrderUseCase {
private final CustomerValidator customerValidator;
private final OrderRepository orderRepository;
private final PaymentGateway paymentGateway;
private final AuditPort auditPort;
private final OrderEventPublisher eventPublisher;
public CreateOrderUseCase(
CustomerValidator customerValidator,
OrderRepository orderRepository,
PaymentGateway paymentGateway,
AuditPort auditPort,
OrderEventPublisher eventPublisher
) {
this.customerValidator = customerValidator;
this.orderRepository = orderRepository;
this.paymentGateway = paymentGateway;
this.auditPort = auditPort;
this.eventPublisher = eventPublisher;
}
public OrderId execute(CreateOrderCommand command) {
if (!customerValidator.exists(command.customerId())) {
throw new IllegalArgumentException("Customer not found");
}
Order order = Order.create(command.customerId(), command.sku(), command.quantity());
orderRepository.save(order);
auditPort.orderCreated(order.id());
PaymentResult paymentResult = paymentGateway.charge(
order.id().value(),
order.customerId(),
command.amount()
);
if (!paymentResult.accepted()) {
order.markPaymentDeclined();
orderRepository.save(order);
throw new PaymentDeclinedApplicationException("Payment declined");
}
order.markPaid();
orderRepository.save(order);
eventPublisher.publishOrderCreated(order.id());
return order.id();
}
}
#Hinweis
Dieser Use Case hat bewusst noch dieselbe Semantik wie Legacy: Payment wird synchron innerhalb des Use Cases ausgeführt. Das ist noch nicht ideal, aber ein sicherer Zwischenschritt, weil die Transaktionssemantik nicht gleichzeitig geändert wird.
#Legacy EJB als dünne Fassade
package com.acme.order.legacy;
import com.acme.audit.ejb.AuditServiceLocal;
import com.acme.customer.ejb.CustomerServiceLocal;
import com.acme.order.application.CreateOrderCommand;
import com.acme.order.application.CreateOrderUseCase;
import com.acme.order.domain.OrderId;
import com.acme.order.dto.CreateOrderRequest;
import com.acme.order.ports.CustomerValidator;
import com.acme.payment.soap.PaymentPort;
import javax.annotation.Resource;
import javax.ejb.EJB;
import javax.ejb.Stateless;
import javax.ejb.TransactionAttribute;
import javax.ejb.TransactionAttributeType;
import javax.jms.ConnectionFactory;
import javax.jms.Queue;
import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
import javax.xml.ws.WebServiceRef;
@Stateless
public class OrderServiceBean implements OrderServiceRemote, OrderServiceLocal {
@PersistenceContext(unitName = "orderPU")
private EntityManager em;
@EJB
private CustomerServiceLocal customerService;
@EJB
private AuditServiceLocal auditService;
@WebServiceRef
private PaymentPort paymentPort;
@Resource(mappedName = "java:/jms/queue/OrderCreatedQueue")
private Queue orderCreatedQueue;
@Resource(mappedName = "java:/ConnectionFactory")
private ConnectionFactory connectionFactory;
@Override
@TransactionAttribute(TransactionAttributeType.REQUIRED)
public String createOrder(CreateOrderRequest request) {
CreateOrderUseCase useCase = new CreateOrderUseCase(
customerId -> customerService.exists(customerId),
new JpaOrderRepository(em),
new SoapPaymentGateway(paymentPort),
new EjbAuditAdapter(auditService),
new JmsOrderEventPublisher(connectionFactory, orderCreatedQueue)
);
OrderId orderId = useCase.execute(new CreateOrderCommand(
request.getCustomerId(),
request.getSku(),
request.getQuantity(),
request.getAmount()
));
return orderId.value();
}
}
#Nächster Verbesserungsschritt: Outbox statt direktem JMS Send
Aktueller Zwischenstand:
Order speichern
Payment synchron aufrufen
Order paid speichern
JMS senden
Besser später:
Order speichern
Payment synchron/async behandeln
OutboxEvent speichern
Commit
separater Publisher sendet JMS
Outbox-Port:
public interface OutboxRepository {
void save(OutboxEvent event);
}
Use Case später:
outboxRepository.save(
OutboxEvent.orderCreated(order.id())
);
#Idempotenz für Invoice
Problem im Legacy-Code:
invoiceService.createInvoiceForOrder(orderId);
Besser:
public void createInvoiceForOrder(String orderId) {
if (invoiceRepository.existsForOrderId(orderId)) {
return;
}
Invoice invoice = Invoice.createForOrder(orderId);
invoiceRepository.save(invoice);
}
Idempotency-Tabelle:
CREATE TABLE processed_message (
message_id VARCHAR(100) PRIMARY KEY,
processed_at TIMESTAMP NOT NULL
);
Handler:
public void handle(OrderCreatedMessage message) {
if (processedMessageRepository.exists(message.messageId())) {
return;
}
createInvoiceUseCase.execute(new CreateInvoiceCommand(message.orderId()));
processedMessageRepository.markProcessed(message.messageId());
}
#Tests
#Charakterisierungstest für Audit
@Test
void auditIsWrittenEvenIfPaymentIsDeclined() {
FakeAuditPort audit = new FakeAuditPort();
FakePaymentGateway payment = PaymentGatewayFake.declined();
CreateOrderUseCase useCase = new CreateOrderUseCase(
customerId -> true,
new InMemoryOrderRepository(),
payment,
audit,
new FakeOrderEventPublisher()
);
assertThrows(PaymentDeclinedApplicationException.class, () ->
useCase.execute(new CreateOrderCommand("C-1", "SKU-1", 1, BigDecimal.TEN))
);
assertTrue(audit.contains("ORDER_CREATED"));
}
#Idempotenztest für Invoice
@Test
void duplicateOrderCreatedMessageDoesNotCreateSecondInvoice() {
OrderCreatedMessage message = new OrderCreatedMessage("msg-1", "order-1");
handler.handle(message);
handler.handle(message);
assertEquals(1, invoiceRepository.countByOrderId("order-1"));
}
#Reihenfolge der echten Umsetzung
- Bestehendes Verhalten dokumentieren.
- JSP/JNDI-Zugriff erfassen.
- Transaction Map erstellen.
- Audit-
REQUIRES_NEWdokumentieren. - Payment-SOAP innerhalb TX als Risiko markieren.
- JMS-Send-Verhalten prüfen.
- MDB-Idempotenz prüfen.
CreateOrderCommandeinführen.CreateOrderUseCaseextrahieren.- Ports definieren.
- Legacy-Adapter bauen.
- EJB als dünne Fassade behalten.
- Tests für Use Case schreiben.
- Invoice Handler extrahieren.
- Idempotenz einführen.
- Outbox Pattern prüfen.
- Erst danach Framework-Migration planen.
#Wichtigste Regel
Nicht gleichzeitig ändern:
- Transaktionsmodell
- Framework
- Messaging-Semantik
- SOAP-Vertrag
- DB-Schema
- Exception-Verhalten
- UI
Erst schneiden, kapseln und testen. Danach ersetzen.