Quelqu’un dans votre équipe vient d’importer pg dans src/domain/invoice.ts. La PR compile. Les tests passent. La revue de code fait trois cents lignes, et personne ne remarque.

Trois mois plus tard, vous voulez extraire la logique métier dans un package partagé. Vous ne pouvez pas. Elle dépend des types Postgres, de la logique de pool de connexions, et d’un wrapper de driver personnalisé qui n’existe que dans le monolithe. Le diagramme sur tableau blanc avec des cercles concentriques est maintenant une blague.

C’est le pattern de dégradation standard de la clean architecture sans enforcement automatisé. Les architectes dessinent des flèches pointant vers l’intérieur. Les développeurs écrivent des imports pointant vers l’extérieur. Le compilateur se fiche de vos couches.

Ce que signifie réellement « le domaine n’importe pas l’infrastructure »

En architecture en couches, les dépendances coulent vers l’intérieur. La couche domaine, au centre, définit les entités, les règles métier et les use cases. Elle n’a aucune connaissance de HTTP, SQL, des queues ou des systèmes de fichiers.

L’infrastructure vit à la périphérie. Elle implémente les interfaces que le domaine définit. L’interface du repository vit dans le domaine. L’implémentation Postgres vit dans l’infrastructure. Le domaine appelle save(invoice). Il n’appelle jamais pool.query().

Quand le code domaine importe l’infrastructure directement, deux choses se produisent. Premièrement, le domaine devient non testable sans que la base de données tourne. Deuxièmement, vous ne pouvez jamais remplacer Postgres par DynamoDB sans réécrire la logique métier. Tout l’intérêt du découpage en couches, la testabilité et l’interchangeabilité, s’évapore.

Pourquoi la revue de code ne suffit pas

Chaque équipe a un ingénieur senior qui attrape les mauvais imports en revue. Cet ingénieur est aussi en vacances, malade, ou en train de relire une PR de cinquante fichiers à 17 h un vendredi.

Les humains sont des détecteurs de patterns avec une RAM limitée. Les règles de dépendances automatisées sont des validateurs déterministes avec une patience infinie. Le bon endroit pour appliquer les limites architecturales est le même que celui où vous appliquez les erreurs de syntaxe : le pipeline de build. Si ça compile mais viole le graphe de dépendances, ça doit échouer.

Comment dependency-cruiser applique les limites de couches

Pour les bases de code TypeScript et JavaScript, dependency-cruiser transforme votre diagramme d’architecture en un ensemble de règles testables. Il parse votre graphe d’imports et fait échouer le build quand une arête interdite apparaît.

Installez-le :

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

Le script d’init génère une config .dependency-cruiser.js. Vous ajoutez une règle qui interdit au domaine de toucher l’infrastructure :

// .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",
    },
  },
};

Cette règle dit : tout fichier sous src/domain qui importe quoi que ce soit sous src/infrastructure casse le build. La deuxième règle attrape l’import de pg ou axios qui atterrit via node_modules.

Intégrez-le dans votre 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

Maintenant, l’import de pg dans src/domain/invoice.ts échoue avant même qu’un humain n’ouvre la PR.

À quoi ressemble l’échec

Quand un développeur enfreint la règle, il obtient une sortie comme celle-ci :

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.

Le message d’erreur lui dit quoi faire. Déplacez l’appel à la base de données dans un repository dans la couche infrastructure. Passez le repository au use case comme une interface. Le code domaine reste pur.

Alternatives pour d’autres écosystèmes

Tout le monde n’utilise pas TypeScript. Le principe est le même partout. Seul l’outillage change.

Java : ArchUnit est la référence. Vous écrivez un test, pas un fichier de config :

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

Ça s’exécute comme un test JUnit, donc ça s’intègre avec Maven et Gradle sans aucune nouvelle plomberie CI.

C# : NetArchTest fournit le même style d’API pour .NET. Écrivez un unit test, exécutez-le en CI.

Go : Il n’existe pas d’équivalent mature. La plupart des équipes appliquent ça avec un simple shell script :

#!/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

C’est grossier, mais ça marche. Beaucoup d’équipes Go utilisent aussi des analyseurs go vet personnalisés ou le package go/ast pour construire des vérifications correctes.

Systèmes de build : Bazel, Gradle et Nx supportent tous nativement les graphes de dépendances de modules. Si vous définissez votre domaine et votre infrastructure comme des modules séparés avec des déclarations de dépendances explicites, l’outil de build applique la limite gratuitement. L’inconvénient est que vous devez d’abord adopter le système de build.

Compromis : faux positifs, douleur de migration et friction d’équipe

Les règles automatisées ne sont pas gratuites. Vous devriez connaître les coûts avant de les activer.

Les faux positifs arrivent quand vous avez des utilitaires partagés légitimes qui se situent entre les couches. Un slugifier de chaînes ou une classe de base d’erreur personnalisée pourrait vivre dans src/shared. Si le domaine et l’infrastructure l’importent tous les deux, la règle est fine. Si vous le mettez dans src/infrastructure/utils et que le domaine l’importe, dependency-cruiser va se plaindre. Le fix consiste généralement à déplacer le code partagé, ce qui est souvent le bon choix de toute façon.

La douleur de migration est réelle. Si votre base de code viole déjà la règle à trente endroits, vous ne pouvez pas activer l’interrupteur sans un refactoring héroïque. L’approche pragmatique est de commencer avec des warnings, de corriger les violations existantes sur un sprint, puis de promouvoir en erreurs. Sinon, utilisez des exceptions pathNot pour les fichiers legacy connus et interdisez les nouvelles violations dès le premier jour.

La friction d’équipe est le coût caché. Les développeurs qui n’ont jamais travaillé avec un enforcement strict des couches vont résister. Ils vont arguer qu’un petit import est inoffensif, que la règle est bureaucratique, que ça les ralentit. C’est un signal culturel, pas technique. Les mêmes développeurs seront aussi ceux qui importeront express dans un modèle de domaine à 23 h pour livrer une feature. La règle existe parce que les humains sous pression prennent des raccourcis.

Comment implémenter cela dans une base de code existante

Vous n’avez pas besoin d’une grande réécriture. Vous avez besoin d’une limite et d’un moyen de la mesurer.

  1. Dessinez les couches. Décidez quels répertoires sont domaine, application et infrastructure. Écrivez-le dans ARCHITECTURE.md.

  2. Ajoutez dependency-cruiser (ou l’équivalent de votre écosystème) avec une règle : le domaine ne peut pas importer l’infrastructure.

  3. Exécutez-le. Comptez les violations. Si le nombre est petit, corrigez-les avant de merger la config. S’il est grand, ajoutez les chemins legacy aux exceptions pathNot.

  4. Faites-le échouer en CI. Pas optionnel. Pas un warning. Une erreur qui bloque le merge.

  5. Chaque fois qu’un développeur tombe sur l’erreur, aidez-le à déplacer l’import. Ne lui dites pas juste que le build est rouge. Expliquez-lui où le code devrait vivre et pourquoi.

Dans les deux semaines, l’équipe cessera d’essayer d’importer pg dans les fichiers domaine. Le diagramme d’architecture sur le tableau blanc correspondra enfin au code dans le repository.

FAQ

La couche application devrait-elle être autorisée à importer l’infrastructure ?

Habituellement, oui. La couche application orchestre les use cases. Elle peut connaître les repositories et les services externes, mais elle devrait dépendre d’interfaces, pas d’implémentations concrètes. Si vous voulez un enforcement strict, ajoutez une deuxième règle : application ne peut pas importer les implémentations infrastructure, seulement leurs interfaces.

Et les domain events ?

Les domain events sont une faille courante. Un domain event est une donnée pure, donc il appartient à la couche domaine. Le bus d’événements infrastructure qui le publie appartient à l’infrastructure. La couche application abonne les handlers de domain events au bus. Le domaine lui-même ne sait jamais que le bus existe.

Puis-je utiliser cela avec Nx ou Turborepo ?

Oui. Nx a des règles de limites de modules intégrées qui fonctionnent au niveau du projet. Si votre domaine et votre infrastructure sont des librairies Nx séparées, vous pouvez appliquer la règle sans outil supplémentaire. C’est la même chose pour Bazel et Gradle.

Et si j’ai besoin d’un utilitaire de l’infrastructure ?

Vous n’en avez pas besoin. Déplacez l’utilitaire dans un package partagé common ou kernel qui n’a pas de dépendances infrastructure. S’il a vraiment besoin d’infrastructure, ce n’est pas un utilitaire. C’est de l’infrastructure.

En résumé

La clean architecture sans enforcement automatisé est un accord de gentlemen. Les accords de gentlemen ne survivent pas aux deadlines de production.

Dependency-cruiser, ArchUnit, ou même un shell script avec grep transforment votre architecture d’une suggestion en une garantie. Le domaine reste pur. L’infrastructure reste interchangeable. Et la prochaine fois que quelqu’un essaiera d’importer pg dans une règle métier, le build échouera avant même que la PR n’atteigne un relecteur humain.

Commencez avec une règle. Faites-la rouge en CI. Corrigez la première violation. Le reste suivra.