#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_NEW Audit-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


#Legacy-Ausgangslage

#Fachlicher Use Case

Ein Benutzer legt über eine JSP eine Bestellung an. Das System:

  1. validiert den Kunden,
  2. speichert Bestellung und Positionen,
  3. schreibt Audit-Log in eigener Transaktion,
  4. ruft synchron einen externen Payment-SOAP-Service auf,
  5. aktualisiert den Zahlungsstatus,
  6. sendet eine JMS-Message OrderCreated,
  7. eine MDB erzeugt später eine Rechnung.

#JSP Entry Point

jsp
<%@ 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

java
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

java
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

java
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

java
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

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

  1. JSP-Logik in Servlet verschieben.
  2. CreateOrderCommand einführen.
  3. CreateOrderUseCase als Plain Java extrahieren.
  4. Ports definieren:
    • OrderRepository
    • CustomerValidator
    • PaymentGateway
    • AuditPort
    • OrderEventPublisher
  5. Legacy Adapter bauen:
    • JPA Repository
    • EJB Audit Adapter
    • SOAP Payment Gateway
    • JMS Order Event Publisher
  6. EJB bleibt als Transaktions-Fassade.

#Zielstruktur

text
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

java
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

java
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;
    }
}
java
package com.acme.order.domain;

public enum OrderStatus {
    NEW,
    CANCELLED
}
java
package com.acme.order.domain;

public enum PaymentStatus {
    PENDING,
    PAID,
    DECLINED
}
java
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

java
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);
}
java
package com.acme.order.ports;

public interface CustomerValidator {
    boolean exists(String customerId);
}
java
package com.acme.order.ports;

import java.math.BigDecimal;

public interface PaymentGateway {
    PaymentResult charge(String orderId, String customerId, BigDecimal amount);
}
java
package com.acme.order.ports;

public class PaymentResult {
    private final boolean accepted;

    public PaymentResult(boolean accepted) {
        this.accepted = accepted;
    }

    public boolean accepted() {
        return accepted;
    }
}
java
package com.acme.order.ports;

import com.acme.order.domain.OrderId;

public interface AuditPort {
    void orderCreated(OrderId orderId);
}
java
package com.acme.order.ports;

import com.acme.order.domain.OrderId;

public interface OrderEventPublisher {
    void publishOrderCreated(OrderId orderId);
}

#Use Case

java
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

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

text
Order speichern
Payment synchron aufrufen
Order paid speichern
JMS senden

Besser später:

text
Order speichern
Payment synchron/async behandeln
OutboxEvent speichern
Commit
separater Publisher sendet JMS

Outbox-Port:

java
public interface OutboxRepository {
    void save(OutboxEvent event);
}

Use Case später:

java
outboxRepository.save(
    OutboxEvent.orderCreated(order.id())
);

#Idempotenz für Invoice

Problem im Legacy-Code:

java
invoiceService.createInvoiceForOrder(orderId);

Besser:

java
public void createInvoiceForOrder(String orderId) {
    if (invoiceRepository.existsForOrderId(orderId)) {
        return;
    }

    Invoice invoice = Invoice.createForOrder(orderId);
    invoiceRepository.save(invoice);
}

Idempotency-Tabelle:

sql
CREATE TABLE processed_message (
    message_id VARCHAR(100) PRIMARY KEY,
    processed_at TIMESTAMP NOT NULL
);

Handler:

java
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

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

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

  1. Bestehendes Verhalten dokumentieren.
  2. JSP/JNDI-Zugriff erfassen.
  3. Transaction Map erstellen.
  4. Audit-REQUIRES_NEW dokumentieren.
  5. Payment-SOAP innerhalb TX als Risiko markieren.
  6. JMS-Send-Verhalten prüfen.
  7. MDB-Idempotenz prüfen.
  8. CreateOrderCommand einführen.
  9. CreateOrderUseCase extrahieren.
  10. Ports definieren.
  11. Legacy-Adapter bauen.
  12. EJB als dünne Fassade behalten.
  13. Tests für Use Case schreiben.
  14. Invoice Handler extrahieren.
  15. Idempotenz einführen.
  16. Outbox Pattern prüfen.
  17. 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.


⌂ Cockpit