#Mini-Repo-Übung für Legacy-Modernisierung

Du baust den modernisierten Kern als kleines Übungsprojekt nach.


#Konkrete Projektübung — Mini-Repo für Legacy-Modernisierung

Ziel dieses Abschnitts ist, aus dem komplexen Beispiel ein kleines, aber realistisches Übungsprojekt zu bauen. Dieses Mini-Repo soll nicht schön aussehen, sondern die typischen Legacy-Probleme nachstellen:

text
EJB-artige Fassade
JTA-ähnliche Transaktionsgrenzen
SOAP Payment Adapter
JMS/Outbox Event Publishing
MDB-artiger Consumer
JPA-artige Repository-Schicht
Idempotenz
Tests für Use Cases und Handler

Die Übung hilft dir, die Modernisierung nicht nur theoretisch zu verstehen, sondern praktisch zu trainieren.

Wichtig: Das Mini-Repo muss nicht sofort auf einem echten Application Server laufen. Der erste Zweck ist, den fachlichen Kern und die Modernisierungsmuster in Plain Java zu üben.


#Ziel der Übung

Am Ende sollst du ein kleines Projekt haben, das diesen Flow abbildet:

text
CreateOrder
  ↓
Order wird mit PAYMENT_PENDING gespeichert
  ↓
OutboxEvent PaymentRequested wird gespeichert
  ↓
PaymentRequestedHandler verarbeitet Event
  ↓
PaymentGateway wird aufgerufen
  ↓
Order wird PAID / PAYMENT_FAILED / PAYMENT_UNKNOWN
  ↓
OutboxEvent OrderPaid wird gespeichert
  ↓
OrderPaidMessageHandler erzeugt idempotent eine Invoice

Das Mini-Repo trainiert diese Kernfähigkeiten:

text
1. Legacy-Fassade von Use Case trennen
2. Domain-Modell aufbauen
3. Ports definieren
4. Adapter implementieren
5. Transaktionsgrenzen bewusst machen
6. Outbox Pattern verstehen
7. Idempotenz implementieren
8. Tests ohne Application Server schreiben
9. später Spring Boot oder Jakarta EE anbinden

#Empfohlene Repository-Struktur

Lege ein neues Übungsrepo an:

text
legacy-modernization-lab/
  README.md
  docs/
    001-problem.md
    002-transaction-map.md
    003-modernization-plan.md
    004-test-plan.md

  order-payment-core/
    pom.xml
    src/main/java/com/example/orderpayment/
      order/
        application/
        domain/
        ports/
        adapters/
      invoice/
        application/
        domain/
        ports/
        adapters/
      shared/
        outbox/
        messaging/
        money/
        time/
    src/test/java/com/example/orderpayment/

  legacy-simulation/
    pom.xml
    src/main/java/com/example/legacy/
      jsp/
      ejb/
      soap/
      jms/

  spring-target/
    pom.xml
    src/main/java/com/example/springtarget/

  jakarta-target/
    pom.xml
    src/main/java/com/example/jakartatarget/

Für den Anfang brauchst du nur:

text
order-payment-core
legacy-simulation

spring-target und jakarta-target kommen später.


#Maven-Grundstruktur

Root 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.example</groupId>
    <artifactId>legacy-modernization-lab</artifactId>
    <version>1.0.0-SNAPSHOT</version>
    <packaging>pom</packaging>

    <modules>
        <module>order-payment-core</module>
        <module>legacy-simulation</module>
    </modules>

    <properties>
        <maven.compiler.source>17</maven.compiler.source>
        <maven.compiler.target>17</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <junit.jupiter.version>5.10.2</junit.jupiter.version>
    </properties>
</project>

order-payment-core/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>

    <parent>
        <groupId>com.example</groupId>
        <artifactId>legacy-modernization-lab</artifactId>
        <version>1.0.0-SNAPSHOT</version>
    </parent>

    <artifactId>order-payment-core</artifactId>

    <dependencies>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter</artifactId>
            <version>${junit.jupiter.version}</version>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>3.2.5</version>
            </plugin>
        </plugins>
    </build>
</project>

legacy-simulation/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>

    <parent>
        <groupId>com.example</groupId>
        <artifactId>legacy-modernization-lab</artifactId>
        <version>1.0.0-SNAPSHOT</version>
    </parent>

    <artifactId>legacy-simulation</artifactId>

    <dependencies>
        <dependency>
            <groupId>com.example</groupId>
            <artifactId>order-payment-core</artifactId>
            <version>${project.version}</version>
        </dependency>
    </dependencies>
</project>

#Package-Struktur im Core-Modul

text
com.example.orderpayment.order
  application
    CreateOrderCommand
    CreateOrderUseCase
    PaymentRequestedEvent
    PaymentRequestedHandler

  domain
    Order
    OrderId
    OrderStatus

  ports
    OrderRepository
    AuditPort
    PaymentGateway
    OutboxPort

  adapters
    inmem
      InMemoryOrderRepository
      InMemoryOutboxPort
      FakeAuditPort
      FakePaymentGateway

com.example.orderpayment.invoice
  application
    CreateInvoiceCommand
    CreateInvoiceUseCase
    OrderPaidMessage
    OrderPaidMessageHandler

  domain
    Invoice
    InvoiceId

  ports
    InvoiceRepository
    ProcessedMessageRepository

  adapters
    inmem
      InMemoryInvoiceRepository
      InMemoryProcessedMessageRepository

com.example.orderpayment.shared
  outbox
    OutboxEvent
    OutboxEventType
  money
    Money

Diese Struktur ist bewusst technologiearm. Kein Spring, kein EJB, kein Jakarta, kein JMS. Dadurch lernst du zuerst den Kern.


#Domain zuerst — Order

java
package com.example.orderpayment.order.domain;

import java.math.BigDecimal;
import java.util.Objects;
import java.util.UUID;

public class Order {

    private final OrderId id;
    private final String customerId;
    private final BigDecimal amount;
    private OrderStatus status;

    private Order(OrderId id, String customerId, BigDecimal amount, OrderStatus status) {
        this.id = Objects.requireNonNull(id);
        this.customerId = requireNotBlank(customerId, "customerId");
        this.amount = requirePositive(amount, "amount");
        this.status = Objects.requireNonNull(status);
    }

    public static Order create(String customerId, BigDecimal amount) {
        return new Order(
                OrderId.of(UUID.randomUUID().toString()),
                customerId,
                amount,
                OrderStatus.PAYMENT_PENDING
        );
    }

    public void markPaymentInProgress() {
        if (status != OrderStatus.PAYMENT_PENDING) {
            throw new IllegalStateException("Only PAYMENT_PENDING orders can become PAYMENT_IN_PROGRESS");
        }
        status = OrderStatus.PAYMENT_IN_PROGRESS;
    }

    public void markPaid() {
        if (status != OrderStatus.PAYMENT_IN_PROGRESS && status != OrderStatus.PAYMENT_PENDING) {
            throw new IllegalStateException("Only pending/in-progress orders can become PAID");
        }
        status = OrderStatus.PAID;
    }

    public void markPaymentFailed() {
        if (status != OrderStatus.PAYMENT_IN_PROGRESS && status != OrderStatus.PAYMENT_PENDING) {
            throw new IllegalStateException("Only pending/in-progress orders can become PAYMENT_FAILED");
        }
        status = OrderStatus.PAYMENT_FAILED;
    }

    public void markPaymentUnknown() {
        if (status != OrderStatus.PAYMENT_IN_PROGRESS) {
            throw new IllegalStateException("Only PAYMENT_IN_PROGRESS orders can become PAYMENT_UNKNOWN");
        }
        status = OrderStatus.PAYMENT_UNKNOWN;
    }

    public OrderId id() {
        return id;
    }

    public String customerId() {
        return customerId;
    }

    public BigDecimal amount() {
        return amount;
    }

    public OrderStatus status() {
        return status;
    }

    private static String requireNotBlank(String value, String field) {
        if (value == null || value.isBlank()) {
            throw new IllegalArgumentException(field + " must not be blank");
        }
        return value;
    }

    private static BigDecimal requirePositive(BigDecimal value, String field) {
        if (value == null || value.signum() <= 0) {
            throw new IllegalArgumentException(field + " must be positive");
        }
        return value;
    }
}
java
package com.example.orderpayment.order.domain;

public record OrderId(String value) {
    public static OrderId of(String value) {
        if (value == null || value.isBlank()) {
            throw new IllegalArgumentException("orderId must not be blank");
        }
        return new OrderId(value);
    }
}
java
package com.example.orderpayment.order.domain;

public enum OrderStatus {
    PAYMENT_PENDING,
    PAYMENT_IN_PROGRESS,
    PAID,
    PAYMENT_FAILED,
    PAYMENT_UNKNOWN,
    CANCELLED
}

#Ports für Order und Payment

java
package com.example.orderpayment.order.ports;

import com.example.orderpayment.order.domain.Order;
import com.example.orderpayment.order.domain.OrderId;
import java.util.Optional;

public interface OrderRepository {
    void save(Order order);
    void update(Order order);
    Optional<Order> findById(OrderId orderId);
}
java
package com.example.orderpayment.order.ports;

import com.example.orderpayment.order.domain.OrderId;

public interface AuditPort {
    void orderCreated(OrderId orderId);
    void orderPaid(OrderId orderId);
    void paymentFailed(OrderId orderId);
    void paymentUnknown(OrderId orderId, String reason);
}
java
package com.example.orderpayment.order.ports;

import com.example.orderpayment.order.application.PaymentCommand;
import com.example.orderpayment.order.application.PaymentResult;

public interface PaymentGateway {
    PaymentResult charge(PaymentCommand command);
}
java
package com.example.orderpayment.order.ports;

import com.example.orderpayment.shared.outbox.OutboxEvent;

public interface OutboxPort {
    void store(OutboxEvent event);
}

#Application Layer für CreateOrder

java
package com.example.orderpayment.order.application;

import java.math.BigDecimal;

public record CreateOrderCommand(String customerId, BigDecimal amount) {
    public CreateOrderCommand {
        if (customerId == null || customerId.isBlank()) {
            throw new IllegalArgumentException("customerId must not be blank");
        }
        if (amount == null || amount.signum() <= 0) {
            throw new IllegalArgumentException("amount must be positive");
        }
    }
}
java
package com.example.orderpayment.order.application;

import com.example.orderpayment.order.domain.Order;
import com.example.orderpayment.order.domain.OrderId;
import com.example.orderpayment.order.ports.AuditPort;
import com.example.orderpayment.order.ports.OrderRepository;
import com.example.orderpayment.order.ports.OutboxPort;
import com.example.orderpayment.shared.outbox.OutboxEvent;

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();
    }
}

Dieser Use Case ist kurz, testbar und enthält keine SOAP-/JMS-/EJB-Abhängigkeit.


#OutboxEvent im Shared-Modul

java
package com.example.orderpayment.shared.outbox;

import com.example.orderpayment.order.domain.Order;
import java.time.Instant;
import java.util.UUID;

public class OutboxEvent {

    private final String id;
    private final String aggregateId;
    private final String aggregateType;
    private final String eventType;
    private final String payload;
    private final Instant createdAt;

    private OutboxEvent(
            String id,
            String aggregateId,
            String aggregateType,
            String eventType,
            String payload,
            Instant createdAt
    ) {
        this.id = id;
        this.aggregateId = aggregateId;
        this.aggregateType = aggregateType;
        this.eventType = eventType;
        this.payload = payload;
        this.createdAt = createdAt;
    }

    public static OutboxEvent paymentRequested(Order order) {
        String payload = """
                {
                  "orderId": "%s",
                  "customerId": "%s",
                  "amount": "%s"
                }
                """.formatted(order.id().value(), order.customerId(), order.amount());

        return new OutboxEvent(
                UUID.randomUUID().toString(),
                order.id().value(),
                "Order",
                "PaymentRequested",
                payload,
                Instant.now()
        );
    }

    public static OutboxEvent orderPaid(Order order) {
        String payload = """
                {
                  "orderId": "%s"
                }
                """.formatted(order.id().value());

        return new OutboxEvent(
                UUID.randomUUID().toString(),
                order.id().value(),
                "Order",
                "OrderPaid",
                payload,
                Instant.now()
        );
    }

    public String id() { return id; }
    public String aggregateId() { return aggregateId; }
    public String aggregateType() { return aggregateType; }
    public String eventType() { return eventType; }
    public String payload() { return payload; }
    public Instant createdAt() { return createdAt; }
}

#PaymentRequestedHandler

java
package com.example.orderpayment.order.application;

import com.example.orderpayment.order.domain.Order;
import com.example.orderpayment.order.domain.OrderId;
import com.example.orderpayment.order.domain.OrderStatus;
import com.example.orderpayment.order.ports.AuditPort;
import com.example.orderpayment.order.ports.OrderRepository;
import com.example.orderpayment.order.ports.OutboxPort;
import com.example.orderpayment.order.ports.PaymentGateway;
import com.example.orderpayment.shared.outbox.OutboxEvent;

public class PaymentRequestedHandler {

    private final OrderRepository orderRepository;
    private final PaymentGateway paymentGateway;
    private final AuditPort auditPort;
    private final OutboxPort outboxPort;

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

    public void handle(PaymentRequestedEvent event) {
        Order order = orderRepository.findById(event.orderId())
                .orElseThrow(() -> new IllegalStateException("Order not found: " + event.orderId().value()));

        if (order.status() == OrderStatus.PAID || order.status() == OrderStatus.PAYMENT_FAILED) {
            return;
        }

        order.markPaymentInProgress();
        orderRepository.update(order);

        try {
            PaymentResult result = paymentGateway.charge(
                    new PaymentCommand(
                            "payment-order-" + order.id().value(),
                            order.customerId(),
                            order.amount()
                    )
            );

            if (result.successful()) {
                order.markPaid();
                orderRepository.update(order);
                auditPort.orderPaid(order.id());
                outboxPort.store(OutboxEvent.orderPaid(order));
            } else {
                order.markPaymentFailed();
                orderRepository.update(order);
                auditPort.paymentFailed(order.id());
            }
        } catch (PaymentTimeoutException ex) {
            order.markPaymentUnknown();
            orderRepository.update(order);
            auditPort.paymentUnknown(order.id(), ex.getMessage());
        }
    }
}

Hilfsklassen:

java
package com.example.orderpayment.order.application;

import com.example.orderpayment.order.domain.OrderId;
import java.math.BigDecimal;

public record PaymentRequestedEvent(
        OrderId orderId,
        String customerId,
        BigDecimal amount
) {
}
java
package com.example.orderpayment.order.application;

import java.math.BigDecimal;

public record PaymentCommand(
        String idempotencyKey,
        String customerId,
        BigDecimal amount
) {
}
java
package com.example.orderpayment.order.application;

public record PaymentResult(
        boolean successful,
        String providerReference
) {
    public static PaymentResult success(String providerReference) {
        return new PaymentResult(true, providerReference);
    }

    public static PaymentResult failure() {
        return new PaymentResult(false, null);
    }
}
java
package com.example.orderpayment.order.application;

public class PaymentTimeoutException extends RuntimeException {
    public PaymentTimeoutException(String message) {
        super(message);
    }
}

#In-Memory Adapter für Tests

java
package com.example.orderpayment.order.adapters.inmem;

import com.example.orderpayment.order.domain.Order;
import com.example.orderpayment.order.domain.OrderId;
import com.example.orderpayment.order.ports.OrderRepository;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;

public class InMemoryOrderRepository implements OrderRepository {

    private final Map<String, Order> orders = new HashMap<>();

    @Override
    public void save(Order order) {
        orders.put(order.id().value(), order);
    }

    @Override
    public void update(Order order) {
        orders.put(order.id().value(), order);
    }

    @Override
    public Optional<Order> findById(OrderId orderId) {
        return Optional.ofNullable(orders.get(orderId.value()));
    }
}
java
package com.example.orderpayment.order.adapters.inmem;

import com.example.orderpayment.order.domain.OrderId;
import com.example.orderpayment.order.ports.AuditPort;
import java.util.ArrayList;
import java.util.List;

public class FakeAuditPort implements AuditPort {

    private final List<String> entries = new ArrayList<>();

    @Override
    public void orderCreated(OrderId orderId) {
        entries.add("ORDER_CREATED:" + orderId.value());
    }

    @Override
    public void orderPaid(OrderId orderId) {
        entries.add("ORDER_PAID:" + orderId.value());
    }

    @Override
    public void paymentFailed(OrderId orderId) {
        entries.add("PAYMENT_FAILED:" + orderId.value());
    }

    @Override
    public void paymentUnknown(OrderId orderId, String reason) {
        entries.add("PAYMENT_UNKNOWN:" + orderId.value() + ":" + reason);
    }

    public boolean containsPrefix(String prefix) {
        return entries.stream().anyMatch(entry -> entry.startsWith(prefix));
    }
}
java
package com.example.orderpayment.order.adapters.inmem;

import com.example.orderpayment.order.ports.OutboxPort;
import com.example.orderpayment.shared.outbox.OutboxEvent;
import java.util.ArrayList;
import java.util.List;

public class InMemoryOutboxPort implements OutboxPort {

    private final List<OutboxEvent> events = new ArrayList<>();

    @Override
    public void store(OutboxEvent event) {
        events.add(event);
    }

    public boolean containsEvent(String eventType, String aggregateId) {
        return events.stream().anyMatch(event ->
                event.eventType().equals(eventType)
                        && event.aggregateId().equals(aggregateId)
        );
    }

    public List<OutboxEvent> events() {
        return List.copyOf(events);
    }
}
java
package com.example.orderpayment.order.adapters.inmem;

import com.example.orderpayment.order.application.PaymentCommand;
import com.example.orderpayment.order.application.PaymentGateway;
import com.example.orderpayment.order.application.PaymentResult;

public class FakePaymentGateway implements PaymentGateway {

    private final PaymentResult result;

    public FakePaymentGateway(PaymentResult result) {
        this.result = result;
    }

    @Override
    public PaymentResult charge(PaymentCommand command) {
        return result;
    }
}

#Tests für CreateOrderUseCase

java
package com.example.orderpayment.order.application;

import com.example.orderpayment.order.adapters.inmem.FakeAuditPort;
import com.example.orderpayment.order.adapters.inmem.InMemoryOrderRepository;
import com.example.orderpayment.order.adapters.inmem.InMemoryOutboxPort;
import com.example.orderpayment.order.domain.Order;
import com.example.orderpayment.order.domain.OrderId;
import com.example.orderpayment.order.domain.OrderStatus;
import org.junit.jupiter.api.Test;

import java.math.BigDecimal;

import static org.junit.jupiter.api.Assertions.*;

class CreateOrderUseCaseTest {

    @Test
    void createOrder_savesPaymentPendingOrder_andStoresPaymentRequestedEvent() {
        InMemoryOrderRepository orderRepository = new InMemoryOrderRepository();
        FakeAuditPort auditPort = new FakeAuditPort();
        InMemoryOutboxPort outboxPort = new InMemoryOutboxPort();

        CreateOrderUseCase useCase = new CreateOrderUseCase(
                orderRepository,
                auditPort,
                outboxPort
        );

        OrderId orderId = useCase.execute(
                new CreateOrderCommand("customer-1", new BigDecimal("99.90"))
        );

        Order order = orderRepository.findById(orderId).orElseThrow();

        assertEquals(OrderStatus.PAYMENT_PENDING, order.status());
        assertTrue(auditPort.containsPrefix("ORDER_CREATED:" + orderId.value()));
        assertTrue(outboxPort.containsEvent("PaymentRequested", orderId.value()));
    }
}

#Tests für PaymentRequestedHandler

java
package com.example.orderpayment.order.application;

import com.example.orderpayment.order.adapters.inmem.FakeAuditPort;
import com.example.orderpayment.order.adapters.inmem.FakePaymentGateway;
import com.example.orderpayment.order.adapters.inmem.InMemoryOrderRepository;
import com.example.orderpayment.order.adapters.inmem.InMemoryOutboxPort;
import com.example.orderpayment.order.domain.Order;
import com.example.orderpayment.order.domain.OrderStatus;
import org.junit.jupiter.api.Test;

import java.math.BigDecimal;

import static org.junit.jupiter.api.Assertions.*;

class PaymentRequestedHandlerTest {

    @Test
    void paymentSuccess_marksOrderPaid_andStoresOrderPaidEvent() {
        InMemoryOrderRepository orderRepository = new InMemoryOrderRepository();
        FakePaymentGateway paymentGateway = new FakePaymentGateway(
                PaymentResult.success("PAY-123")
        );
        FakeAuditPort auditPort = new FakeAuditPort();
        InMemoryOutboxPort outboxPort = new InMemoryOutboxPort();

        Order order = Order.create("customer-1", new BigDecimal("99.90"));
        orderRepository.save(order);

        PaymentRequestedHandler handler = new PaymentRequestedHandler(
                orderRepository,
                paymentGateway,
                auditPort,
                outboxPort
        );

        handler.handle(new PaymentRequestedEvent(
                order.id(),
                order.customerId(),
                order.amount()
        ));

        Order updated = orderRepository.findById(order.id()).orElseThrow();

        assertEquals(OrderStatus.PAID, updated.status());
        assertTrue(auditPort.containsPrefix("ORDER_PAID:" + order.id().value()));
        assertTrue(outboxPort.containsEvent("OrderPaid", order.id().value()));
    }

    @Test
    void paymentFailure_marksOrderPaymentFailed_andDoesNotStoreOrderPaidEvent() {
        InMemoryOrderRepository orderRepository = new InMemoryOrderRepository();
        FakePaymentGateway paymentGateway = new FakePaymentGateway(PaymentResult.failure());
        FakeAuditPort auditPort = new FakeAuditPort();
        InMemoryOutboxPort outboxPort = new InMemoryOutboxPort();

        Order order = Order.create("customer-1", new BigDecimal("99.90"));
        orderRepository.save(order);

        PaymentRequestedHandler handler = new PaymentRequestedHandler(
                orderRepository,
                paymentGateway,
                auditPort,
                outboxPort
        );

        handler.handle(new PaymentRequestedEvent(
                order.id(),
                order.customerId(),
                order.amount()
        ));

        Order updated = orderRepository.findById(order.id()).orElseThrow();

        assertEquals(OrderStatus.PAYMENT_FAILED, updated.status());
        assertTrue(auditPort.containsPrefix("PAYMENT_FAILED:" + order.id().value()));
        assertFalse(outboxPort.containsEvent("OrderPaid", order.id().value()));
    }
}

#Invoice und Idempotenz

Invoice-Domain:

java
package com.example.orderpayment.invoice.domain;

import com.example.orderpayment.order.domain.OrderId;
import java.util.UUID;

public class Invoice {

    private final InvoiceId id;
    private final OrderId orderId;

    private Invoice(InvoiceId id, OrderId orderId) {
        this.id = id;
        this.orderId = orderId;
    }

    public static Invoice createForOrder(OrderId orderId) {
        return new Invoice(InvoiceId.of(UUID.randomUUID().toString()), orderId);
    }

    public InvoiceId id() {
        return id;
    }

    public OrderId orderId() {
        return orderId;
    }
}
java
package com.example.orderpayment.invoice.domain;

public record InvoiceId(String value) {
    public static InvoiceId of(String value) {
        if (value == null || value.isBlank()) {
            throw new IllegalArgumentException("invoiceId must not be blank");
        }
        return new InvoiceId(value);
    }
}

Ports:

java
package com.example.orderpayment.invoice.ports;

import com.example.orderpayment.invoice.domain.Invoice;
import com.example.orderpayment.order.domain.OrderId;

public interface InvoiceRepository {
    void save(Invoice invoice);
    boolean existsForOrderId(OrderId orderId);
    long countByOrderId(OrderId orderId);
}
java
package com.example.orderpayment.invoice.ports;

public interface ProcessedMessageRepository {
    boolean alreadyProcessed(String messageId);
    void markProcessed(String messageId);
}

Use Case:

java
package com.example.orderpayment.invoice.application;

import com.example.orderpayment.invoice.domain.Invoice;
import com.example.orderpayment.invoice.ports.InvoiceRepository;

public class CreateInvoiceUseCase {

    private final InvoiceRepository invoiceRepository;

    public CreateInvoiceUseCase(InvoiceRepository invoiceRepository) {
        this.invoiceRepository = invoiceRepository;
    }

    public void execute(CreateInvoiceCommand command) {
        if (invoiceRepository.existsForOrderId(command.orderId())) {
            return;
        }

        Invoice invoice = Invoice.createForOrder(command.orderId());
        invoiceRepository.save(invoice);
    }
}
java
package com.example.orderpayment.invoice.application;

import com.example.orderpayment.order.domain.OrderId;

public record CreateInvoiceCommand(OrderId orderId) {
}

Message Handler:

java
package com.example.orderpayment.invoice.application;

import com.example.orderpayment.invoice.ports.InvoiceRepository;
import com.example.orderpayment.invoice.ports.ProcessedMessageRepository;

public class OrderPaidMessageHandler {

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

    public OrderPaidMessageHandler(
            ProcessedMessageRepository processedMessageRepository,
            InvoiceRepository invoiceRepository,
            CreateInvoiceUseCase createInvoiceUseCase
    ) {
        this.processedMessageRepository = processedMessageRepository;
        this.invoiceRepository = invoiceRepository;
        this.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());
    }
}
java
package com.example.orderpayment.invoice.application;

import com.example.orderpayment.order.domain.OrderId;

public record OrderPaidMessage(String messageId, OrderId orderId) {
}

#In-Memory Invoice Adapter und Idempotenz-Test

java
package com.example.orderpayment.invoice.adapters.inmem;

import com.example.orderpayment.invoice.domain.Invoice;
import com.example.orderpayment.invoice.ports.InvoiceRepository;
import com.example.orderpayment.order.domain.OrderId;
import java.util.ArrayList;
import java.util.List;

public class InMemoryInvoiceRepository implements InvoiceRepository {

    private final List<Invoice> invoices = new ArrayList<>();

    @Override
    public void save(Invoice invoice) {
        invoices.add(invoice);
    }

    @Override
    public boolean existsForOrderId(OrderId orderId) {
        return invoices.stream().anyMatch(invoice -> invoice.orderId().equals(orderId));
    }

    @Override
    public long countByOrderId(OrderId orderId) {
        return invoices.stream().filter(invoice -> invoice.orderId().equals(orderId)).count();
    }
}
java
package com.example.orderpayment.invoice.adapters.inmem;

import com.example.orderpayment.invoice.ports.ProcessedMessageRepository;
import java.util.HashSet;
import java.util.Set;

public class InMemoryProcessedMessageRepository implements ProcessedMessageRepository {

    private final Set<String> processed = new HashSet<>();

    @Override
    public boolean alreadyProcessed(String messageId) {
        return processed.contains(messageId);
    }

    @Override
    public void markProcessed(String messageId) {
        processed.add(messageId);
    }
}

Test:

java
package com.example.orderpayment.invoice.application;

import com.example.orderpayment.invoice.adapters.inmem.InMemoryInvoiceRepository;
import com.example.orderpayment.invoice.adapters.inmem.InMemoryProcessedMessageRepository;
import com.example.orderpayment.order.domain.OrderId;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;

class OrderPaidMessageHandlerTest {

    @Test
    void duplicateOrderPaidMessage_createsOnlyOneInvoice() {
        InMemoryProcessedMessageRepository processedMessages =
                new InMemoryProcessedMessageRepository();

        InMemoryInvoiceRepository invoiceRepository =
                new InMemoryInvoiceRepository();

        CreateInvoiceUseCase createInvoiceUseCase =
                new CreateInvoiceUseCase(invoiceRepository);

        OrderPaidMessageHandler handler = new OrderPaidMessageHandler(
                processedMessages,
                invoiceRepository,
                createInvoiceUseCase
        );

        OrderPaidMessage message = new OrderPaidMessage(
                "message-1",
                OrderId.of("order-1")
        );

        handler.handle(message);
        handler.handle(message);

        assertEquals(1, invoiceRepository.countByOrderId(OrderId.of("order-1")));
    }
}

#Legacy-Simulation ohne echten Application Server

Im Modul legacy-simulation simulierst du alte Entry Points.

Beispiel einer EJB-artigen Fassade:

java
package com.example.legacy.ejb;

import com.example.orderpayment.order.adapters.inmem.FakeAuditPort;
import com.example.orderpayment.order.adapters.inmem.InMemoryOrderRepository;
import com.example.orderpayment.order.adapters.inmem.InMemoryOutboxPort;
import com.example.orderpayment.order.application.CreateOrderCommand;
import com.example.orderpayment.order.application.CreateOrderUseCase;
import com.example.orderpayment.order.domain.OrderId;

import java.math.BigDecimal;

public class LegacyOrderServiceBeanSimulation {

    private final InMemoryOrderRepository orderRepository;
    private final FakeAuditPort auditPort;
    private final InMemoryOutboxPort outboxPort;

    public LegacyOrderServiceBeanSimulation(
            InMemoryOrderRepository orderRepository,
            FakeAuditPort auditPort,
            InMemoryOutboxPort outboxPort
    ) {
        this.orderRepository = orderRepository;
        this.auditPort = auditPort;
        this.outboxPort = outboxPort;
    }

    public String createOrder(String customerId, BigDecimal amount) {
        CreateOrderUseCase useCase = new CreateOrderUseCase(
                orderRepository,
                auditPort,
                outboxPort
        );

        OrderId orderId = useCase.execute(new CreateOrderCommand(customerId, amount));
        return orderId.value();
    }
}

Simulierter JSP-Entry:

java
package com.example.legacy.jsp;

import com.example.legacy.ejb.LegacyOrderServiceBeanSimulation;

import java.math.BigDecimal;

public class CreateOrderJspSimulation {

    private final LegacyOrderServiceBeanSimulation orderService;

    public CreateOrderJspSimulation(LegacyOrderServiceBeanSimulation orderService) {
        this.orderService = orderService;
    }

    public String submitForm(String customerId, String amount) {
        return orderService.createOrder(customerId, new BigDecimal(amount));
    }
}

Das ist natürlich keine echte JSP. Es ist eine Übung, um den alten Call Flow kontrolliert nachzubauen.


#Commit-Reihenfolge für das Mini-Repo

Arbeite in kleinen Commits:

text
Commit 01: Maven Multi-Module Skeleton
Commit 02: Order Domain einführen
Commit 03: Order Ports definieren
Commit 04: CreateOrderCommand und CreateOrderUseCase
Commit 05: OutboxEvent und OutboxPort
Commit 06: In-Memory Adapter für Tests
Commit 07: CreateOrderUseCaseTest
Commit 08: PaymentCommand, PaymentResult, PaymentGateway
Commit 09: PaymentRequestedHandler
Commit 10: PaymentRequestedHandlerTest
Commit 11: Invoice Domain und Ports
Commit 12: CreateInvoiceUseCase
Commit 13: OrderPaidMessageHandler mit Idempotenz
Commit 14: Idempotenz-Test
Commit 15: Legacy-Simulation für JSP/EJB Entry Point
Commit 16: Dokumentation und Transaction Map

Nach jedem Commit muss gelten:

text
mvn test läuft grün
keine Framework-Migration ohne Test
keine Transaktionssemantik ändern ohne Dokumentation

#Dokumentation im Mini-Repo

docs/001-problem.md:

markdown
# Problem

Der Legacy-Flow erzeugt eine Bestellung, ruft Payment synchron innerhalb der Transaktion auf und sendet danach eine JMS Message. Dadurch entstehen lange DB-Transaktionen, unklare Fehlerzustände und mögliche doppelte Folgeprozesse.

docs/002-transaction-map.md:

markdown
# Transaction Map

## Legacy

- TX startet in OrderServiceBean#createOrder
- DB Insert ORDERS
- Audit mit REQUIRES_NEW
- SOAP Payment innerhalb offener TX
- DB Update ORDERS
- JMS Send

## Modernisiertes Ziel

- TX 1: Order PAYMENT_PENDING + Outbox PaymentRequested
- Payment Call außerhalb CreateOrder-TX
- TX 2: Order PAID/PAYMENT_FAILED/PAYMENT_UNKNOWN + Outbox OrderPaid
- Invoice Consumer idempotent

docs/003-modernization-plan.md:

markdown
# Modernization Plan

1. Fachlogik in Use Cases extrahieren
2. Ports definieren
3. Legacy Adapter bauen
4. Outbox einführen
5. Payment aus CreateOrder-TX lösen
6. Invoice-Verarbeitung idempotent machen
7. Danach Spring/Jakarta Zielplattform anbinden

docs/004-test-plan.md:

markdown
# Test Plan

## Unit Tests

- CreateOrderUseCase speichert PAYMENT_PENDING Order
- CreateOrderUseCase speichert PaymentRequested Event
- PaymentRequestedHandler setzt Order auf PAID bei Erfolg
- PaymentRequestedHandler setzt PAYMENT_FAILED bei Ablehnung
- OrderPaidMessageHandler erzeugt nur eine Invoice bei doppelter Message

## Spätere Integrationstests

- JPA Repository gegen Testdatenbank
- Outbox Publisher gegen JMS Testcontainer
- SOAP Adapter gegen Mock Server

#Übungsaufgaben

#Aufgabe 1: Projekt anlegen

Erzeuge die Maven-Struktur und stelle sicher:

bash
mvn test

läuft erfolgreich.

#Aufgabe 2: Order Domain implementieren

Akzeptanzkriterien:

text
- Order.create erzeugt Status PAYMENT_PENDING
- amount muss positiv sein
- customerId darf nicht leer sein
- markPaid ist nur aus PAYMENT_PENDING oder PAYMENT_IN_PROGRESS erlaubt

#Aufgabe 3: CreateOrderUseCase implementieren

Akzeptanzkriterien:

text
- Order wird gespeichert
- Audit ORDER_CREATED wird geschrieben
- Outbox PaymentRequested wird gespeichert
- kein PaymentGateway wird direkt aufgerufen

#Aufgabe 4: PaymentRequestedHandler implementieren

Akzeptanzkriterien:

text
- Erfolgreiches Payment setzt Order auf PAID
- Fehlgeschlagenes Payment setzt Order auf PAYMENT_FAILED
- Timeout setzt Order auf PAYMENT_UNKNOWN
- OrderPaid Event wird nur bei Erfolg gespeichert

#Aufgabe 5: Invoice Idempotenz

Akzeptanzkriterien:

text
- dieselbe Message zweimal erzeugt nur eine Invoice
- bereits existierende Invoice wird erkannt
- Message wird als verarbeitet markiert

#Aufgabe 6: Legacy-Simulation bauen

Akzeptanzkriterien:

text
- simulierter JSP Entry ruft simulierter EJB Facade auf
- EJB Facade ruft CreateOrderUseCase auf
- Legacy-Schicht enthält keine Fachlogik mehr

#Was du mit dieser Übung gelernt hast

Nach diesem Mini-Repo hast du praktisch verstanden:

text
- Warum man Legacy nicht sofort rewritet
- Wie man EJBs zu dünnen Fassaden macht
- Wie Use Cases ohne Container testbar werden
- Wie Ports und Adapter technische Kopplung reduzieren
- Warum Outbox zuverlässiger ist als direkter JMS Send
- Warum Payment nicht in einer offenen DB-Transaktion laufen sollte
- Warum Idempotenz bei Messaging Pflicht ist
- Wie man schrittweise Richtung Spring Boot oder Jakarta EE migriert

Der nächste logische Schritt ist dann:

text
Nächster Schritt: Spring-Boot-Zielimplementierung für das Mini-Repo

Dort wird der bisherige Plain-Java-Kern in eine echte Spring-Boot-Anwendung eingebunden:

text
UseCase als @Service
Transaktionen mit @Transactional
JPA Adapter als @Repository
Outbox Publisher mit @Scheduled
JMS Consumer mit @JmsListener
REST Entry Point als @RestController
Konfiguration über application.yml