Architekturvergleich

Fachmodell und Codevergleich

Realer Java-21-Code für denselben Bestell-Use-Case.

Java 21LayeredTDD-FirstDDD

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.

Darstellung

Design
Text
Dichte
⌂ Cockpit