Architekturvergleich – Gesamtwerk
Layered, TDD-First und DDD in einer einzelnen offlinefähigen Vergleichsdokumentation.
Architekturvergleich – Überblick
Architekturvergleich – Überblick
Vergleichsgrundlage
Die drei Projekte implementieren dieselbe Order-to-Cash-Referenzfachlichkeit. Die fachliche Gleichheitsprüfung hat den tatsächlich nachgewiesenen Funktionsumfang getrennt ausgewiesen: 7 Szenarien sind in allen drei Varianten vollständig gleich, 7 teilweise angeglichen und 22 besitzen mindestens eine Lücke.
Der Vergleich trennt deshalb zwei Ebenen
- Architektureigenschaften Modularisierung, Abhängigkeitsrichtung, Fachmodell, Transaktionen und Integration.
- Reifegrad des konkreten Projekts tatsächlich implementierte und nachgewiesene Szenarien.
Keine falsche Gleichsetzung
Layered Architecture und Domain-Driven Design strukturieren ein System. Test-Driven Development beschreibt dagegen den Entwicklungszyklus. Die TDD-First-Variante ist in diesem Labor eine modulare, ports-orientierte Architektur, deren Form schrittweise aus Tests und Refactorings entstanden ist. TDD kann in einem realen Projekt sowohl mit Layered Architecture als auch mit DDD kombiniert werden.
Tatsächlicher Projektumfang
| Variante | Maven-Module | Hauptquellen | Testquellen | Hauptcode-Zeilen | Testcode-Zeilen | Verifizierte Szenarien |
|---|---|---|---|---|---|---|
| Layered | 7 | 105 | 23 | 2541 | 724 | 20 |
| TDD-First | 3 | 213 | 26 | 2451 | 385 | 27 |
| DDD | 13 | 150 | 17 | 1161 | 261 | 11 |
Die Zahlen sind keine Qualitätsrangliste. Viele kleine Klassen können explizite Verantwortungen oder unnötige Zersplitterung bedeuten; wenige Klassen können fokussiert oder überladen sein. Entscheidend ist die Passung zur Domäne und zum Änderungsdruck.
Charakter in einem Satz
- Layered leicht lesbare technische Schichten und zentral orchestrierte Use Cases.
- TDD-First schneller Feedbackzyklus, vertikale Slices und durch Tests entstandene Ports.
- DDD explizite Fachsprache, kleine Aggregate und contextbezogene Ownership.
Wichtigstes Ergebnis
Es gibt keinen allgemeinen Sieger. Für ein kleines, stabiles Anwendungssystem ist DDD häufig unnötig teuer. Für eine komplexe Kerndomäne mit mehreren Teams ist eine rein technische Layer-Struktur häufig zu schwach. Bei hoher Änderungsrate ist TDD unabhängig von der gewählten Architektur ein besonders wirksamer Entwicklungsansatz.
Modulgrenzen und Abhängigkeiten
Modulgrenzen und Abhängigkeitsrichtung
Layered
Die sieben Module folgen technischen Verantwortungen: Domain, Application, Persistence, Integration, REST, Bootstrap und Architecture Tests. Der zentrale Vorteil ist eine sofort erkennbare Aufrufrichtung. Der zentrale Risikopunkt ist, dass fachlich unabhängige Bereiche über gemeinsame Application Services oder ein gemeinsames Datenmodell gekoppelt werden.
REST ──> Application ──> Domain
^
Persistence ───┘
Integration ───┘
Bootstrap komponiert alle Adapter
TDD-First
Die drei Module bündeln Walking Skeleton, technische Adapter und Qualität. Innerhalb des Walking Skeletons liegen vertikale fachliche Slices und Ports. Die Modulzahl ist klein, während die Zahl der schmalen Klassen hoch ist. Die Grenzen entstanden nicht zuerst als Organigramm, sondern wurden nach wiederkehrenden Test- und Refactoring-Signalen herausgearbeitet.
Acceptance Test ──> Use Case ──> Port
^ |
HTTP Handler ────────────┘ └── Adapter
DDD
Dreizehn Module bilden Value Objects, Aggregate, drei Domain- und drei Application-Bereiche, Integrationsverträge, Adapter, Workflow und Kontexttests ab. Die Abhängigkeitsrichtung ist pro Bounded Context lokal. Kontextübergreifende Zusammenarbeit erfolgt über veröffentlichte Verträge und einen Process Manager.
Ordering Application ──> Ordering Domain
Inventory Application ─> Inventory Domain
Payment Application ───> Payment Domain
|
└── Published Language / Outbox / Process Manager
Vergleich
| Kriterium | Layered | TDD-First | DDD |
|---|---|---|---|
| primäre Grenze | technische Schicht | testgetriebener Slice und Port | Bounded Context und Aggregate |
| kleinste Änderungseinheit | Service plus Adapter | Use Case Slice | fachliche Fähigkeit im Context |
| Datenhoheit | häufig zentral | je Port pragmatisch | explizit je Context |
| verbotene Kopplung | äußere zu falscher Schicht | Test-/Adapterdetails zum Kern | direkte Context-zu-Context-Modellkopplung |
| typische Gefahr | horizontale Änderung durch viele Schichten | zu viele schmale Typen oder testgetriebene Lokallösung | hohe Modellierungs- und Integrationskosten |
Bewertung
Layered besitzt die geringste Einstiegshürde. TDD-First reduziert Änderungsangst und lässt Grenzen aus Verhalten entstehen. DDD liefert die stärksten fachlichen Ownership-Grenzen, verlangt aber konsequente Sprache, Integration und Governance.
Entwicklungswege
Entwicklungswege
Layered: Struktur zuerst
Die Layered-Variante beginnt mit der vorgesehenen Modul- und Schichtenstruktur. Fachmodelle und Services werden anschließend in diese Struktur eingeordnet. Das ist planbar und für bekannte Anforderungen effizient. Ein falsch gewählter Schnitt bleibt jedoch leicht lange bestehen, weil die technische Struktur früh stabil wirkt.
TDD-First: Verhalten zuerst
Jeder Slice startet mit einem beobachtbaren Fehler. Die minimale Implementierung macht den Test grün, danach wird duplizierte oder schlecht platzierte Verantwortung refaktoriert. Architektur ist ein Ergebnis wiederholter Designentscheidungen unter Testschutz.
Akzeptanztest rot
↓
Use-Case-Test rot
↓
kleinste Implementierung
↓
Refactoring von Namen, Ports und Grenzen
↓
Adapter- und Architekturtest
DDD: Sprache und Grenze zuerst
Die DDD-Variante beginnt ohne technische Aggregate. Fachliche Erkundung, Ubiquitous Language, Subdomains, Bounded Contexts und Context Map werden vor der taktischen Implementierung entwickelt. Erst danach entstehen Value Objects, Aggregate und Application Ports.
Problemraum → Sprache → Subdomains → Bounded Contexts
→ Context Map → Value Objects → Aggregate → Application Layer
→ Published Language → Outbox / Inbox / Process Manager
Was wird früh optimiert?
| Variante | früh optimiert | spät sichtbar werdendes Risiko |
|---|---|---|
| Layered | Verständlichkeit der technischen Struktur | fachliche Kopplung und Service-Wachstum |
| TDD-First | korrektes Verhalten und Feedbackgeschwindigkeit | lokale Optimierung ohne strategisches Zielbild |
| DDD | fachliche Grenzen und langfristige Ownership | Overengineering bei geringer Domänenkomplexität |
Kombinationsregel
Für eine komplexe Kerndomäne ist die stärkste reale Kombination häufig DDD für strategische und taktische Grenzen, TDD für die Entwicklung innerhalb dieser Grenzen und Layering beziehungsweise Ports für die technische Abhängigkeitsrichtung. Die drei Laborprojekte trennen die Schwerpunkte nur, damit Unterschiede sichtbar bleiben.
Fachmodell und Codevergleich
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.
Tests und Qualität
Tests und Qualität
Layered
Die Referenz nutzt Domain-, Application-, Persistenz-, REST- und Architekturtests. ArchUnit schützt die Schichten. Qualitätsprofile umfassen Coverage, Mutation, statische Analyse, Formatierung und SBOM. Die Teststrategie folgt der bereits definierten Architektur.
TDD-First
Tests sind nicht nur Verifikation, sondern Entstehungsmechanismus. Akzeptanztests geben den äußeren Vertrag vor, Use-Case-Tests treiben Fachverhalten, Adapter Contract Tests sichern technische Implementierungen. Refactoring-Chronik und Test-Smell-Audit zeigen, wie Tests selbst verbessert wurden.
DDD
Aggregate Tests prüfen Invarianten und Ereignisse. Application-Smoke-Tests prüfen Commands, Queries, Ports und Transaktionen. Integrationstests prüfen Published Language, ACL, Outbox, Inbox und Process Manager. Architekturtests verhindern Context-Importe und schützen Domain-Unabhängigkeit.
Tatsächlicher Stand
| Variante | Testquellen | Testcode-Zeilen | direkt verifizierte Szenarien | teilweise | nicht implementiert |
|---|---|---|---|---|---|
| Layered | 23 | 724 | 20 | 3 | 13 |
| TDD-First | 26 | 385 | 27 | 1 | 8 |
| DDD | 17 | 261 | 11 | 8 | 17 |
Die Zahl der Testzeilen ist nicht mit Testqualität gleichzusetzen. TDD-First verwendet zusätzlich wiederverwendbare Testhilfen und viele schmale Produktionsklassen. DDD fokussiert im aktuellen Stand tief auf drei Core Contexts, während Supporting Contexts absichtlich fehlen.
Typische Testkopplung
| Risiko | Layered | TDD-First | DDD |
|---|---|---|---|
| Controller- und Service-Mocking | mittel | bewusst reduziert | gering im Domainkern |
| Tests gegen Implementierungsdetails | mittel | zentrales Refactoring-Thema | bei Aggregate-Interna möglich |
| langsame Integrationssuite | mittel | über Contract Tests kontrolliert | hoch bei vielen Contextflüssen |
| falsches Sicherheitsgefühl durch Coverage | möglich | durch Mutation und Test-Smell-Audit adressiert | durch Invarianten- und Contract-Nachweise adressiert |
Empfehlung
Coverage ist nur ein Warnsignal. Für alle Varianten sind entscheidend: beobachtbare Akzeptanzszenarien, belastbare Invarianten, wenige fragile Mocks, Adapterverträge und automatisierte Architekturgrenzen.
Persistenz, Transaktionen und Integration
Persistenz, Transaktionen und Integration
Layered
JPA-Modelle und Data Mapper liegen im Persistence-Modul. Repository-Adapter implementieren Application Ports. Transaktionen werden durch einen abstrahierten Transaction Runner geöffnet. Pessimistische Sperren schützen Reservierungen; eine Outbox transportiert Business Events zu Kafka beziehungsweise Redpanda.
TDD-First
Technische Adapter wurden erst nach stabilen Use Cases eingeführt. JDBC-, HTTP- und Messaging-Adapter erfüllen zuvor durch Fakes definierte Ports. Contract Tests prüfen dieselbe Semantik gegen In-Memory- und technische Implementierungen. Das minimiert Frameworkeinfluss auf frühe Fachtests.
DDD
Jeder Context besitzt eigene Application Ports. Domain Events werden an der Contextgrenze in versionierte Integration Events übersetzt. Outbox, Inbox, idempotenter Consumer und Process Manager koordinieren einen eventual-consistenten Order-Fulfillment-Ablauf. Lokale Transaktionen enden an der Contextgrenze.
Transaktionsvergleich
| Thema | Layered | TDD-First | DDD |
|---|---|---|---|
| lokale Transaktion | Application Service | Use Case / Unit of Work | Handler je Context |
| Cross-Domain-Prozess | zentraler Service oder Eventfluss | sliceweise Orchestrierung | Process Manager / Saga-ähnlich |
| Datenmodell | zentral relational | adapterabhängig, durch Ports verdeckt | je Context eigene Hoheit |
| Eventpublikation | transaktionale Outbox | Outbox-Adapter mit Contract Tests | Domain Event → ACL → Published Language → Outbox |
| Parallelität | Locks und Versionen | idempotente Ports und Adapterregeln | Aggregate-Versionen plus Inbox/Outbox |
| Kompensation | Application-Prozess | explizite Recovery-Slices | Workflow und Integrationsereignisse |
Betriebsfolgen
Layered besitzt die geringste verteilte Komplexität, solange ein gemeinsames Daten- und Transaktionsmodell genügt. TDD-First macht technische Austauschbarkeit besonders gut testbar. DDD bietet die stärkste Autonomie, bezahlt sie aber mit mehr Verträgen, Observability-Bedarf und eventual-consistency-spezifischen Fehlerfällen.
Entscheidungshilfe
Entscheidungshilfe
Profil A: kleine bis mittlere Geschäftsanwendung
Empfehlung Layered, ergänzt um konsequente Tests und klare Modulregeln.
Geeignet bei stabilen Anforderungen, einem Team, gemeinsamer Datenbank und überschaubarer Fachlogik. DDD-Terminologie und Eventorchestrierung wären hier häufig teurer als ihr Nutzen.
Profil B: hohe Änderungsrate oder unsichere Anforderungen
Empfehlung TDD als Entwicklungsstandard; Architektur je nach Domänenkomplexität Layered, Modular Monolith oder DDD.
TDD-First ist besonders wertvoll, wenn Fachregeln häufig wechseln, Regression teuer ist oder technische Adapter austauschbar bleiben müssen.
Profil C: komplexe Kerndomäne und mehrere Teams
Empfehlung DDD für Sprache, Bounded Contexts und Ownership; TDD innerhalb jedes Contexts.
Geeignet, wenn unterschiedliche Fachbereiche eigene Regeln und Datenhoheit besitzen und Integrationskosten ohnehin unvermeidbar sind.
Profil D: Legacy-Modernisierung
Empfehlung mit Characterization Tests und TDD-Slices beginnen; fachliche Contextgrenzen schrittweise entdecken. Nicht sofort das gesamte System in Aggregate und Events umbenennen.
Qualitative Eignungsmatrix
| Entscheidungssituation | Layered | TDD-First | DDD |
|---|---|---|---|
| schneller Start mit bekanntem CRUD-Prozess | hoch | hoch | niedrig |
| häufig wechselnde Regeln | mittel | hoch | hoch mit TDD |
| komplexe fachliche Invarianten | mittel | hoch | sehr hoch |
| mehrere autonome Fachteamgrenzen | niedrig bis mittel | mittel | sehr hoch |
| einheitliche Datenbanktransaktion | sehr hoch | hoch | nur innerhalb eines Contexts |
| schrittweise Legacy-Ablösung | mittel | sehr hoch | hoch nach Discovery |
| geringe Betriebs- und Integrationskomplexität | sehr hoch | hoch | mittel bis niedrig |
| explizite langfristige Datenhoheit | mittel | mittel | sehr hoch |
Warnsignale
Layered nicht ausreichen lassen, wenn
- Services fachlich unzusammenhängende Regeln sammeln,
- jede Änderung viele technische Schichten gleichzeitig berührt,
- mehrere Teams dieselben Tabellen und Entitäten verändern.
TDD nicht missverstehen
- Tests ersetzen keine strategische Architekturentscheidung,
- ein grüner lokaler Test beweist keine gute Contextgrenze,
- zu viele Mocks können das Design verschlechtern.
DDD nicht einsetzen, wenn
- die Domäne überwiegend CRUD und Workflow-Konfiguration ist,
- keine Fachpersonen an Sprache und Modell mitarbeiten,
- Team und Betrieb die zusätzlichen Verträge nicht tragen können.
Schlussentscheidung
Für das Labor ist keine Variante pauschal besser. Die sinnvollste Enterprise-Zielarchitektur wäre eine modulare, fachlich geschnittene Anwendung mit DDD dort, wo die Domäne komplex ist, TDD als durchgängiger Entwicklungsweise und Layering/Ports als technische Abhängigkeitsregel.