Konsolidiertes Gesamtwerk

Architekturvergleich – Gesamtwerk

Layered, TDD-First und DDD in einer einzelnen offlinefähigen Vergleichsdokumentation.

7 Vergleichskapitelreale Projektmetrikenechter Java-CodeEntscheidungshilfe
Grundlage Der Vergleich verwendet den im entsprechenden Kapitel nachgewiesenen Funktionsstand und trennt Reifegrad von Architektureigenschaften.
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

  1. Architektureigenschaften Modularisierung, Abhängigkeitsrichtung, Fachmodell, Transaktionen und Integration.
  2. 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.

Darstellung

Design
Text
Dichte
⌂ Cockpit