Die eine Regel: Abhängigkeiten zeigen nach innen
Clean Architecture wird oft als vier konzentrische Kreise dargestellt, was das Konzept komplizierter aussehen lässt, als es eigentlich ist. Die gesamte Idee basiert auf einer einzigen Einschränkung bei den Imports: Quellcode-Abhängigkeiten zeigen nach innen, in Richtung der Domain. Äußere Layer dürfen innere Layer importieren. Innere Layer dürfen keine äußeren importieren.
Das ist die Dependency Rule, und fast alles andere ist eine Folge daraus. Die Domain – der Code, der festlegt, was eine Bestellung ist und wann sie storniert werden darf – importiert nichts aus dem Framework, dem Datenbanktreiber oder der HTTP-Library. Es ist reiner Code, der in einem Testprozess, einem Skript oder einer völlig anderen Runtime laufen könnte.
Der Grund dafür ist nicht die Reinheit um der Reinheit willen. Es liegt daran, dass die Dinge, die sich am wahrscheinlichsten ändern, außen liegen. Datenbanken werden aktualisiert oder ersetzt, HTTP-Frameworks kommen aus der Mode, ein Zahlungsanbieter wird ausgetauscht. Die Geschäftsregeln ändern sich zwar auch, aber weitaus langsamer und aus anderen Gründen. Wenn die Regeln von den Tools abhängen, reißt jede Tool-Änderung die Regeln mit sich. Wenn die Abhängigkeit nach innen zeigt, bleibt eine Tool-Änderung auf einen Adapter beschränkt.
Dies ist derselbe Instinkt wie bei der Trennung einer Library von ihren Aufrufern. Man möchte, dass der wertvolle, stabile Teil nutzbar und testbar ist, ohne die volatilen Teile mitschleppen zu müssen.
Bei der Regel geht es um Imports, nicht um Ordner. services in domain umzubenennen und Dateien zu verschieben bewirkt nichts, wenn der Code darin immer noch das ORM importiert. Der Test ist mechanisch: Öffnen Sie die innerste Datei und schauen Sie sich die Import-Liste an. Wenn dort eine Datenbank, ein Framework oder ein Vendor genannt wird, ist die Grenze rein dekorativ.
Die Layer und was jeder einzelne wissen darf
Das klassische Diagramm besteht aus vier Ringen. Von innen nach außen:
Entities (oder die Domain). Die Business-Objekte und ihre Invarianten: Ein Order muss mindestens eine Zeile haben, ein Money-Wert darf keine Währungen mischen, eine Rechnung kann nicht zweimal bezahlt werden. Dieser Layer ist am stabilsten und am wertvollsten. Er sollte aus reinem Code bestehen, ohne Imports von außen.
Use cases (der Application Layer). Die Dinge, die das System tut: eine Bestellung aufgeben, ein Abonnement kündigen, ein Passwort zurücksetzen. Ein Use Case orchestriert Entities, um eine bestimmte Absicht zu erfüllen, ruft die benötigten Ports auf und gibt reine Daten zurück. Er enthält den Ablauf, nicht die Regeln.
Interface adapters. Controller, Presenter, Repository-Implementierungen, Serialisierer. Diese übersetzen zwischen den Formaten der Außenwelt und denen der Domain. Ein Controller wandelt einen HTTP-Request in einen Use-Case-Input um; ein Repository wandelt Domain-Objekte in Tabellenzeilen um und zurück.
Frameworks and drivers. Express, Postgres, Redis, das Cloud-SDK. Der äußerste Ring, in dem die meisten Details liegen und wo am wenigsten Design-Überlegung nötig ist. Dieser Layer ist der Kleber.
Die Regel ist einfach: Ein Pfeil darf nach innen zeigen, niemals nach außen. Der Use Case darf OrderRepository aufrufen, da dieses Interface in der Domain definiert ist. Die Domain darf pg.Pool nicht aufrufen, da pg ein externes Detail ist. Wenn Sie den Drang verspüren, einen Datenbank-Typ in einen Use Case zu importieren, ist die Lösung nicht ein besserer Import – sondern ein Port.
Ports und Adapter, die hexagonale Sicht
Die hexagonale Architektur, auch bekannt als Ports and Adapters, vermittelt dasselbe Konzept anhand einer Darstellung, die viele für einfacher anzuwenden finden. Die Applikation steht im Zentrum. Um sie herum befinden sich Ports: Interfaces, die definieren, was die Applikation von außen benötigt und was die Außenwelt von ihr verlangen kann. An diese Ports werden Adapter angeschlossen: konkrete Implementierungen.
Es gibt zwei Arten von Ports:
- Driven Ports (ausgehend) beschreiben, was die Applikation benötigt: ein
OrderRepository, einPaymentGateway, einClock. Die Domain besitzt das Interface; die Infrastruktur implementiert es. - Driving Ports (eingehend) beschreiben, was von der Applikation verlangt werden kann: ein Use-Case-Interface, das von einem Controller oder einem Message Consumer aufgerufen wird.
Der entscheidende Punkt ist die Ownership. Das Interface befindet sich neben dem Code, der es verwendet – in der inneren Schicht – und nicht neben der Implementierung. Das ist es, was Dependency Inversion ermöglicht: Die Domain deklariert OrderRepository, und die Postgres-Klasse importiert die Domain, um dieses zu implementieren. Der Import-Pfeil zeigt nach innen, obwohl der Kontrollfluss-Pfeil nach außen zeigt.
Ein Port sollte durch die Bedürfnisse der Applikation geformt werden, nicht durch die Fähigkeiten der Datenbank. Wenn OrderRepository findByCustomerAndStatusPaginated exponiert, ist das Datenbankschema in das Vokabular der Applikation durchgesickert. Wenn es hingegen findByCustomer exponiert, beschreibt die Applikation, was sie benötigt, und der Adapter kann entscheiden, wie dies erfüllt wird.
Der Composition Root ist das Element, das dieses Konzept zur Laufzeit erst funktionsfähig macht. Interfaces allein verbinden nichts; jemand muss die Implementierung auswählen und übergeben. Diese Entscheidung erfolgt einmalig beim Startup in einer einzigen Datei. Wenn Sie feststellen, dass Sie innerhalb eines Use Case ein PostgresOrderRepository instanziieren, ist die Inversion nur nominell – der Use Case hat seine Abhängigkeit lediglich von einem Import zu einem Konstruktoraufruf verschoben.
Dependency Inversion in der Praxis
Dependency Inversion ist der Mechanismus, nicht das Ziel. Das Ziel ist, dass die Domain den Vertrag (Contract) definiert und die Infrastruktur diesen erfüllt.
Ohne Inversion importiert der Use Case die Repository-Klasse:
import { PostgresOrderRepository } from "../infrastructure/postgres-order-repository.js";
Mit Inversion importiert der Use Case ein Interface, und die konkrete Klasse wird übergeben:
import type { OrderRepository } from "../domain/order-repository.js";
export class PlaceOrder {
constructor(private readonly orders: OrderRepository) {}
}
Das konkrete Repository wird einmalig beim Start in einem Composition Root ausgewählt. Das ist die einzige Datei, die sowohl die Domain als auch die Infrastruktur importiert. Alles andere sieht nur Interfaces.
const orderRepository = new PostgresOrderRepository(pool);
const placeOrder = new PlaceOrder(orderRepository);
Der praktische Nutzen ist sofort spürbar. In der Produktion erhält PlaceOrder ein Postgres-Repository. In einem Unit-Test erhält es einen In-Memory-Fake. Der Code des Use Case ist in beiden Fällen identisch und musste zu keinem Zeitpunkt wissen, welche Implementierung er erhalten hat.
Ein praxisnahes Beispiel: Eine Bestellung aufgeben
Verfolgen wir eine Operation durch die einzelnen Layer.
Der Controller empfängt POST /orders. Er parst den Body in ein einfaches Objekt, validiert, dass die Kunden-ID vorhanden ist und die Positionen korrekt formatiert sind, und ruft den Use Case auf. Er greift nicht auf die Datenbank zu und erstellt auch kein Order selbst.
router.post("/orders", async (req, res) => {
const result = await placeOrder.execute({
customerId: req.body.customerId,
lines: req.body.lines.map((line) => ({
sku: line.sku,
quantity: line.quantity,
unitPrice: Money.fromCents(line.unitPriceCents),
})),
});
res.status(201).json(result);
});
Der Use Case erstellt die Entity mit Order.place, wodurch die Invariante erzwungen wird, dass eine Bestellung mindestens eine Position benötigt. Anschließend ruft er this.orders.save(order) auf – einen Port. Dieser gibt { orderId, totalCents } zurück, also reine Daten, ohne dass die Entity nach außen dringt.
Der Adapter implementiert save. Er öffnet eine Transaktion, führt ein Upsert für die Bestellzeile aus, ersetzt die Positionen und führt einen Commit durch. Er mappt order.lines auf Zeilen und mappt in findById die Zeilen mittels Order.reconstitute wieder zurück. Dieses Mapping ist die Aufgabe des Adapters; der Use Case sieht niemals eine Zeile.
Beachten Sie, was die Domain importiert hat: Money, was ebenfalls Domain-Code ist. Nichts anderes. Beachten Sie, was der Use Case importiert hat: die Domain. Beachten Sie, was der Adapter importiert hat: die Domain und pg. Die Abhängigkeitspfeile zeigen alle nach innen, und das Mapping findet exakt an der Grenze statt.
Clean Architecture ist nicht dasselbe wie DDD
Diese beiden Konzepte werden oft gemeinsam genannt, sind aber nicht identisch.
Domain-Driven Design ist eine Sammlung von Ideen zur Modellierung komplexer Geschäftsprozesse: Entities mit einer Identität, Value Objects ohne eine solche, Aggregates als Konsistenzgrenzen, Repositories für die Persistenz und eine Ubiquitous Language, die von Entwicklern und Domänenexperten gemeinsam genutzt wird. Es geht darum, wie das Domänenmodell aussieht.
Bei Clean Architecture geht es darum, wohin die Abhängigkeiten zeigen. Es wird nicht festgelegt, ob man Aggregates, Domain Events oder eine Ubiquitous Language benötigt.
Man kann das eine ohne das andere einsetzen:
- Eine Clean Architecture App mit einer dünnen, anämischen Domäne behält die Richtung der Abhängigkeiten bei, selbst wenn die Geschäftsregeln schwach ausgeprägt sind.
- Eine DDD App, bei der TypeORM Entities direkt in der Domäne annotiert sind, verfügt über reichhaltige Regeln, aber die falschen Abhängigkeiten.
Beide Konzepte ergänzen sich gut, und diese Kombination ist das, was die meisten Menschen meinen, wenn sie von einem „richtig strukturierten Backend“ sprechen. Wenn Ihre Domäne jedoch einfach ist, ist die Anwendung der Dependency Rule ohne vollständiges DDD ein absolut sinnvolles Ergebnis. Nutzen Sie Value Objects wie Money dort, wo sie echte Bugs verhindern; führen Sie kein Aggregate ein, nur weil es in einem Buch so stand.
Onion, hexagonal und clean: drei Namen, eine Richtung
Teams verwenden diese Begriffe oft so, als handele es sich um konkurrierende Patterns. Tatsächlich sind es drei verschiedene Darstellungen derselben Einschränkung, die über Jahre hinweg veröffentlicht wurden.
- Hexagonal architecture (Cockburn, 2005) stellt die Applikation ins Zentrum, mit dazugehörigen Ports und Adaptern drumherum. Ihr besonderer Beitrag ist die Symmetrie: Sowohl Inbound als auch Outbound sind Adapter.
- Onion architecture (Palermo, 2008) zeichnet konzentrische Schichten und betont, dass das Domain-Modell im Kern sitzt und die Abhängigkeiten nach innen zeigen.
- Clean Architecture (Martin, 2012) benennt vier Ringe und formuliert die Dependency Rule explizit, wobei eine Use-Case-Schicht zwischen den Entities und den Adaptern eingefügt wird.
Das Vokabular unterscheidet sich, die Regel jedoch nicht. In einem Code Review darüber zu streiten, welches Diagramm korrekt ist, verschwendet die Zeit aller Beteiligten. Entscheidend ist, ob die Domain ein Framework importiert und ob das Interface neben seinem Nutzer lebt. Wenn diese beiden Fragen richtig beantwortet werden, wird das Pattern angewendet.
Reads benötigen keine Zeremonie
Ein häufiger Fehler besteht darin, Queries durch dieselben Mechanismen zu schleusen wie Writes. Ein Use Case existiert, um Invarianten zu schützen; eine Query hat keine Invarianten, die geschützt werden müssen. Einen SELECT in eine Entity, einen Use Case und einen Mapper zu hüllen, erzeugt zusätzliche Dateien und Mapping-Fehler, ohne einen Mehrwert zu bieten.
Eine pragmatische Trennung ist gängig: Commands laufen über die Domain und die Ports, während Queries direkt auf ein Read Model zugreifen und DTOs zurückgeben.
// queries/order-summary.ts
export async function getOrderSummary(pool: Pool, orderId: string) {
const { rows } = await pool.query(
`SELECT o.id, o.status, o.total_cents, c.email
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.id = $1`,
[orderId],
);
return rows[0] ?? null;
}
Diese Query importiert pg, und das ist völlig in Ordnung. Es handelt sich um einen Adapter der äußeren Schicht ohne Domain-Logik, sodass die Dependency Rule nichts zu schützen hat. Reads simpel zu halten ist kein Kompromiss, sondern die ehrliche Anwendung des Patterns, denn der Wert eines Domain-Modells liegt in der Durchsetzung von Regeln – und eine Read-Operation setzt keine Regeln durch.
Dies ist der Kern von CQRS, und an diesem Punkt können Sie aufhören. Sie benötigen keine separaten Datenbanken oder Event Projections, um Reads an der Domain vorbeizuführen – nur eine klare Trennung zwischen Operationen, die entscheiden, und Operationen, die lediglich auslesen.
Testen der Domain ohne Datenbank
Der konkretste Vorteil der Dependency-Rule ist die Testgeschwindigkeit. Da die Domain nichts importiert, benötigen ihre Tests auch nichts.
Ein Unit-Test für PlaceOrder erstellt ein In-Memory-Fake-Repository und übergibt dieses. Der Test prüft, ob die Bestellung gespeichert wurde und ob die Gesamtsumme korrekt ist. Er läuft in Mikrosekunden ab und benötigt weder einen Container, noch Migrationen oder ein Netzwerk.
const orders = new InMemoryOrderRepository();
const useCase = new PlaceOrder(orders);
const result = await useCase.execute({
customerId: "cust_1",
lines: [{ sku: "book", quantity: 2, unitPrice: Money.fromCents(1500) }],
});
expect(result.totalCents).toBe(3000);
Entity-Tests sind sogar noch einfacher. Money-Tests prüfen, dass das Hinzufügen verschiedener Währungen einen Fehler auslöst und dass multiply korrekt skaliert. Order-Tests stellen sicher, dass das Aufgeben einer leeren Bestellung einen Fehler wirft und dass ein doppeltes Stornieren harmlos ist. Keiner dieser Tests erwähnt eine Datenbank.
Die echte Datenbank muss natürlich trotzdem getestet werden, aber der Test bezieht sich nun auf den Adapter, nicht auf die Regeln: Schreibt PostgresOrderRepository.save die Zeilen korrekt und rekonstruiert findById die Entity originalgetreu? Das ist ein gezielter Integrationstest pro Adapter – und wenn er fehlschlägt, weiß man, dass das Problem im Mapping liegt und nicht in der Business-Logik.
Genau hier liegt der Kern des Ganzen. Ohne diese Trennung zieht jeder Test einer Regel eine Datenbank mit sich, wodurch Tests langsam, instabil und selten werden. Mit dieser Trennung bleiben die Regeln abgedeckt, während die langsamen Tests wenige und gezielt eingesetzt sind.
Der Preis der Indirektion
Clean Architecture ist nicht kostenlos. Wer so tut, führt dazu, dass Teams sie überall anwenden und sie schließlich hassen.
Mehr Dateien. Ein einzelner Endpoint, der früher aus einem Handler und einer Query bestand, wird nun zu einem Controller, einem Use Case, einem Port, einem Adapter und einem Mapper. Für eine CRUD-Ressource sind das fünf Dateien, wo eine gereicht hätte, und der Trace vom Request bis zur Query wird länger.
Mapping in beide Richtungen. Rows werden zu Entities, Entities werden zu Responses, und manchmal liegen DTOs dazwischen. Das Mapping ist langweilig, repetitiv und anfällig für subtile Fehler – ein fehlendes Feld, eine Währung, die stillschweigend auf einen Standardwert gesetzt wird. Es benötigt eigene Tests, und diese Tests prüfen keinen geschäftlichen Mehrwert.
Ein größeres Vokabular. Ports, Adapter, Use Cases, Composition Roots, DTOs. Ein neuer Entwickler muss erst das Layout lernen, bevor er überhaupt etwas finden kann. Und ein Team, das das Konzept nur halbherzig einführt, bekommt das Schlimmste aus beiden Welten: Indirektion ohne konsistente Grenzen.
Ein falsches Gefühl der Entkopplung. Das Importieren eines Interfaces macht einen nicht unabhängig von der dahinterliegenden Technologie. Wenn Ihr Port SQL-Semantiken exponiert oder Ihre Domain von ON CONFLICT-Verhalten abhängt, sind Sie trotzdem gekoppelt. Das Interface ist eine Nahtstelle, kein Kraftfeld.
Die ehrliche Sichtweise ist, dass dies eine Investition ist. Sie rentiert sich, wenn die Domain komplex genug ist, um sie umfassend zu testen, wenn die Infrastruktur sich wahrscheinlich ändern wird und wenn mehrere Personen klare Grenzen benötigen. Bei einem Einstellungs-Bildschirm rentiert sie sich nicht.
Die Rendite zeigt sich am deutlichsten bei der Wartung. Wenn sich eine Regel ändert, landet die Änderung in einer Entity und einem Test. Wenn eine Query einen Index benötigt, landet die Änderung in einem Adapter. Wenn das Team einen neuen Payment-Provider ausprobieren möchte, schreiben sie einen zweiten Adapter und ändern eine Zeile im Composition Root. Keine dieser Änderungen wirkt sich auf die anderen aus – und genau das ist der gesamte Gewinn aus den zusätzlichen Dateien.
Wo man die Grenze ziehen sollte
Die pragmatische Antwort ist kein Alles-oder-Nichts-Ansatz. Ziehen Sie die Grenze dort, wo eine Geschäftsregel existiert, und halten Sie den Rest simpel.
Eine hilfreiche Heuristik: Wenn eine Operation eine Geschäftsregel hat, die auf eine interessante Weise falsch sein könnte, geben Sie ihr einen Use Case und ein Domain-Objekt. Eine Bestellung aufgeben, einen Rabatt anwenden, ein Abonnement kündigen – diese Vorgänge haben Invarianten, die es wert sind, geschützt zu werden. Wenn eine Operation ein einfacher Lese- oder Schreibvorgang ohne Entscheidungslogik ist, lassen Sie sie eine schlanke Query sein (optional hinter einem kleinen Repository) und hüllen Sie sie nicht in unnötige Zeremonien.
In demselben System können Sie beides haben. Ein PlaceOrder Use Case mit einem komplexen Order Aggregate und einem In-Memory-Fake existiert neben einem GetProductById, das lediglich eine auf ein DTO gemappte Query ist. Diese Asymmetrie schadet niemandem, und die Codebasis bleibt proportional zur Komplexität des Problems.
Seien Sie beim ORM ebenso pragmatisch. Viele Teams behalten ein ORM für Lesezugriffe bei und nutzen handgeschriebene Repository-Adapter für den Schreibpfad der Domain. Andere setzen überall auf rohes SQL und akzeptieren, dass der Adapter das Mapping übernimmt. Die Regel betrifft die Richtung, nicht das Tooling: Solange die Domain das ORM nicht importiert, sind Sie frei zu verwenden, was auch immer der Adapter benötigt.
Der gängigste Mittelweg ist es wert, klar benannt zu werden: Ein Domain-Package mit echten Entities für die zwei oder drei Konzepte, die Regeln enthalten. Ein Use Case pro bedeutsamem Command. Repository-Interfaces nur dort, wo der Schreibpfad sie benötigt. Alles andere – Lesezugriffe, Admin-Screens, Reports – als schlanke Handler über SQL. Das ist kein Kompromiss, sondern das Muster, skaliert auf das jeweilige Problem.
Projektstrukturierung
Das Ordnerlayout sollte die Richtung der Abhängigkeiten auf einen Blick verdeutlichen. Eine gängige Struktur:
src/
domain/
order.ts
money.ts
order-repository.ts
application/
place-order.ts
cancel-order.ts
infrastructure/
postgres-order-repository.ts
stripe-payment-gateway.ts
interfaces/
http/
order-controller.ts
routes.ts
main.ts
Die Namen sind weniger wichtig als die zugrunde liegende Regel. domain importiert nichts aus den anderen Ordnern. application importiert domain. infrastructure und interfaces importieren beides. main.ts verbindet alles miteinander und ist die einzige Datei, die jede Ebene kennen darf.
Wenn Sie Vertical Slices bevorzugen – also einen Ordner pro Feature mit domain, application und infrastructure darin –, funktioniert das ebenfalls und skaliert gut, wenn die Features unabhängig voneinander sind. Was Sie dabei verlieren, ist ein einziger, offensichtlicher Ort für übergreifende Regeln; was Sie gewinnen, ist, dass ein Feature in sich abgeschlossen ist.
Erzwingen Sie die Richtung durch Tooling statt durch Disziplin. ESLint’s no-restricted-imports, dependency-cruiser oder ein Import-Boundary-Plugin können den Build fehlschlagen lassen, wenn domain pg importiert. Eine Regel, die nur eine Konvention ist, ist eine Regel, die an einem Freitagnachmittag um 17 Uhr gebrochen wird.
Egal für welches Layout Sie sich entscheiden, halten Sie den Composition Root explizit und klein. Eine einzelne main.ts, die die konkreten Adapter importiert und die Use Cases konstruiert, ist leicht zu lesen und einfach zu ändern. Wenn die Verdrahtung über Module verteilt ist, die jeweils ihre eigenen Abhängigkeiten konstruieren, kann niemand mehr sagen, mit welcher Datenbank ein Use Case tatsächlich kommuniziert, und die Nahtstelle, die die Architektur versprochen hat, verschwindet.
Was in einen Use Case gehört
Ein Use Case ist eine einzelne Absicht, die als Klasse mit einer öffentlichen Methode ausgedrückt wird: PlaceOrder, CancelOrder, RefundPayment. Seine execute-Methode nimmt einfache Eingaben entgegen, orchestriert die Domain sowie die Ports und gibt einfache Ausgaben zurück. Wenn Sie die Absicht nicht als Verb und Substantiv benennen können, erledigt der Use Case wahrscheinlich zu viel.
Ein Use Case sollte den Ablauf (Flow), nicht die Regeln enthalten. Er bestimmt die Reihenfolge der Schritte – laden, agieren, persistieren, veröffentlichen. Er entscheidet nicht darüber, was eine Bestellung gültig macht; das liegt in der Entity. Diese Trennung ist wichtig, da Regeln über mehrere Use Cases hinweg wiederverwendet werden, Abläufe hingegen meist nicht.
export class CancelOrder {
constructor(
private readonly orders: OrderRepository,
private readonly events: EventPublisher,
) {}
async execute(input: { orderId: string; reason: string }) {
const order = await this.orders.findById(input.orderId);
if (!order) throw new OrderNotFound(input.orderId);
order.cancel(); // the rule lives in the entity
await this.orders.save(order);
await this.events.publish("order.cancelled", { orderId: order.id });
}
}
Was ein Use Case nicht tun sollte: SQL aufbauen, req oder res lesen, HTTP-Statuscodes kennen, E-Mails direkt versenden oder ein Framework importieren. Jedes dieser Dinge ist entweder ein Anliegen der äußeren Schicht oder gehört hinter einen Port. Wenn der Use Case express importiert, ist die Boundary verletzt, unabhängig davon, wie die Ordner benannt sind.
Die Autorisierung ist eine echte Designfrage. Die Überprüfung von Berechtigungen im Use Case hält die Regeln an einem Ort und macht sie testbar; die Überprüfung in einer middleware bedeutet weniger Code, ist aber leichter zu vergessen. Eine praktikable Lösung sind grobe Prüfungen am Edge und Autorisierungen auf Business-Ebene innerhalb des Use Case, da „nur der Besitzer darf stornieren“ eine Regel ist und kein Routing-Anliegen.
Transaktionen, Side Effects und die Boundary
Transaktionen sind ein Anliegen der Infrastruktur, aber deren Boundary ist ein Anliegen der Applikation. Der Use Case weiß, dass ein Satz von Änderungen gemeinsam committet werden muss; er sollte jedoch nicht wissen, dass der Mechanismus in Postgres BEGIN und COMMIT ist.
Zwei Ansätze funktionieren hier gut. Der einfachste ist, jede Repository-Methode für sich transactional zu gestalten, was ausreicht, wenn ein Use Case nur einen einzigen Schreibvorgang durchführt. Wenn ein Use Case jedoch über mehrere Repositories schreibt und die Änderungen atomar sein müssen, führen Sie einen Unit of Work Port ein:
export interface UnitOfWork {
run<T>(work: (repos: Repositories) => Promise<T>): Promise<T>;
}
Der Adapter implementiert diesen mit einer Connection und einer Transaktion, und der Use Case kapselt seine Arbeit in unitOfWork.run. Die Domain besitzt weiterhin das Interface, der Adapter das SQL, und die Atomarität ist explizit in der Application Layer definiert, wo sie hingehört.
Side Effects folgen derselben Regel. Das Versenden einer E-Mail, das Belasten einer Kreditkarte oder das Publizieren eines Events sollte ein Port sein — EmailSender, PaymentGateway, EventPublisher — und kein direkter fetch Aufruf. Das hält den Use Case testbar, da der Fake aufzeichnet, was gesendet worden wäre, und die Wahl des Providers bleibt im Adapter.
Die Reihenfolge von Side Effects im Verhältnis zur Datenbank ist subtil. Ein Use Case, der eine Bestellung speichert und dann ein Event publiziert, hat das Dual-Write-Problem: Der Prozess kann zwischen diesen beiden Schritten abstürzen. Das zuverlässige Pattern besteht darin, das Event in derselben Transaktion wie den State zu schreiben — eine Outbox — und einen Relay für die Publikation zu nutzen. Der Use Case bittet einen EventPublisher, das Event aufzuzeichnen; der Adapter entscheidet, ob dies eine Outbox-Zeile oder eine direkte Publikation bedeutet.
Anämische Domains, Leaky Abstractions und Framework-Typen
Es gibt drei Fehlermuster, die wie Clean Architecture aussehen, es aber nicht sind.
Eine anämische Domain (anaemic domain) ist eine Sammlung von Klassen, die nur aus Feldern, Gettern und Settern bestehen, während die gesamte Logik in Services liegt. Die Ordnerstruktur ist korrekt, die Abhängigkeitspfeile stimmen, aber die Domain ist leer. Das ist kein Desaster – ein Service-Layer über einfachen Daten ist ein legitimes Design –, aber es als „Rich Domain“ zu bezeichnen, ist Selbstbetrug. Meist bedeutet es, dass Invarianten an mehreren Stellen erzwungen und an einer Stelle vergessen werden.
Eine Leaky Abstraction ist ein Port, der durch seine Implementierung geformt wurde. OrderRepository.upsertOnConflict erwähnt Postgres. PaymentGateway.chargeWithStripeToken erwähnt einen Vendor. Ein Port sollte die Sprache der Applikation sprechen: save, findById, charge. Wenn ein Port „leckt“, bedeutet der Austausch des Adapters, dass das Interface und jeder Aufrufer geändert werden müssen, was den eigentlichen Zweck eines Ports zunichtemacht.
Framework-Typen in der Domain sind die direkteste Verletzung der Prinzipien. Eine Entity, die mit @Entity und @Column annotiert ist, oder ein Use Case, dessen Input-Typ ein Express Request ist, hat eine äußere Schicht importiert. Es mag kompilieren und die Tests bestehen, aber die Dependency-Regel ist gebrochen, und das Framework entscheidet nun, wann und wie die Domain konstruiert wird. Halten Sie Decorators und Request-Typen in den äußeren Schichten und übergeben Sie einfache Objekte nach innen.
Teststrategie von innen nach außen
Die Dependency Rule führt ganz natürlich zur Testpyramide. Testen Sie von innen nach außen – dort, wo Tests schnell sind – und arbeiten Sie sich nur so weit nach außen vor, wie es unbedingt nötig ist.
Domain-Tests decken Entities und Value Objects ab. Es handelt sich um reine Unit-Tests ohne I/O. Davon sollte es viele geben, da Fehler in den Geschäftsregeln am kostspieligsten sind. Money lehnt gemischte Währungen ab; Order lehnt eine leere Zeilenliste ab; ein doppeltes Stornieren ist ein No-Op.
Use-Case-Tests decken den Ablauf ab. Konstruieren Sie den Use Case mit In-Memory-Fakes für jeden Port, rufen Sie execute auf und prüfen Sie das Ergebnis sowie die Aufzeichnungen der Fakes. Diese Tests finden fehlende Schritte, falsche Reihenfolgen oder vergessene Speichervorgänge und laufen dennoch in Mikrosekunden.
Adapter-Tests decken das Mapping ab. Lassen Sie diese gegen eine echte PostgreSQL-Instanz in einem Container laufen, schreiben Sie Daten und lesen Sie diese wieder aus, um sicherzustellen, dass die Entity korrekt hin- und herkonvertiert wird (Round-Trip). Hier bemerken Sie beispielsweise, dass status als String zurückkam oder dass eine nullable Spalte eine undefined-Zeile erzeugt hat. In der Regel genügt ein fokussierter Test pro Adapter-Methode.
End-to-End-Tests prüfen die gesamte Verkabelung: eine echte HTTP-Anfrage über den Controller, den Use Case und die Datenbank, mit einer Prüfung der Antwort. Halten Sie diese Tests sparsam. Sie sind langsam, schlagen aus nicht zusammenhängenden Gründen fehl und ihre Aufgabe ist es, zu beweisen, dass der Composition Root korrekt verbunden ist – nicht, die Geschäftsregeln erneut zu testen.
Diese Aufteilung bedeutet: Ein fehlgeschlagener Regel-Test deutet auf die Domain hin, ein fehlgeschlagener Flow-Test auf den Use Case, ein fehlgeschlagener Round-Trip-Test auf den Adapter und ein fehlgeschlagener End-to-End-Test auf die Verkabelung. Diese diagnostische Klarheit ist wertvoller als die reine Anzahl an Tests.
Wann man die Boundary einführen sollte
Es gelingt selten, die Boundaries von Anfang an richtig zu setzen, da man die genauen Regeln zu diesem Zeitpunkt noch nicht kennt. Eine praxisnahe Vorgehensweise ist es, mit schlanken Handlern und einem Data-Access-Modul zu beginnen, zu beobachten, wo sich Logik ansammelt, und erst dann einen Use Case und ein Domain-Objekt auszulagern.
Anzeichen dafür, dass es sich lohnt, eine Boundary einzuführen:
- Dieselbe Regel wird in mehr als einem Handler durchgesetzt.
- Ein Test einer Business-Regel benötigt eine Datenbank oder einen laufenden Server.
- Der Wechsel eines Frameworks oder eines ORM würde bedeuten, dass die Logik angepasst werden muss.
- Eine Funktion vermischt Validierung, Persistenz und externe Aufrufe und ist nur schwer zu benennen.
- Zwei Entwickler kommen ständig in derselben Datei in Konflikt.
Anzeichen dafür, dass man sich den Aufwand sparen sollte:
- Die Operation ist ein einfacher Lese- oder Schreibvorgang ohne Entscheidungslogik.
- Die Regeln ändern sich noch täglich; eine Stabilisierung wäre verfrüht.
- Die gesamte App besteht aus einer Person und einer Handvoll Endpunkten.
Führen Sie die Boundary dort ein, wo der Schmerz am größten ist, und nicht überall gleichzeitig. Eine Codebase mit drei präzise gezogenen Boundaries und viel einfachem Code ist gesünder als eine, in der jede Tabelle ein Aggregate und jeder Aufruf einen Port besitzt.
Best Practices
- Achte darauf, dass die Abhängigkeitspfeile nach innen zeigen, und sorge dafür, dass die Domain nichts importiert.
- Definiere Ports dort, wo sie verwendet werden (in der inneren Schicht), nicht dort, wo sie implementiert werden.
- Gestalte Ports basierend auf den Anforderungen der Anwendung, nicht nach den Möglichkeiten der Datenbank.
- Platziere konkrete Implementierungen in Adaptern und wähle diese ausschließlich im Composition Root aus.
- Mappe an den Grenzen zwischen Rows, Domain-Objekten und DTOs; lass Typen einer Schicht niemals in eine andere übergehen.
- Gib einem Use Case eine einzige Absicht und sorge dafür, dass er einfache Daten zurückgibt.
- Erzwinge Invarianten in der Domain mithilfe von Factories und privaten Konstruktoren, nicht in Controllern.
- Teste Use Cases in Unit-Tests mit In-Memory-Fakes und führe Integration-Tests für Adapter separat aus.
- Halte die Domain frei von Framework-Decorators, ORM-Annotationen und HTTP-Typen.
- Wende das Pattern dort an, wo Geschäftsregeln existieren, und halte einfache CRUD-Operationen schlank.
- Erzwinge Import-Grenzen mithilfe einer Lint-Regel oder dependency-cruiser in der CI/CD.
Häufige Fehler
- Domain-Entities mit ORM-Decorators zu annotieren und dies als Clean Architecture zu bezeichnen.
- Entities oder Datenbankzeilen direkt an Controller zurückzugeben.
- Das Repository-Interface neben der Postgres-Klasse zu definieren, anstatt neben dem Use Case.
- Eine anämische Domain aus Gettern und Settern zu schreiben, während die Logik weiterhin im Service verbleibt.
- SQL-Semantiken in einen Port fließen zu lassen und zu glauben, dass die Layer entkoppelt sind.
- Jede Tabelle und jeden Aufruf zu abstrahieren, wodurch eine CRUD-App zu einem reinen Zeremoniell wird.
- Den Composition Root überall zu platzieren, sodass faktisch nichts invertiert wird.
- Use Cases gegen die echte Datenbank zu testen und dadurch die Geschwindigkeit zu verlieren, die die Aufteilung ursprünglich rechtfertigte.
- Das Mapping zu überspringen und
anyüber eine Boundary hinweg zu übergeben. - Vollständiges DDD, CQRS und Event Sourcing gleichzeitig einzuführen, nur weil es ein Diagramm suggeriert hat.
Wie geht es weiter?
Clean Architecture zieht Grenzen innerhalb einer einzelnen Anwendung. Der Guide zum Modular Monolith zeigt, wie man diese Grenzen zwischen Modulen explizit macht, ohne die Kosten eines Netzwerks in Kauf nehmen zu müssen – für die meisten Systeme ist dies der richtige nächste Schritt. Wenn eine Grenze tatsächlich zu einer eigenständig deploybaren Einheit werden muss, erläutert der Guide zu Microservices, wie Service-Grenzen entstehen und was sich ändert, wenn ein Aufruf remote erfolgt. Wenn die Ports kostengünstig und selbstdokumentierend sein sollen, behandelt der TypeScript-Guide Interfaces und Typen, und PostgreSQL ist die Datenbank, für die die meisten Adapter geschrieben werden.