#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:
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:
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:
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:
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:
order-payment-core
legacy-simulation
spring-target und jakarta-target kommen später.
#Maven-Grundstruktur
Root 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.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:
<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:
<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
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
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;
}
}
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);
}
}
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
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);
}
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);
}
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);
}
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
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");
}
}
}
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
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
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:
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
) {
}
package com.example.orderpayment.order.application;
import java.math.BigDecimal;
public record PaymentCommand(
String idempotencyKey,
String customerId,
BigDecimal amount
) {
}
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);
}
}
package com.example.orderpayment.order.application;
public class PaymentTimeoutException extends RuntimeException {
public PaymentTimeoutException(String message) {
super(message);
}
}
#In-Memory Adapter für Tests
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()));
}
}
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));
}
}
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);
}
}
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
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
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:
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;
}
}
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:
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);
}
package com.example.orderpayment.invoice.ports;
public interface ProcessedMessageRepository {
boolean alreadyProcessed(String messageId);
void markProcessed(String messageId);
}
Use Case:
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);
}
}
package com.example.orderpayment.invoice.application;
import com.example.orderpayment.order.domain.OrderId;
public record CreateInvoiceCommand(OrderId orderId) {
}
Message Handler:
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());
}
}
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
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();
}
}
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:
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:
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:
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:
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:
mvn test läuft grün
keine Framework-Migration ohne Test
keine Transaktionssemantik ändern ohne Dokumentation
#Dokumentation im Mini-Repo
docs/001-problem.md:
# 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:
# 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:
# 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:
# 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:
mvn test
läuft erfolgreich.
#Aufgabe 2: Order Domain implementieren
Akzeptanzkriterien:
- 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:
- Order wird gespeichert
- Audit ORDER_CREATED wird geschrieben
- Outbox PaymentRequested wird gespeichert
- kein PaymentGateway wird direkt aufgerufen
#Aufgabe 4: PaymentRequestedHandler implementieren
Akzeptanzkriterien:
- 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:
- dieselbe Message zweimal erzeugt nur eine Invoice
- bereits existierende Invoice wird erkannt
- Message wird als verarbeitet markiert
#Aufgabe 6: Legacy-Simulation bauen
Akzeptanzkriterien:
- 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:
- 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:
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:
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