Jemand in deinem Team hat gerade pg in src/domain/invoice.ts importiert. Der PR kompiliert. Die Tests laufen durch. Der Code-Review ist dreihundert Zeilen lang, und niemand bemerkt es.

Drei Monate später willst du die Domain-Logik in ein gemeinsames package extrahieren. Das geht nicht. Sie hängt von Postgres-Typen, Connection-Pooling-Logik und einem benutzerdefinierten Driver-Wrapper ab, der nur im Monolithen existiert. Das Whiteboard-Diagramm mit den konzentrischen Kreisen ist jetzt ein Witz.

Das ist das Standard-Zerfallsmuster von Clean Architecture ohne automatisierte Durchsetzung. Architekten zeichnen Pfeile nach innen. Entwickler schreiben Imports nach außen. Der Compiler kümmert sich nicht um deine Layer.

Was „Domain importiert keine Infrastruktur“ wirklich bedeutet

In einer geschichteten Architektur fließen dependencies nach innen. Der Domain-Layer in der Mitte definiert Entitäten, Geschäftsregeln und Use Cases. Er hat kein Wissen über HTTP, SQL, Queues oder Dateisysteme.

Die Infrastruktur lebt im äußeren Ring. Sie implementiert Interfaces, die die Domain definiert. Das Repository-Interface lebt in der Domain. Die Postgres-Implementierung lebt in der Infrastruktur. Die Domain ruft save(invoice) auf. Sie ruft niemals pool.query() auf.

Wenn Domain-Code direkt Infrastruktur importiert, passieren zwei Dinge. Erstens wird die Domain ohne laufende Datenbank untestbar. Zweitens kannst du Postgres nie gegen DynamoDB tauschen, ohne Geschäftslogik neu zu schreiben. Der ganze Sinn der Schichtung – Testbarkeit und Austauschbarkeit – verdampft.

Warum Code-Review nicht ausreicht

Jedes Team hat einen Senior Engineer, der schlechte Imports im Review erwischt. Dieser Engineer ist aber auch im Urlaub, krank oder reviewt einen Fünfzig-Dateien-PR um 17 Uhr an einem Freitag.

Menschen sind Pattern-Matcher mit begrenztem RAM. Automatisierte dependency rules sind deterministische Validatoren mit unendlicher Geduld. Der richtige Ort, um architektonische Grenzen durchzusetzen, ist derselbe, an dem du Syntaxfehler durchsetzt: die Build-Pipeline. Wenn es kompiliert, aber den dependency graph verletzt, sollte es fehlschlagen.

Wie dependency-cruiser Layer-Grenzen durchsetzt

Für TypeScript- und JavaScript-Codebases verwandelt dependency-cruiser dein Architekturdiagramm in einen testbaren Regelsatz. Er parsed deinen Import-Graphen und lässt den Build fehlschlagen, wenn eine verbotene Kante auftritt.

Installiere es:

npm install --save-dev dependency-cruiser
npx depcruise --init

Das Init-Skript generiert eine .dependency-cruiser.js-Konfig. Du fügst eine Regel hinzu, die der Domain verbietet, die Infrastruktur zu berühren:

// .dependency-cruiser.js
module.exports = {
  forbidden: [
    {
      name: "domain-cannot-import-infrastructure",
      comment:
        "Domain code must not depend on infrastructure. Move the import to an adapter or repository.",
      severity: "error",
      from: {
        path: "^src/domain",
      },
      to: {
        path: "^src/infrastructure",
      },
    },
    {
      name: "domain-cannot-import-node-modules",
      comment:
        "Domain code must not depend on external I/O libraries.",
      severity: "error",
      from: {
        path: "^src/domain",
      },
      to: {
        dependencyTypes: ["npm"],
        // Allow only pure logic libraries like date-fns or lodash
        pathNot: "^(date-fns|lodash|ramda)",
      },
    },
  ],
  options: {
    doNotFollow: {
      path: "node_modules",
    },
    tsPreCompilationDeps: true,
    tsConfig: {
      fileName: "tsconfig.json",
    },
  },
};

Diese Regel besagt: Jede Datei unter src/domain, die etwas unter src/infrastructure importiert, bricht den Build. Die zweite Regel fängt den pg- oder axios-Import ab, der über node_modules hereinkommt.

Verdrahte es in dein CI:

# .github/workflows/ci.yml
jobs:
  architecture:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
      - run: npm ci
      - run: npx depcruise src

Jetzt schlägt der pg-Import in src/domain/invoice.ts fehl, bevor ein Mensch den PR überhaupt öffnet.

Wie der Fehler aussieht

Wenn ein Entwickler die Regel bricht, bekommt er Output wie diesen:

error domain-cannot-import-infrastructure: src/domain/invoice.ts → src/infrastructure/database/pool.ts
  Domain code must not depend on infrastructure. Move the import to an adapter or repository.

error domain-cannot-import-node-modules: src/domain/invoice.ts → pg
  Domain code must not depend on external I/O libraries.

✖ 2 dependency violations (2 errors, 0 warnings). Showing only the first 2.

Die Fehlermeldung sagt ihm, was zu tun ist. Verschiebe den Datenbankaufruf in ein Repository im Infrastructure-Layer. Übergib das Repository als Interface an den Use Case. Der Domain-Code bleibt rein.

Alternativen für andere Ökosysteme

Nicht jeder nutzt TypeScript. Das Prinzip ist überall dasselbe. Nur das Tooling ändert sich.

Java: ArchUnit ist der Goldstandard. Du schreibst einen Test, keine Config-Datei:

@ArchTest
static final ArchRule domain_should_not_access_infrastructure =
    noClasses()
        .that()
        .resideInAPackage("..domain..")
        .should()
        .dependOnClassesThat()
        .resideInAPackage("..infrastructure..");

Das läuft als JUnit-Test, integriert sich also mit Maven und Gradle ohne neues CI-Plumbing.

C#: NetArchTest bietet denselben API-Stil für .NET. Schreibe einen Unit-Test, führe ihn im CI aus.

Go: Es gibt keine reife Entsprechung. Die meisten Teams setzen das mit einem einfachen Shell-Skript durch:

#!/bin/bash
# scripts/check-domain-imports.sh

if grep -r "infrastructure/" internal/domain/ ; then
  echo "Domain code imports infrastructure. Fix the dependency direction."
  exit 1
fi

Es ist grob, aber es funktioniert. Viele Go-Teams nutzen auch custom go vet-Analyzers oder das go/ast-package, um ordentliche Checks zu bauen.

Build-Systeme: Bazel, Gradle und Nx unterstützen module dependency graphs nativ. Wenn du Domain und Infrastruktur als separate Module mit expliziten dependency declarations definierst, setzt das Build-Tool die Grenze kostenlos durch. Der Haken: Du musst dich erst in das Build-System einbringen.

Abwägungen: False Positives, Migrations-Schmerzen und Team-Reibung

Automatisierte Regeln sind nicht umsonst. Du solltest die Kosten kennen, bevor du sie anschaltest.

False Positives passieren, wenn du legitime Shared Utilities hast, die zwischen den Layern sitzen. Ein String-Slugifier oder eine benutzerdefinierte Error-Basisklasse könnte in src/shared leben. Wenn sowohl Domain als auch Infrastruktur sie importieren, ist die Regel in Ordnung. Wenn du sie in src/infrastructure/utils legst und die Domain sie importiert, wird dependency-cruiser sich beschweren. Der Fix ist meist, den Shared Code zu verschieben – was ohnehin oft der richtige Schritt ist.

Migrations-Schmerzen sind real. Wenn deine Codebase die Regel bereits an dreißig Stellen verletzt, kannst du nicht einfach den Schalter umlegen ohne heroisches Refactoring. Der pragmatische Ansatz ist, mit Warnungen zu beginnen, die bestehenden Verletzungen über einen Sprint zu beheben und dann auf Errors hochzustufen. Alternativ nutze pathNot-Ausnahmen für bekannte Legacy-Dateien und verbiete neue Verletzungen ab Tag eins.

Team-Reibung ist die versteckte Kosten. Entwickler, die noch nie mit strikter Layer-Durchsetzung gearbeitet haben, werden zurückschieben. Sie werden argumentieren, dass ein kleiner Import harmlos ist, dass die Regel bürokratisch ist, dass sie sie ausbremst. Das ist ein kulturelles Signal, kein technisches. Dieselben Entwickler werden auch diejenigen sein, die um 23 Uhr express in ein Domain-Modell importieren, um ein Feature auszuliefern. Die Regel existiert, weil Menschen unter Druck Abkürzungen nehmen.

Wie man das in einer bestehenden Codebase implementiert

Du brauchst kein großes Rewrite. Du brauchst eine Grenze und eine Möglichkeit, sie zu messen.

  1. Zeichne die Layer. Entscheide, welche Verzeichnisse Domain, Application und Infrastruktur sind. Schreibe es in ARCHITECTURE.md auf.

  2. Füge dependency-cruiser (oder das Äquivalent deines Ökosystems) mit einer Regel hinzu: Domain kann Infrastruktur nicht importieren.

  3. Führe es aus. Zähle die Verletzungen. Wenn die Zahl klein ist, behebe sie, bevor du die Config mergest. Wenn sie groß ist, füge die Legacy-Pfade zu pathNot-Ausnahmen hinzu.

  4. Lass es im CI fehlschlagen. Nicht optional. Keine Warnung. Ein Error, der den Merge blockt.

  5. Jedes Mal, wenn ein Entwickler auf den Fehler trifft, hilf ihm, den Import zu verschieben. Sag ihm nicht einfach, dass der Build rot ist. Erkläre, wo der Code leben sollte und warum.

Innerhalb von zwei Wochen wird das Team aufhören, pg in Domain-Dateien zu importieren. Das Architekturdiagramm an der Whiteboard wird endlich mit dem Code im Repository übereinstimmen.

FAQ

Soll der Application-Layer Infrastruktur importieren dürfen?

Normalerweise ja. Der Application-Layer orchestriert Use Cases. Er kann von Repositories und externen Services wissen, sollte aber von Interfaces und nicht von konkreten Implementierungen abhängen. Wenn du strikte Durchsetzung willst, füge eine zweite Regel hinzu: application kann infrastructure-Implementierungen nicht importieren, nur deren Interfaces.

Was ist mit Domain Events?

Domain Events sind eine gängige Schlupflücke. Ein Domain Event ist reiner Datensatz, also gehört es in den Domain-Layer. Der Infrastructure Event Bus, der es published, gehört in die Infrastruktur. Der Application-Layer subscribed Domain-Event-Handler an den Bus. Die Domain selbst weiß nie, dass der Bus existiert.

Kann ich das mit Nx oder Turborepo nutzen?

Ja. Nx hat eingebaute Module-Boundary-Rules, die auf Projektebene funktionieren. Wenn deine Domain und Infrastruktur separate Nx-Libraries sind, kannst du die Regel ohne zusätzliche Tools durchsetzen. Gleiches gilt für Bazel und Gradle.

Was, wenn ich ein Utility aus der Infrastruktur brauche?

Das tust du nicht. Verschiebe das Utility in ein gemeinsames common- oder kernel-package, das keine Infrastruktur-dependencies hat. Wenn es wirklich Infrastruktur braucht, ist es kein Utility. Es ist Infrastruktur.

Fazit

Clean Architecture ohne automatisierte Durchsetzung ist ein Gentlemen’s Agreement. Gentlemen’s Agreements überleben keine Produktions-Deadlines.

dependency-cruiser, ArchUnit oder sogar ein Shell-Skript mit grep verwandeln deine Architektur aus einer Empfehlung in eine Garantie. Die Domain bleibt rein. Die Infrastruktur bleibt austauschbar. Und das nächste Mal, wenn jemand versucht, pg in eine Geschäftsregel zu importieren, schlägt der Build fehl, bevor der PR überhaupt einen menschlichen Reviewer erreicht.

Fange mit einer Regel an. Mach sie im CI rot. Behebe die erste Verletzung. Der Rest wird folgen.