Deine Test-Suite verifiziert, dass calculateTotal bei korrekter Eingabe 42 zurückgibt. Sie verifiziert nicht, ob src/domain/Invoice.ts src/infrastructure/Database.ts importieren darf. Der Compiler ist mit beidem zufrieden. Deine Unit-Tests sind mit beidem zufrieden. Aber eine davon ist ein Architektur-Verstoß, der dir in sechs Monaten eine Woche Refactoring kosten wird.

Das ist der blinde Fleck. Wir schreiben Tests für Logik und nehmen an, dass die Struktur sich von selbst regelt. Tut sie nicht.

Was ein Architektur-Test wirklich ist

Ein Architektur-Test ist eine Assertion über die Struktur deines Codes, nicht über sein Verhalten. Er prüft, ob der Dependency-Graph mit dem Design übereinstimmt, auf das ihr euch geeinigt habt. Er lässt den Build fehlschlagen, wenn ein Entwickler den falschen Layer importiert, eine zyklische dependency erzeugt oder eine Repository-Klasse ohne das standardisierte Suffix benennt.

Das sind keine Lint-Regeln. Linting findet Style-Verstöße. Architektur-Tests finden strukturelle Verstöße. Der Unterschied ist wichtig, weil Struktur Semantik hat. Ein Modul, das seinen Parent importiert, ist kein Style-Problem. Es ist ein Design-Problem.

Die meisten Teams dokumentieren diese Regeln in einem Wiki, einer README oder einer vom Tech-Lead angepinnten Slack-Nachricht. Dokumentation ist fürs Onboarding nützlich. Für Durchsetzung ist sie nutzlos. Ein Architektur-Test verschiebt die Regel aus dem menschlichen Gedächtnis in die Build-Pipeline – dort kostet das Vergessen einen roten Build statt eines Production-Incidents.

Was du mit einem Test durchsetzen kannst

Der offensichtliche Anwendungsfall ist die Dependency-Direction. Domain sollte Infrastructure nicht importieren. UI sollte nicht direkt auf Data Access zugreifen. Diese Regeln lassen sich sauber auf Package- oder Verzeichnis-Grenzen abbilden.

Aber Architektur-Tests können mehr als Imports prüfen. Hier sind Muster, die in Production-Codebases wirklich wichtig sind.

Zyklische dependencies. Ein Package, das sich selbst über eine Kette von drei anderen Packages importiert, ist immer noch zyklisch. Deine Augen finden das im Code Review nicht. Ein Test, der den Import-Graph traversiert, schon.

Naming Conventions. Wenn dein Team entschieden hat, dass jede Repository-Implementierung mit Repository enden muss, kann ein Test das durchsetzen. Das klingt pedantisch, bis jemand UserDao und UserRepo in derselben Codebase erzeugt und neue Engineers nicht wissen, welches sie verwenden sollen.

Verbotene dependencies zu bestimmten Libraries. Vielleicht darf dein Domain-Layer nicht von axios, pg oder fs abhängen. Vielleicht darf dein Frontend lodash nicht importieren, weil ihr auf native Methoden standardisiert. Ein Architektur-Test kann prüfen, dass ein bestimmtes Package nie im Dependency-Tree eines bestimmten Moduls auftaucht.

Annotation- und Vererbungs-Regeln. In Java kannst du testen, dass keine Klasse in ..domain.. @Autowired verwendet. In C# kannst du testen, dass keine Klasse in Infrastructure ein Interface aus Application implementiert. Das sind strukturelle Constraints, die statische Analyse allein ohne Domain-Knowledge nicht ausdrücken kann.

Wie man einen schreibt

Die besten Architektur-Tests sehen aus wie gewöhnliche Unit-Tests. Sie laufen in deinem bestehenden Test-Runner. Sie erscheinen im selben CI-Job wie deine anderen Tests. Der einzige Unterschied ist, worauf sie prüfen.

Java mit ArchUnit:

import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;

import static com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.slices;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;

@AnalyzeClasses(packages = "com.mycompany")
class ArchitectureTest {

    @ArchTest
    static final ArchRule no_cycles =
        slices()
            .matching("com.mycompany.(*)..")
            .should().beFreeOfCycles();

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

Das ist ein JUnit-Test. Er läuft mit mvn test. Er lässt den Build fehlschlagen. Kein spezieller CI-Job nötig.

C# mit NetArchTest:

using NetArchTest.Rules;
using Xunit;

public class ArchitectureTests
{
    [Fact]
    public void Domain_Should_Not_Depend_On_Infrastructure()
    {
        var result = Types.InCurrentDomain()
            .That()
            .ResideInNamespace("MyApp.Domain")
            .ShouldNot()
            .DependOnAny(Types.InNamespace("MyApp.Infrastructure"))
            .GetResult();

        Assert.True(result.IsSuccessful);
    }
}

Python mit import-linter:

Python hat keine ausgereifte ArchUnit-Entsprechung, aber import-linter bietet eine deklarative Config, die wie ein Test funktioniert:

# .importlinter
[importlinter:contract:domain-independent]
name = Domain does not import infrastructure
type = forbidden
source_modules =
    myapp.domain
forbidden_modules =
    myapp.infrastructure

Führe es mit lint-imports im CI aus. Bei einem Verstoß beendet es sich mit einem Exit-Code ungleich Null.

Go: Schreib deinen eigenen

Go hat keine gängige Architektur-Testing-Library. Die meisten Teams schreiben einen kleinen Test, der den AST durchläuft:

import (
    "strings"
    "testing"

    "golang.org/x/tools/go/packages"
)

func TestDomainDoesNotImportInfrastructure(t *testing.T) {
    cfg := &packages.Config{
        Mode: packages.NeedImports | packages.NeedFiles,
    }
    pkgs, err := packages.Load(cfg, "./domain/...")
    if err != nil {
        t.Fatal(err)
    }

    for _, pkg := range pkgs {
        for path := range pkg.Imports {
            if strings.Contains(path, "/infrastructure/") {
                t.Errorf("domain package %s imports infrastructure: %s", pkg.PkgPath, path)
            }
        }
    }
}

Es sind zwanzig Zeilen. Er lebt in deiner Test-Suite. Er läuft bei jedem PR. Darum geht es. Du brauchst kein Framework. Du brauchst eine Assertion.

Die Trade-offs, über die niemand spricht

Architektur-Tests sind nicht umsonst. Sie führen eine neue Kategorie von Build-Failures ein, und neue Kategorien von Build-Failures erzeugen immer Friction.

Sie sind langsamer als Unit-Tests. Ein Architektur-Test, der jeden Import in einer 500.000-Zeilen-Codebase scannt, braucht Zeit. Nicht Stunden. Sekunden, manchmal Zehnersekunden. Aber das ist eine Größenordnung langsamer als ein typischer Unit-Test. Wenn du sie im selben Job wie deine schnellen Unit-Tests laufen lässt, verlierst du den Feedback-Loop, der Unit-Tests wertvoll macht.

Der pragmatische Split besteht darin, Architektur-Tests in einem dedizierten CI-Job laufen zu lassen, oder sie als Integration-Tests zu taggen und nach dem Bestehen der schnellen Suite auszuführen. Die Regel blockiert immer noch den Merge. Sie verlangsamt nur dein lokales npm test nicht.

False Positives passieren, wenn die Regel zu breit ist. Wenn du alle Imports aus node_modules in deinem Domain-Layer verbietest, zerstörst du legitime Verwendungen von date-fns oder zod. Regeln brauchen Ausnahmen, und Ausnahmen brauchen Maintenance. Eine Regel mit dreißig pathNot-Einträgen setzt keine Architektur durch. Sie kodiert dein aktuelles Chaos.

Sie können falsche Sicherheit geben. Ein bestandener Architektur-Test bedeutet nicht, dass dein Design gut ist. Er bedeutet, dass dein Design mit den Regeln übereinstimmt, die du geschrieben hast. Wenn die Regeln falsch sind, sind die Tests nur automatisierter Cargo-Cult.

Wie du sie hinzufügst, ohne CI zu zerstören

Fang nicht mit zehn Regeln an. Fang mit einer an. Wähle die Dependency-Direction, die dir am meisten Schmerzen bereitet hat. Vielleicht ist es Domain, die Infrastructure importiert. Vielleicht ist es dein Frontend, das direkt Backend-Code importiert.

Schreib den Test. Führe ihn lokal aus. Zähle die Failures. Wenn die Zahl null ist, bist du entweder sehr diszipliniert oder die Regel fängt nicht das, was du denkst. Verifiziere mit einem bewussten Verstoß.

Wenn die Zahl fünfzig ist, hast du eine Wahl. Behebe alle fünfzig in einem heroischen PR, oder füge Ausnahmen für bestehende Verstöße hinzu und verbiete neue. Die zweite Option ist weniger befriedigend und nachhaltiger.

Lass den Test den CI-Build fehlschlagen. Nicht warnen. Fehlschlagen. Eine Warnung ist eine Regel, die Engineers lernen zu ignorieren.

FAQ

Ersetzen Architektur-Tests das Code Review?

Nein. Sie automatisieren die Teile des Code Reviews, in denen Menschen schlecht sind, wie das Erkennen transitiver Imports über zwanzig Dateien hinweg. Menschen sind immer noch besser darin zu beurteilen, ob eine neue dependency Sinn ergibt.

Was ist mit Microservices?

Architektur-Tests funktionieren am besten innerhalb einer einzelnen deploybaren Unit. Über Services hinweg setzt du Grenzen mit API-Contracts und Deployment-Isolation durch, nicht mit Import-Graphen.

Sollte ich Naming Conventions testen?

Nur wenn Inkonsistenz echte Verwirrung stiftet. Ein Test, der Repository-Suffixe durchsetzt, ist in einem Zehn-Entwickler-Team nützlich. Er ist wahrscheinlich Noise in einem Solo-Projekt.

Kann ich das mit Monorepos verwenden?

Ja. Nx, Bazel und Turborepo haben alle Module-Boundary-Enforcement. Wenn du bereits eines verwendest, nutze dessen Built-in-Regeln. Sie laufen schneller und integrieren sich in den Dependency-Graph. Wenn nicht, ist ein standalone Architektur-Test der leichtgewichtige Einstieg.

Fang mit einer Regel an

Deine Codebase hat bereits implizite Architektur-Regeln. Sie leben im Kopf deines Senior Engineers. Sie werden im Code Review durchgesetzt, wenn dieser Engineer nicht im Urlaub ist. Sie werden um 23 Uhr vor einer Deadline verletzt.

Schreib eine davon als Test nieder. Lass ihn rot werden. Behebe die Verstöße. Lass ihn CI blockieren.

Wenn das nächste Mal jemand fragt: „Dürfen wir aus dem Domain-Layer Infrastructure importieren?“, wird die Antwort nicht in einem Wiki stehen. Sie wird in einem fehlschlagenden Build stehen.