Tests d'architecture

Toutes les règles posées dans cette section ont un point commun : ce sont des intentions. « Le domaine ne dépend de rien », « une couche n’appelle que celle du dessous », « seule la façade du module est importable », « aucun ORM dans le domaine ». Rien, dans un dépôt ordinaire, ne les empêche d’être violées — et une règle d’architecture non vérifiée est violée en quelques semaines, sans mauvaise volonté de personne.

Cette page est le garde-fou promis plusieurs fois ailleurs :

Une frontière qui n’échoue pas en intégration continue n’est pas une frontière, c’est une convention.

Le terme générique est fonction d’aptitude (fitness function), emprunté à Building Evolutionary Architectures : une caractéristique architecturale rendue exécutable. Un test d’architecture en est le cas particulier le plus courant et le plus rentable.

Ce qui est vraiment testable

Toutes les propriétés d’une architecture ne le sont pas. Voici celles qui le sont, par ordre de rentabilité.

Ce qu’on vérifieExemple de règle
1Dépendancesdomaine n’importe ni infrastructure, ni aucune bibliothèque tierce
2CyclesAucun cycle entre modules
3Surface publiqueexpedition n’atteint facturation que par sa façade
4Emplacement et nommageUn cas d’usage vit dans application/ ; un contrôleur finit par Controller
5Absence de dépendance techniqueLe domaine n’importe pas sqlalchemy, typeorm, sqlx
6ContratsLe schéma d’événement reste compatible avec la version précédente
7Caractéristiques mesurablesTemps de démarrage, taille du paquet, nombre de requêtes SQL par cas d’usage

Les cinq premières sont statiques, se vérifient en quelques secondes et couvrent la quasi-totalité des règles de cette section. La sixième relève des tests de contrat, la septième est la plus négligée — et souvent la plus intéressante, parce qu’elle attrape des régressions qu’aucune revue ne voit, comme un N+1 apparu dans un cas d’usage.

Les outils

LangageOutilNature de la garantie
RustLe système de crates lui-mêmeStructurelle : le code interdit ne compile pas
JavaArchUnitTests unitaires ordinaires
.NETNetArchTestTests unitaires ordinaires
Pythonimport-linterContrats déclaratifs, une commande
TypeScriptdependency-cruiser, eslint-plugin-boundariesRègles déclaratives, une commande

En pratique

​
// .dependency-cruiser.js
module.exports = {
  forbidden: [
    {
      name: 'domaine-sans-infrastructure',
      severity: 'error',
      comment: "Le domaine ne connaît ni l'infrastructure, ni l'application.",
      from: { path: '^src/[^/]+/domaine' },
      to:   { path: '^src/[^/]+/(infrastructure|application)' },
    },
    {
      name: 'domaine-sans-dependance-tierce',
      severity: 'error',
      comment: 'Aucune bibliothèque technique dans le domaine.',
      from: { path: '^src/[^/]+/domaine' },
      to:   { dependencyTypes: ['npm'],
              pathNot: '^(uuid|date-fns)$' },     // La liste blanche, explicite
    },
    {
      name: 'modules-par-la-facade',
      severity: 'error',
      comment: "Un module n'atteint un autre module que par son index.",
      from: { path: '^src/([^/]+)/' },
      to:   { path: '^src/([^/]+)/.+', pathNot: '^src/$1/|^src/[^/]+/index\\.ts$' },
    },
    {
      name: 'aucun-cycle',
      severity: 'error',
      from: {},
      to: { circular: true },
    },
  ],
  options: { doNotFollow: { path: 'node_modules' }, tsPreCompilationDeps: true },
};
npx depcruise src --config .dependency-cruiser.js   # échoue en code non nul
# .importlinter
[importlinter]
root_packages = facturation
                expedition
include_external_packages = True

[importlinter:contract:couches]
name = Le domaine ne connaît ni application ni infrastructure
type = layers
layers =
    infrastructure
    application
    domaine
containers =
    facturation
    expedition

[importlinter:contract:domaine-pur]
name = Aucune bibliothèque technique dans le domaine
type = forbidden
source_modules =
    facturation.domaine
    expedition.domaine
forbidden_modules =
    sqlalchemy
    fastapi
    httpx
    redis

[importlinter:contract:modules-independants]
name = Les modules ne se parlent que par leur façade
type = independence
modules =
    facturation.domaine
    expedition.domaine
lint-imports          # échoue en code non nul, et nomme la chaîne d'imports fautive
# crates/facturation-domaine/Cargo.toml
# La frontière EST ce fichier : ce qui n'est pas déclaré ne compile pas.
[package]
name = "facturation-domaine"

[dependencies]
uuid = { version = "1", features = ["v4"] }
time = "0.3"
# Aucune trace de sqlx, axum, reqwest, tokio.
# deny.toml — cargo-deny : interdire explicitement, pour que l'ajout se voie
[bans]
multiple-versions = "warn"

[[bans.deny]]
name = "sqlx"
wrappers = ["facturation-infra"]   # sqlx toléré ici, nulle part ailleurs

[[bans.deny]]
name = "axum"
wrappers = ["app"]
cargo deny check bans        # échoue si une crate interdite remonte ailleurs
cargo build                 # suffit déjà pour les frontières de modules privés

En Rust, l’essentiel du travail est fait par le langage : un mod privé est inatteignable, et une dépendance non déclarée ne compile pas. cargo-deny ne sert qu’à empêcher qu’une dépendance interdite arrive transitivement dans la crate du domaine.

Le nommage et l’emplacement

La quatrième catégorie mérite un mot, parce qu’elle est souvent jugée cosmétique alors qu’elle protège une convention réelle.

Vérifier qu’un cas d’usage vit dans application/ n’est pas une question de style : c’est ce qui empêche qu’un cas d’usage apparaisse dans infrastructure/, où la règle de dépendance ne le protégerait plus. De même, une classe qui finit par Repository et qui n’implémente pas le port correspondant signale une confusion entre dépôt et DAO — distinction traitée dans Services, dépôts et fabriques.

Ces règles ont un défaut symétrique : trop nombreuses, elles deviennent du bruit qu’on désactive en bloc. Trois ou quatre suffisent.

Où les faire tourner

Comme des tests, avec les tests. Un échec de règle d’architecture doit casser la construction exactement comme un test unitaire — pas produire un avertissement que personne ne lit.

# .github/workflows/ci.yml — extrait
- name: Règles d'architecture
  run: |
    lint-imports                                       # Python
    npx depcruise src --config .dependency-cruiser.js  # TypeScript
    cargo deny check bans                              # Rust

Le placement dans le pipeline importe : ces vérifications sont rapides — quelques secondes — et doivent donc passer avant la suite de tests longue. Un développeur qui découvre une violation de frontière après huit minutes de tests d’intégration la contournera.

Introduire dans un code existant

C’est le point où la plupart des tentatives échouent, et il a une solution connue.

Activer toutes les règles d’un coup sur un code existant produit trois cents échecs et une seule réaction possible : désactiver. La technique qui fonctionne est le cliquet — baseline and ratchet :

  1. Mesurer l’existant : combien de violations aujourd’hui, et lesquelles.
  2. Enregistrer ce nombre comme référence tolérée.
  3. Faire échouer la CI dès que le nombre augmente. Les violations existantes passent, les nouvelles non.
  4. Abaisser la référence à chaque fois qu’une violation est corrigée. Le cliquet ne remonte jamais.

L’effet est immédiat sur ce qui compte : le code nouveau respecte la règle dès le premier jour, et l’ancien s’améliore au rythme des passages. C’est la même logique que l’étranglement progressif appliquée à une contrainte plutôt qu’à du code.

Tip

Une variante plus simple et souvent suffisante quand l’outil ne gère pas de référence : n’appliquer les règles qu’à un périmètre restreint — le module de cœur de métier — et l’étendre module par module. Une règle appliquée à 10 % du code et respectée vaut mieux qu’une règle appliquée partout et désactivée.

Les limites

Ils vérifient la structure, pas la conception. C’est la limite fondamentale : un modèle anémique passe toutes les règles de dépendance du monde. Des entités sans comportement, des services qui font tout le travail, une couche traversante — rien de tout cela n’est détectable par un test d’architecture. Ils garantissent que les règles ne dépendent de rien ; pas qu’il y ait des règles.

Ils ne disent pas si la frontière est au bon endroit. Un découpage médiocre, rigoureusement respecté, reste un découpage médiocre.

Ils créent un risque de fausse confiance. Une CI verte sur les règles d’architecture peut faire croire que la conception va bien. C’est le complément d’une revue, pas son remplacement.

Ils ont un coût de maintenance. Un remaniement légitime fait échouer des règles écrites pour une structure antérieure, et il faut alors décider — modifier la règle ou renoncer au remaniement. C’est un arbitrage sain, mais c’est du temps.

Ce qu’il faut mettre en place, au minimum

Si une seule chose devait être faite, dans cet ordre :

  1. Une règle de dépendance sur le domaine : il n’importe ni l’infrastructure, ni aucune bibliothèque technique. C’est celle qui protège la décision la plus coûteuse à défaire.
  2. Une règle anti-cycles entre modules. Gratuite à écrire, et elle attrape des dégradations que personne ne voit venir.
  3. Une règle de façade si le code est organisé en modules — voir Monolithe, modulaire, microservices.

Trois règles, quelques secondes de CI, et l’essentiel de ce que cette section recommande cesse d’être une intention.

Références