Fachmodell und Codevergleich
Realer Java-21-Code für denselben Bestell-Use-Case.
Fachmodell und realer Codevergleich
Gemeinsamer Use Case: Bestellung platzieren
Alle drei Varianten müssen Kundenstatus, Produktbestellbarkeit, Preis-Snapshots, mindestens eine Position und Idempotenz berücksichtigen. Der Unterschied liegt darin, wo diese Entscheidungen sichtbar werden.
Layered: Application Service orchestriert
Der Service lädt Daten, prüft Idempotenz, erzeugt Snapshots, baut das Domain-Objekt und publiziert das Ereignis. Der Ablauf ist an einer Stelle gut nachvollziehbar; bei wachsender Fachlichkeit kann der Service jedoch sehr breit werden.
package com.aydinsude.enterprise.layered.application.order;
import com.aydinsude.enterprise.layered.application.common.ApplicationException;
import com.aydinsude.enterprise.layered.application.common.Ports;
import com.aydinsude.enterprise.layered.application.common.RequestFingerprint;
import com.aydinsude.enterprise.layered.application.common.UseCases;
import com.aydinsude.enterprise.layered.domain.catalog.Product;
import com.aydinsude.enterprise.layered.domain.event.BusinessEvent;
import com.aydinsude.enterprise.layered.domain.order.Order;
import java.time.Clock;
import java.time.Instant;
import java.util.List;
import java.util.Objects;
import static com.aydinsude.enterprise.layered.application.common.ApplicationException.ErrorCode.*;
/** Pattern: Service Layer. Zweck: UC-03 mit Preis-Snapshot und Idempotenz orchestrieren. */
public final class PlaceOrderService implements UseCases.PlaceOrder {
private final Ports.CustomerRepository customers;
private final Ports.ProductRepository products;
private final Ports.OrderRepository orders;
private final Ports.IdentifierGenerator ids;
private final Ports.EventPublisher events;
private final Ports.TransactionRunner transactions;
private final Clock clock;
public PlaceOrderService(Ports.CustomerRepository customers, Ports.ProductRepository products,
Ports.OrderRepository orders, Ports.IdentifierGenerator ids,
Ports.EventPublisher events, Ports.TransactionRunner transactions, Clock clock) {
this.customers = Objects.requireNonNull(customers);
this.products = Objects.requireNonNull(products);
this.orders = Objects.requireNonNull(orders);
this.ids = Objects.requireNonNull(ids);
this.events = Objects.requireNonNull(events);
this.transactions = Objects.requireNonNull(transactions);
this.clock = Objects.requireNonNull(clock);
}
@Override
public Order.Id execute(UseCases.PlaceOrderCommand command) {
return transactions.required(() -> doExecute(command));
}
private Order.Id doExecute(UseCases.PlaceOrderCommand command) {
String fingerprint = fingerprint(command);
var existing = orders.findByIdempotencyKey(command.idempotencyKey());
if (existing.isPresent()) {
if (existing.get().requestFingerprint().equals(fingerprint)) return existing.get().id();
throw new ApplicationException(IDEMPOTENCY_CONFLICT, "key used with different payload");
}
var customer = customers.findById(command.customerId())
.filter(c -> c.isActive())
.orElseThrow(() -> new ApplicationException(CUSTOMER_NOT_ACTIVE, "customer is not active"));
if (command.lines() == null || command.lines().isEmpty())
throw new ApplicationException(ORDER_LINES_INVALID, "order requires at least one line");
List<Order.Line> lines = command.lines().stream().map(requested -> {
Product product = products.findById(requested.productId())
.filter(Product::isActive)
.orElseThrow(() -> new ApplicationException(PRODUCT_NOT_ORDERABLE,
"product is not orderable: " + requested.productId().value()));
return Order.Line.from(product.snapshot(), requested.quantity());
}).toList();
Order.Id id = new Order.Id(ids.nextId());
Instant now = clock.instant();
Order order = Order.place(id, customer.id(), lines, command.shippingAddress(),
command.idempotencyKey(), fingerprint, now);
orders.save(order);
events.publish(new BusinessEvent.OrderPlaced(ids.nextId(), required(command.correlationId()),
now, id, customer.id(), order.total()));
return id;
}
private static String fingerprint(UseCases.PlaceOrderCommand command) {
StringBuilder value = new StringBuilder(command.customerId().value())
.append('|').append(command.shippingAddress()).append('|');
if (command.lines() != null)
command.lines().forEach(line -> value.append(line.productId().value())
.append(':').append(line.quantity().value()).append(';'));
return RequestFingerprint.sha256(value.toString());
}
private static String required(String value) {
if (value == null || value.isBlank()) throw new IllegalArgumentException("correlationId required");
return value;
}
}
TDD-First: Use Case aus Tests entstanden
Die TDD-Variante besitzt ebenfalls einen Application Service, aber die Ports, Fehlerklassen und Domain-Operationen wurden sliceweise aus Akzeptanz- und Use-Case-Tests entwickelt. Die Struktur ist kleinteiliger und auf direkt testbares Verhalten ausgerichtet.
package com.aydinsude.enterprise.tdd.application;
import com.aydinsude.enterprise.tdd.domain.*;
import com.aydinsude.enterprise.tdd.port.*;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
/** Pattern: Application Service. Zweck: Kundenstatus, Produkt-Snapshots, Idempotenz und Bestellzustand orchestrieren. */
public final class PlaceOrderUseCase {
private final OrderRepository orders;
private final CustomerOrderingSource customers;
private final ProductOrderingSource products;
private final OrderIdGenerator ids;
private final OrderEventPublisher events;
public PlaceOrderUseCase(OrderRepository orders, CustomerOrderingSource customers, ProductOrderingSource products,
OrderIdGenerator ids, OrderEventPublisher events) {
this.orders=orders; this.customers=customers; this.products=products; this.ids=ids; this.events=events;
}
public PlaceOrderResult place(PlaceOrderCommand command) {
validate(command);
String fingerprint = fingerprint(command);
var existing = orders.findByIdempotencyKey(command.idempotencyKey());
if (existing.isPresent()) {
if (!existing.get().requestFingerprint().equals(fingerprint)) throw new IdempotencyConflictException(command.idempotencyKey());
return result(existing.get());
}
if (!customers.isActive(command.customerId())) throw new CustomerNotActiveException(command.customerId());
Order order = Order.draft(ids.nextId(), command.customerId(), command.shippingAddress(), command.idempotencyKey(), fingerprint);
for (var line : command.lines()) {
if (line.quantity() < 1) throw new OrderLinesInvalidException("quantity must be positive");
ProductSnapshot snapshot = products.findOrderable(line.productId())
.orElseThrow(() -> new ProductNotOrderableException(line.productId()));
order.addLine(snapshot, new Quantity(line.quantity()));
}
OrderPlaced event = order.place();
orders.save(order);
events.publish(event);
return result(order);
}
private static void validate(PlaceOrderCommand command) {
if (command == null) throw new OrderLinesInvalidException("request is required");
if (command.lines().isEmpty()) throw new OrderLinesInvalidException("at least one order line is required");
if (command.idempotencyKey() == null || command.idempotencyKey().isBlank()) throw new OrderLinesInvalidException("idempotency key is required");
}
private static PlaceOrderResult result(Order order) {
return new PlaceOrderResult(order.id().value(), order.customerId(), order.status().name(), order.total(),
order.lines().stream().map(line -> new PlaceOrderResult.Line(line.productId(), line.productName(),
line.quantity().value(), line.unitPrice(), line.subtotal())).toList());
}
/** Pattern: Request Fingerprint. Zweck: Gleichen Schlüssel mit anderem Payload sicher als Konflikt erkennen. */
private static String fingerprint(PlaceOrderCommand command) {
String canonical = command.customerId()+"|"+command.shippingAddress()+"|"+
command.lines().stream().map(l -> l.productId()+":"+l.quantity()).reduce((a,b)->a+";"+b).orElse("");
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(canonical.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException ex) { throw new IllegalStateException(ex); }
}
}
DDD: Handler delegiert Entscheidung an Aggregate
Der Handler hält Transaktion und Repository-Zugriff. Die eigentliche Zustandsentscheidung liegt im Order Aggregate, das seine Invarianten schützt und Domain Events sammelt.
package com.aydinsude.enterprise.ddd.ordering.application.handler;
import com.aydinsude.enterprise.ddd.ordering.application.command.PlaceOrderCommand; import com.aydinsude.enterprise.ddd.ordering.application.exception.OrderNotFoundException; import com.aydinsude.enterprise.ddd.ordering.application.port.*; import com.aydinsude.enterprise.ddd.ordering.application.view.OrderView; import com.aydinsude.enterprise.ddd.ordering.domain.Order; import java.util.Objects;
/** Pattern: Command Handler. Öffnet Transaktion, lädt Aggregate, delegiert Fachentscheidung, speichert und publiziert. */
public final class PlaceOrderHandler { private final OrderRepository repository; private final OrderingEventPublisher events; private final OrderingTransactionBoundary transactions; private final OrderingClock clock;
public PlaceOrderHandler(OrderRepository repository,OrderingEventPublisher events,OrderingTransactionBoundary transactions,OrderingClock clock){ this.repository=Objects.requireNonNull(repository); this.events=Objects.requireNonNull(events); this.transactions=Objects.requireNonNull(transactions); this.clock=Objects.requireNonNull(clock); }
public OrderView handle(PlaceOrderCommand c){ return transactions.required(()->{ Order order=repository.findById(c.orderId()).orElseThrow(()->new OrderNotFoundException(c.orderId())); if(order.status().name().equals("PLACED")) return OrderView.from(order); order.place(clock.now()); repository.save(order); events.publish(order.pullDomainEvents()); return OrderView.from(order); }); }
}
Aggregate-Auszug
package com.aydinsude.enterprise.ddd.ordering.domain;
import com.aydinsude.enterprise.ddd.ordering.event.*;
import com.aydinsude.enterprise.ddd.ordering.model.*;
import java.time.Instant;
import java.util.*;
/**
* Pattern: Aggregate Root.
* Zweck: Schützt alle unmittelbar konsistenten Invarianten von Order und OrderLine.
* Einsatzgrenze: Bestand, Zahlung, Rechnung und Versand gehören ausdrücklich nicht in dieses Aggregate.
*/
public final class Order {
private final OrderId id;
private final CustomerId customerId;
private final ShippingAddress shippingAddress;
private final LinkedHashMap<OrderLineId, OrderLine> lines = new LinkedHashMap<>();
private final List<OrderDomainEvent> pendingEvents = new ArrayList<>();
private OrderStatus status;
private long version;
Order(OrderId id, CustomerId customerId, ShippingAddress shippingAddress, Instant createdAt) {
this.id = Objects.requireNonNull(id);
this.customerId = Objects.requireNonNull(customerId);
this.shippingAddress = Objects.requireNonNull(shippingAddress);
this.status = OrderStatus.DRAFT;
this.version = 0L;
register(new OrderDraftCreated(id, customerId, Objects.requireNonNull(createdAt)));
}
public OrderId id() { return id; }
public CustomerId customerId() { return customerId; }
public ShippingAddress shippingAddress() { return shippingAddress; }
public OrderStatus status() { return status; }
public long version() { return version; }
public List<OrderLine> lines() { return List.copyOf(lines.values()); }
public void addLine(OrderLineId lineId, ProductId productId, ProductNameSnapshot productName, Quantity quantity, Money unitPrice, Instant occurredAt) {
ensureDraft();
Objects.requireNonNull(lineId); Objects.requireNonNull(productId); Objects.requireNonNull(productName); Objects.requireNonNull(quantity); Objects.requireNonNull(unitPrice); Objects.requireNonNull(occurredAt);
if (lines.containsKey(lineId)) throw new IllegalArgumentException("duplicate order line id");
ensureCurrencyCompatible(unitPrice);
lines.put(lineId, new OrderLine(lineId, productId, productName, quantity, unitPrice));
changed();
register(new OrderLineAdded(id, lineId, productId, quantity, occurredAt));
}
public void changeQuantity(OrderLineId lineId, Quantity next, Instant occurredAt) {
ensureDraft();
OrderLine line = requireLine(lineId);
Quantity previous = line.quantity();
if (previous.equals(next)) return;
line.changeQuantity(Objects.requireNonNull(next));
changed();
register(new OrderLineQuantityChanged(id, lineId, previous, next, Objects.requireNonNull(occurredAt)));
}
public void removeLine(OrderLineId lineId, Instant occurredAt) {
ensureDraft();
if (lines.remove(Objects.requireNonNull(lineId)) == null) throw new IllegalArgumentException("unknown order line");
changed();
register(new OrderLineRemoved(id, lineId, Objects.requireNonNull(occurredAt)));
}
public Money total() {
if (lines.isEmpty()) return Money.of("0.00", "EUR");
Money sum = Money.of("0.00", lines.values().iterator().next().unitPrice().currency().getCurrencyCode());
for (OrderLine line : lines.values()) sum = sum.add(line.lineTotal());
return sum;
}
public void place(Instant occurredAt) {
ensureDraft();
if (lines.isEmpty()) throw new IllegalStateException("order requires at least one line");
status = OrderStatus.PLACED;
changed();
register(new OrderPlaced(id, lines.size(), total(), Objects.requireNonNull(occurredAt)));
}
public void cancel(CancellationReason reason, Instant occurredAt) {
Objects.requireNonNull(reason); Objects.requireNonNull(occurredAt);
if (status == OrderStatus.CANCELLED) return;
status = OrderStatus.CANCELLED;
changed();
register(new OrderCancelled(id, reason, occurredAt));
}
/** Pattern: Domain Events Collection. Liefert Ereignisse einmalig an den späteren Application Layer. */
public List<OrderDomainEvent> pullDomainEvents() {
List<OrderDomainEvent> result = List.copyOf(pendingEvents);
pendingEvents.clear();
return result;
}
private void ensureDraft() { if (status != OrderStatus.DRAFT) throw new IllegalStateException("only a draft order can be changed"); }
private OrderLine requireLine(OrderLineId id) { OrderLine line = lines.get(Objects.requireNonNull(id)); if (line == null) throw new IllegalArgumentException("unknown order line"); return line; }
private void ensureCurrencyCompatible(Money next) { if (!lines.isEmpty() && !lines.values().iterator().next().unitPrice().currency().equals(next.currency())) throw new IllegalArgumentException("all order lines must use one currency"); }
private void changed() { version++; }
private void register(OrderDomainEvent event) { pendingEvents.add(Objects.requireNonNull(event)); }
@Override public boolean equals(Object other) { return this == other || other instanceof Order that && id.equals(that.id); }
@Override public int hashCode() { return id.hashCode(); }
}
Fachliche Lokalisierung
| Frage | Layered | TDD-First | DDD |
|---|---|---|---|
| Wo liegt Ablaufsteuerung? | Application Service | Use Case | Command Handler / Process Manager |
| Wo liegt Invariante? | Domain plus teilweise Service | Domainobjekt, Policy oder Use Case | Aggregate beziehungsweise Value Object |
| Wo liegt Idempotenz? | Application Service und Repository | expliziter Use Case / Port | Application/Integration; nicht Aggregate-Aufgabe |
| Wo entstehen Events? | Service oder Domain | Domain/Use Case, testgetrieben | Aggregate Domain Events, danach Übersetzung |
| Was ist fachliche Einheit? | meist Entity/Service | Verhalten eines Slices | Aggregate im Bounded Context |
Interpretation
Der Layered-Code ist linear und onboardingfreundlich. Der TDD-Code macht testbare Fehler- und Portgrenzen sehr explizit. Der DDD-Code trennt Orchestrierung und Fachentscheidung am stärksten, benötigt dafür mehr Typen und einen klaren Kontextzuschnitt.