La règle d’or : les dépendances pointent vers l’intérieur
La Clean Architecture est souvent représentée par quatre cercles concentriques, et ce schéma la fait paraître plus complexe qu’elle ne l’est réellement. L’idée centrale repose sur une seule contrainte concernant les imports : les dépendances du code source pointent vers l’intérieur, vers le domaine. Les couches externes peuvent importer les couches internes. Les couches internes ne doivent jamais importer les couches externes.
C’est cela, la règle de dépendance, et presque tout le reste en est la conséquence. Le domaine — le code qui définit ce qu’est une commande et quand elle peut être annulée — n’importe rien du framework, du driver de base de données ou de la bibliothèque HTTP. C’est du code pur qui pourrait s’exécuter dans un processus de test, un script ou un runtime totalement différent.
L’objectif n’est pas la pureté pour la pureté. C’est parce que les éléments les plus susceptibles de changer se trouvent à l’extérieur. Les bases de données sont mises à jour ou remplacées, les frameworks HTTP tombent en désuétude, un prestataire de paiement est changé. Les règles métier évoluent également, mais beaucoup plus lentement et pour des raisons différentes. Si les règles dépendent des outils, chaque changement d’outil entraîne avec lui une modification des règles. Si la dépendance pointe vers l’intérieur, un changement d’outil reste confiné à un adapter.
C’est le même instinct que celui de séparer une bibliothèque de ses appelants. Vous voulez que la partie précieuse et stable soit utilisable et testable sans avoir à traîner les parties volatiles.
Cette règle concerne les imports, pas les dossiers. Renommer services en domain et déplacer des fichiers ne sert à rien si le code à l’intérieur importe toujours l’ORM. Le test est mécanique : ouvrez le fichier le plus interne et examinez sa liste d’imports. S’il mentionne une base de données, un framework ou un fournisseur, la frontière n’est que décorative.
Les couches et ce que chacune a le droit de savoir
Le diagramme classique se compose de quatre cercles. De l’intérieur vers l’extérieur :
Entities (ou le domaine). Les objets métier et leurs invariants : un Order doit avoir au moins une ligne, une valeur Money ne peut pas mélanger les devises, une facture ne peut pas être payée deux fois. Cette couche est la plus stable et la plus précieuse. Elle doit être composée de code pur, sans imports externes.
Use cases (la couche application). Ce que le système fait : passer une commande, annuler un abonnement, réinitialiser un mot de passe. Un use case orchestre les entities pour répondre à une intention, appelle les ports dont il a besoin et retourne des données brutes. Il contient le flux, pas les règles.
Interface adapters. Contrôleurs, présentateurs, implémentations de repositories, sérialiseurs. Ils assurent la traduction entre les formats du monde extérieur et ceux du domaine. Un contrôleur transforme une requête HTTP en entrée pour un use case ; un repository transforme des objets du domaine en lignes de base de données et inversement.
Frameworks and drivers. Express, Postgres, Redis, le SDK du cloud. Le cercle le plus externe, là où se trouvent les détails techniques et où la réflexion sur le design est la moins nécessaire. Cette couche sert de colle.
La règle est simple : une flèche peut pointer vers l’intérieur, mais jamais vers l’extérieur. Le use case peut appeler OrderRepository, car cette interface est définie dans le domaine. Le domaine ne peut pas appeler pg.Pool, car pg est un détail externe. Lorsque vous ressentez le besoin d’importer un type de base de données dans un use case, la solution n’est pas un meilleur import — c’est un port.
Ports et adaptateurs, la vision hexagonale
L’architecture hexagonale, également appelée « ports et adaptateurs », exprime la même idée avec un schéma que beaucoup trouvent plus facile à appliquer. L’application se trouve au centre. Autour d’elle se trouvent les ports : des interfaces qui déclarent ce dont l’application a besoin de l’extérieur et ce que l’extérieur peut lui demander. Branchés sur ces ports se trouvent les adaptateurs : des implémentations concrètes.
Il existe deux directions de ports :
- Les ports pilotés (outbound) décrivent ce dont l’application a besoin : un
OrderRepository, unPaymentGateway, unClock. Le domaine possède l’interface ; l’infrastructure l’implémente. - Les ports moteurs (inbound) décrivent ce que l’on peut demander à l’application : une interface de cas d’utilisation (use-case) qu’un contrôleur ou un consommateur de messages appelle.
L’élément crucial est la propriété (ownership). L’interface vit à côté du code qui l’utilise, dans la couche interne, et non à côté de l’implémentation. C’est ce qui rend l’inversion de dépendance possible : le domaine déclare OrderRepository, et la classe Postgres importe le domaine pour l’implémenter. La flèche des imports pointe vers l’intérieur, même si la flèche de contrôle pointe vers l’extérieur.
Un port doit être façonné par les besoins de l’application, et non par les capacités de la base de données. Si OrderRepository expose findByCustomerAndStatusPaginated, le schéma de la base de données a fuité dans le vocabulaire de l’application. S’il expose findByCustomer, l’application décrit ce dont elle a besoin et l’adaptateur peut décider comment y répondre.
La racine de composition (composition root) est ce qui permet au schéma de fonctionner au moment de l’exécution. Les interfaces seules ne connectent rien ; quelqu’un doit choisir l’implémentation et la transmettre. Ce choix se fait une seule fois, au démarrage, dans un fichier unique. Si vous vous retrouvez à construire un PostgresOrderRepository à l’intérieur d’un cas d’utilisation, l’inversion est purement nominale — le cas d’utilisation a simplement déplacé sa dépendance d’un import vers un appel de constructeur.
L’inversion de dépendance en pratique
L’inversion de dépendance est le mécanisme, pas l’objectif. L’objectif est que le domaine définisse le contrat et que l’infrastructure le remplisse.
Sans inversion, le cas d’utilisation (use case) importe la classe du repository :
import { PostgresOrderRepository } from "../infrastructure/postgres-order-repository.js";
Avec l’inversion, le cas d’utilisation importe une interface, et la classe concrète est passée en argument :
import type { OrderRepository } from "../domain/order-repository.js";
export class PlaceOrder {
constructor(private readonly orders: OrderRepository) {}
}
Le repository concret est choisi une seule fois, au démarrage, dans une composition root. C’est le seul fichier qui importe à la fois le domaine et l’infrastructure. Tout le reste ne voit que des interfaces.
const orderRepository = new PostgresOrderRepository(pool);
const placeOrder = new PlaceOrder(orderRepository);
Le gain pratique est immédiat. En production, PlaceOrder reçoit un repository Postgres. Dans un test unitaire, il reçoit un fake en mémoire. Le code du cas d’utilisation est identique dans les deux cas, et il n’a jamais eu besoin de savoir lequel il a reçu.
Exemple concret : passer une commande
Suivons une opération à travers les différentes couches.
Le contrôleur reçoit POST /orders. Il analyse le corps de la requête pour en faire un objet simple, valide la présence de l’identifiant client ainsi que le format des lignes, puis appelle le cas d’utilisation (use case). Il ne touche pas à la base de données et ne construit pas lui-même d’Order.
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);
});
Le cas d’utilisation construit l’entité avec Order.place, ce qui permet d’appliquer l’invariant selon lequel une commande doit contenir au moins une ligne. Il appelle ensuite this.orders.save(order) — un port. Il retourne { orderId, totalCents }, des données brutes sans qu’aucune entité ne s’en échappe.
L’adaptateur implémente save. Il ouvre une transaction, effectue un upsert de la ligne de commande, remplace les lignes et valide la transaction (commit). Il mappe order.lines vers des lignes et, dans findById, mappe les lignes en retour avec Order.reconstitute. Ce mapping est le rôle de l’adaptateur ; le cas d’utilisation ne voit jamais de ligne de base de données.
Remarquez ce que le domaine a importé : Money, qui est également du code domaine. Rien d’autre. Remarquez ce que le cas d’utilisation a importé : le domaine. Remarquez ce que l’adaptateur a importé : le domaine et pg. Les flèches de dépendance pointent toutes vers l’intérieur, et le mapping s’effectue précisément à la frontière.
La Clean Architecture n’est pas la même chose que le DDD
Ces deux concepts sont souvent mentionnés ensemble, mais ils sont différents.
Le Domain-Driven Design est un ensemble d’idées sur la modélisation d’un métier complexe : des entités avec une identité, des value objects sans identité, des agrégats comme limites de cohérence, des repositories pour la persistance, et un langage ubiquitaire partagé par les développeurs et les experts métier. Le DDD concerne l’aspect du modèle de domaine.
La Clean Architecture concerne l’orientation des dépendances. Elle ne dit rien sur la nécessité d’utiliser des agrégats, des événements de domaine ou un langage ubiquitaire.
Vous pouvez utiliser l’un sans l’autre :
- Une application en Clean Architecture avec un domaine mince et anémique respecte toujours la direction des dépendances, même si les règles métier sont faibles.
- Une application DDD avec des entités TypeORM annotées dans le domaine possède des règles riches, mais des dépendances incorrectes.
Ils se complètent bien, et c’est cette combinaison que la plupart des gens désignent lorsqu’ils parlent d’un « backend correctement structuré ». Cependant, si votre domaine est simple, adopter la règle des dépendances sans appliquer tout le DDD est un excellent résultat. Utilisez des value objects comme Money là où ils permettent d’éviter de réels bugs ; n’introduisez pas un agrégat simplement parce qu’un livre le préconise.
Onion, hexagonale et clean : trois noms, une seule direction
Les équipes utilisent ces noms comme s’il s’agissait de patterns concurrents. Ce sont en réalité trois représentations d’une même contrainte, publiées à quelques années d’intervalle.
- L’architecture hexagonale (Cockburn, 2005) place l’application au centre avec ses propres ports et des adapters tout autour. Sa contribution distinctive est la symétrie : les flux entrants et sortants sont tous deux des adapters.
- L’architecture Onion (Palermo, 2008) dessine des couches concentriques et souligne que le modèle de domaine se situe au cœur et que les dépendances pointent vers l’intérieur.
- La Clean Architecture (Martin, 2012) définit quatre anneaux et énonce explicitement la règle de dépendance, en ajoutant une couche de cas d’utilisation (use-case) entre les entités et les adapters.
Le vocabulaire diffère, mais la règle reste la même. Lors d’une code review, débattre de savoir quel diagramme est le bon est une perte de temps pour tout le monde. Ce qui importe, c’est de savoir si le domaine importe un framework et si l’interface se trouve à côté de son utilisateur. Si ces deux réponses sont correctes, alors le pattern est appliqué.
Les lectures n’ont pas besoin de tout ce cérémonial
Une erreur fréquente consiste à forcer les requêtes à passer par la même machinerie que les écritures. Un cas d’utilisation (use case) sert à protéger des invariants ; une requête, elle, n’a aucun invariant à protéger. Envelopper un SELECT dans une entité, un use case et un mapper ajoute des fichiers et des bugs de mapping sans aucun bénéfice.
Une séparation pragmatique est courante : les commandes passent par le domaine et les ports, tandis que les requêtes vont directement vers un modèle de lecture et retournent des DTO.
// 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;
}
Cette requête importe pg, et c’est tout à fait correct. Il s’agit d’un adaptateur de couche externe sans logique métier, la règle de dépendance n’a donc rien à protéger. Garder les lectures simples n’est pas un compromis ; c’est l’application honnête du pattern, car la valeur d’un modèle de domaine réside dans l’application de règles, et une lecture n’en applique aucune.
C’est l’essence même du CQRS, et vous pouvez vous arrêter là. Vous n’avez pas besoin de bases de données séparées ou de projections d’événements pour permettre aux lectures de contourner le domaine — juste une ligne claire entre les opérations qui décident et les opérations qui consultent.
Tester le domaine sans base de données
L’avantage le plus concret de la règle de dépendance est la vitesse des tests. Comme le domaine n’importe rien, ses tests n’ont besoin de rien.
Un test unitaire pour PlaceOrder construit un faux repository en mémoire et le passe en argument. Le test vérifie que la commande a été sauvegardée et que le total est correct. Il s’exécute en quelques microsecondes et ne nécessite ni container, ni migrations, ni réseau.
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);
Les tests d’entités sont encore plus simples. Money teste que l’ajout de devises mixtes lève une erreur et que multiply s’adapte correctement. Order teste que la passation d’une commande vide lève une erreur et que l’annuler deux fois est sans effet. Aucun d’entre eux ne mentionne de base de données.
La base de données réelle doit toujours être testée, mais le test porte désormais sur l’adapter et non sur les règles : est-ce que PostgresOrderRepository.save écrit bien les lignes, et est-ce que findById reconstruit l’entité fidèlement ? Cela représente un seul test d’intégration ciblé par adapter, et en cas d’échec, vous savez que le problème se situe dans le mapping, et non dans la logique métier.
C’est tout l’intérêt de cette séparation. Sans elle, chaque test d’une règle traîne une base de données avec lui, rendant les tests lents, instables et rares. Avec elle, les règles restent couvertes et les tests lents sont peu nombreux et ciblés.
Le prix de l’indirection
La Clean Architecture n’est pas gratuite, et prétendre le contraire conduit des équipes à l’appliquer partout pour finir par la détester.
Plus de fichiers. Un simple endpoint qui n’était autrefois qu’un handler et une requête devient un controller, un use case, un port, un adapter et un mapper. Pour une ressource CRUD, cela représente cinq fichiers là où un seul suffirait, et le cheminement de la requête jusqu’à la base de données est plus long.
Le mapping dans les deux sens. Les lignes de base de données deviennent des entités, les entités deviennent des réponses, et parfois des DTO s’interposent. Le mapping est ennuyeux, répétitif et facile à rater subtilement — un champ manquant, une devise définie par défaut sans avertissement. Cela nécessite ses propres tests, et ces tests ne testent pas la valeur métier.
Un vocabulaire plus vaste. Ports, adapters, use cases, composition roots, DTO. Un nouveau développeur doit apprendre l’organisation du projet avant de pouvoir trouver quoi que ce soit, et une équipe qui l’adopte à moitié se retrouve avec le pire des deux mondes : de l’indirection sans frontières cohérentes.
Un faux sentiment de découplage. Importer une interface ne vous rend pas indépendant de la technologie qui se trouve derrière. Si votre port expose des sémantiques SQL, ou si votre domaine repose sur le comportement de ON CONFLICT, vous êtes couplé malgré tout. L’interface est une couture, pas un champ de force.
L’approche honnête consiste à considérer cela comme un investissement. Il devient rentable lorsque le domaine est suffisamment riche pour être testé, lorsque l’infrastructure est susceptible de changer, et lorsque plusieurs personnes ont besoin de frontières claires. Ce n’est pas rentable pour un écran de paramètres.
Le retour sur investissement est plus évident lors de la maintenance. Lorsqu’une règle métier change, la modification se limite à une entité et un test. Lorsqu’une requête nécessite un index, cela se passe dans un seul adapter. Lorsque l’équipe souhaite essayer un nouveau prestataire de paiement, elle écrit un second adapter et modifie une seule ligne dans le composition root. Aucun de ces changements n’a d’effet domino sur les autres, et c’est là tout l’intérêt de ces fichiers supplémentaires.
Où fixer la limite
La réponse pragmatique n’est pas du “tout ou rien”. Fixez la frontière là où une règle métier existe, et laissez le reste simple.
Une heuristique utile : si une opération possède une règle métier qui pourrait être erronée de manière critique, créez-lui un use case et un objet de domaine. Passer une commande, appliquer une remise, annuler un abonnement — ce sont des opérations qui possèdent des invariants qu’il vaut la peine de protéger. Si une opération est une simple lecture ou écriture sans aucune décision logique, laissez-la être une requête légère, éventuellement derrière un petit repository, et ne l’enrobez pas dans un formalisme inutile.
Vous pouvez avoir les deux au sein du même système. Un use case PlaceOrder avec un agrégat Order riche et un fake en mémoire peut coexister avec un GetProductById qui n’est qu’une seule requête mappée vers un DTO. Cette asymétrie ne nuit à personne, et la base de code reste proportionnelle à la complexité du problème.
Soyez tout aussi pragmatique avec l’ORM. De nombreuses équipes conservent un ORM pour les lectures et utilisent des adaptateurs de repository écrits à la main pour le flux d’écriture du domaine. D’autres utilisent du SQL brut partout et acceptent que l’adaptateur gère le mapping. La règle concerne la direction, pas l’outillage : tant que le domaine n’importe pas l’ORM, vous êtes libre d’utiliser tout ce dont l’adaptateur a besoin.
Le terrain d’entente le plus courant mérite d’être énoncé clairement. Un package de domaine avec des entités réelles pour les deux ou trois concepts qui portent des règles. Un use case par commande significative. Des interfaces de repository uniquement là où le flux d’écriture en a besoin. Tout le reste — lectures, écrans d’administration, rapports — sous forme de handlers légers exécutant du SQL. Ce n’est pas un compromis ; c’est l’application du pattern adaptée à l’échelle du problème.
Structurer un projet
L’organisation des dossiers doit rendre la direction des dépendances évidente au premier coup d’œil. Une structure courante :
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
Les noms importent moins que la règle. domain n’importe rien des autres. application importe domain. infrastructure et interfaces importent les deux. main.ts les relie entre eux et est le seul fichier autorisé à connaître chaque couche.
Si vous préférez les tranches verticales — un dossier par fonctionnalité contenant domain, application et infrastructure — cela fonctionne également et s’adapte bien lorsque les fonctionnalités sont indépendantes. Ce que vous perdez, c’est l’endroit unique et évident où chercher les règles transversales ; ce que vous gagnez, c’est qu’une fonctionnalité est autonome.
Imposez cette direction avec des outils plutôt que par la discipline. Le no-restricted-imports ou dependency-cruiser d’ESLint, ou un plugin d’import-boundary, peuvent faire échouer le build lorsque domain importe pg. Une règle qui n’est qu’une convention est une règle qui sera enfreinte un vendredi à 17h.
Quel que soit le layout choisi, gardez la racine de composition (composition root) explicite et restreinte. Un seul main.ts qui importe les adaptateurs concrets et construit les cas d’utilisation est facile à lire et à modifier. Lorsque le câblage est dispersé dans des modules qui construisent chacun leurs propres dépendances, plus personne ne peut dire à quelle base de données un cas d’utilisation communique réellement, et la couture promise par l’architecture disparaît.
Ce qui doit figurer dans un cas d’utilisation
Un cas d’utilisation est une intention unique exprimée sous la forme d’une classe avec une seule méthode publique : PlaceOrder, CancelOrder, RefundPayment. Sa méthode execute prend des entrées simples, orchestre le domaine et les ports, et renvoie une sortie simple. Si vous ne pouvez pas nommer l’intention par un verbe et un nom, c’est que le cas d’utilisation en fait probablement trop.
Un cas d’utilisation doit contenir le flux, et non les règles. Il décide de l’ordre des étapes : charger, agir, persister, publier. Il ne décide pas de ce qui rend une commande valide ; cela relève de l’entité. Cette distinction est importante car les règles sont réutilisées entre plusieurs cas d’utilisation, alors que les flux ne le sont généralement pas.
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 });
}
}
Ce qu’un cas d’utilisation ne doit pas faire : construire du SQL, lire req ou res, connaître les codes de statut HTTP, envoyer des e-mails directement ou importer un framework. Chacun de ces éléments est soit une préoccupation de la couche externe, soit appartient à un port. Si le cas d’utilisation importe express, la frontière a échoué, peu importe le nom des dossiers.
L’autorisation est une véritable question de conception. Vérifier les permissions dans le cas d’utilisation permet de garder les règles au même endroit et de les rendre testables ; les vérifier dans un middleware demande moins de code mais est plus facile à oublier. Une réponse viable consiste à effectuer des vérifications globales à la périphérie et une autorisation au niveau métier à l’intérieur du cas d’utilisation, car « seul le propriétaire peut annuler » est une règle, et non une question de routage.
Transactions, effets de bord et périmètre
Les transactions relèvent de l’infrastructure, mais leur périmètre (boundary) relève de l’application. Le cas d’utilisation sait qu’un ensemble de modifications doit être validé ensemble ; il ne doit pas savoir que le mécanisme est BEGIN et COMMIT dans Postgres.
Deux approches fonctionnent bien. La plus simple consiste à rendre chaque méthode de repository transactionnelle par elle-même, ce qui convient parfaitement lorsqu’un cas d’utilisation effectue une seule écriture. Lorsqu’un cas d’utilisation écrit via plusieurs repositories et que les modifications doivent être atomiques, introduisez un port unit of work :
export interface UnitOfWork {
run<T>(work: (repos: Repositories) => Promise<T>): Promise<T>;
}
L’adaptateur l’implémente avec une connexion et une transaction, et le cas d’utilisation enveloppe son travail dans unitOfWork.run. Le domaine possède toujours l’interface, l’adaptateur possède le SQL, et l’atomicité est explicite dans la couche application, là où elle doit se trouver.
Les effets de bord suivent la même règle. Envoyer un e-mail, débiter une carte ou publier un événement doit être un port — EmailSender, PaymentGateway, EventPublisher — et non un appel fetch direct. Cela permet de garder le cas d’utilisation testable, car le fake enregistre ce qui aurait été envoyé, et cela maintient le choix du fournisseur dans l’adaptateur.
L’ordonnancement des effets de bord par rapport à la base de données est subtil. Un cas d’utilisation qui enregistre une commande puis publie un événement est confronté au problème du “dual-write” : le processus peut s’arrêter entre les deux. Le pattern fiable consiste à écrire l’événement dans la même transaction que l’état — un outbox — et à laisser un relais le publier. Le cas d’utilisation demande à un EventPublisher d’enregistrer l’événement ; l’adaptateur décide s’il s’agit d’une ligne dans l’outbox ou d’une publication directe.
Domaine anémique, abstractions fuyantes et types de framework
Trois modes de défaillance ressemblent à la Clean Architecture sans en être.
Un domaine anémique est un ensemble de classes ne contenant que des champs, des getters et des setters, tandis que toute la logique réside dans les services. Les dossiers sont corrects, les flèches de dépendance sont correctes, mais le domaine est vide. Ce n’est pas un désastre — une couche de service sur des données brutes est un design légitime — mais prétendre qu’il s’agit d’un domaine riche est une illusion, et cela signifie généralement que les invariants sont appliqués à plusieurs endroits et oubliés ailleurs.
Une abstraction fuyante (leaky abstraction) est un port façonné par son implémentation. OrderRepository.upsertOnConflict mentionne Postgres. PaymentGateway.chargeWithStripeToken mentionne un fournisseur. Un port doit parler le langage de l’application : save, findById, charge. Lorsqu’un port « fuit », remplacer l’adaptateur implique de modifier l’interface et tous les appels, ce qui annule tout l’intérêt d’avoir un port.
Les types de framework dans le domaine constituent la violation la plus directe. Une entité annotée avec @Entity et @Column, ou un cas d’utilisation dont le type d’entrée est un Request Express, a importé une couche externe. Le code peut compiler et passer les tests, mais la règle de dépendance est rompue, et c’est désormais le framework qui décide quand et comment le domaine est construit. Gardez les décorateurs et les types de requête dans les couches externes et transmettez des objets simples vers l’intérieur.
Stratégie de test de l’intérieur vers l’extérieur
La règle de dépendance fait émerger naturellement la pyramide des tests. Testez depuis l’intérieur, là où les tests sont rapides, et progressez vers l’extérieur uniquement autant que nécessaire.
Les tests de domaine couvrent les entités et les objets de valeur. Ce sont des tests unitaires purs sans E/S, et ils doivent être nombreux, car c’est dans les règles métier que les bugs coûtent le plus cher. Money rejette les devises mixtes ; Order rejette une liste de lignes vide ; annuler deux fois n’a aucun effet.
Les tests de cas d’utilisation couvrent le flux. Construisez le cas d’utilisation avec des fakes en mémoire pour chaque port, appelez execute, et vérifiez le résultat ainsi que ce que les fakes ont enregistré. Ils permettent de détecter les étapes manquantes, les erreurs d’ordonnancement et les oublis de sauvegarde, tout en s’exécutant en quelques microsecondes.
Les tests d’adaptateurs couvrent le mapping. Exécutez-les contre un vrai PostgreSQL dans un container, insérez des données, relisez-les, et vérifiez que l’entité effectue l’aller-retour fidèlement. C’est ici que vous découvrirez que status est revenu sous forme de chaîne de caractères ou qu’une colonne nullable a produit une ligne undefined. Un seul test ciblé par méthode d’adaptateur est généralement suffisant.
Les tests de bout en bout couvrent le câblage : une véritable requête HTTP passant par le contrôleur, le cas d’utilisation et la base de données, avec une vérification de la réponse. Gardez-les peu nombreux. Ils sont lents, ils échouent pour des raisons sans rapport avec le code, et leur rôle est de prouver que la racine de composition est bien connectée, et non de re-tester les règles métier.
Cette répartition signifie qu’un test de règle qui échoue pointe vers le domaine, un test de flux qui échoue pointe vers le cas d’utilisation, un test d’aller-retour qui échoue pointe vers l’adaptateur, et un test de bout en bout qui échoue pointe vers le câblage. Cette clarté de diagnostic a plus de valeur que le nombre brut de tests.
Quand introduire la frontière
Il est rare de définir les bonnes frontières dès le départ, car vous ne connaissez pas encore précisément où se situent les règles. Une approche pratique consiste à commencer avec des handlers légers et un module d’accès aux données, à observer où la logique s’accumule, puis à extraire un cas d’utilisation et un objet de domaine à ce moment-là.
Les signaux indiquant qu’une frontière mérite d’être introduite :
- La même règle est appliquée dans plus d’un handler.
- Le test d’une règle métier nécessite une base de données ou un serveur en cours d’exécution.
- Le changement d’un framework ou d’un ORM obligerait à modifier la logique.
- Une fonction mélange validation, persistance et appels externes, et devient difficile à nommer.
- Deux développeurs entrent constamment en conflit dans le même fichier.
Les signaux indiquant qu’il ne faut pas s’en bother :
- L’opération est une simple lecture ou écriture sans aucune prise de décision.
- Les règles changent encore quotidiennement et les stabiliser serait prématuré.
- L’application entière est gérée par une seule personne et ne comporte qu’une poignée d’endpoints.
Introduisez la frontière là où se trouve la douleur, et non partout d’un coup. Une base de code avec trois frontières bien tracées et beaucoup de code simple est plus saine qu’une base où chaque table possède un agrégat et chaque appel un port.
Bonnes pratiques
- Gardez la flèche de dépendance pointée vers l’intérieur et faites en sorte que le domaine n’importe rien.
- Définissez les ports là où ils sont utilisés, dans la couche interne, et non là où ils sont implémentés.
- Concevez les ports en fonction des besoins de l’application, et non des capacités de la base de données.
- Placez les implémentations concrètes dans les adapters et ne les choisissez qu’au niveau de la composition root.
- Effectuez le mapping entre les lignes, les objets du domaine et les DTO aux frontières ; ne laissez jamais les types d’une couche traverser vers une autre.
- Donnez une seule intention à un use case et faites en sorte qu’il retourne des données brutes.
- Imposez les invariants dans le domaine via des factories et des constructeurs privés, et non dans les contrôleurs.
- Testez unitairement les use cases avec des fakes en mémoire et testez les adapters séparément via des tests d’intégration.
- Gardez le domaine exempt de décorateurs de framework, d’annotations ORM et de types HTTP.
- Appliquez ce pattern là où des règles métier existent et laissez les opérations CRUD simples être légères.
- Imposez les frontières d’importation avec une règle de lint ou dependency-cruiser dans la CI/CD.
Erreurs courantes
- Annoter les entités du domaine avec des décorateurs ORM et appeler cela de la Clean Architecture.
- Retourner des entités ou des lignes de base de données directement aux contrôleurs.
- Définir l’interface du repository à côté de la classe Postgres au lieu de la définir à côté du cas d’utilisation (use case).
- Écrire un domaine anémique composé de getters et setters, tout en laissant la logique dans le service.
- Laisser fuiter la sémantique SQL dans un port et croire que les couches sont découplées.
- Abstraire chaque table et chaque appel, transformant une application CRUD en une usine à gaz.
- Placer la racine de composition (composition root) partout, faisant en sorte que rien n’est réellement inversé.
- Tester les cas d’utilisation avec la base de données réelle et perdre la rapidité qui justifiait la séparation.
- Sauter l’étape du mapping et passer
anyà travers une frontière de couche. - Adopter simultanément le DDD, le CQRS et l’event sourcing complets simplement parce qu’un schéma le suggérait.
Et après ?
La Clean Architecture trace des frontières à l’intérieur d’une seule application. Le guide sur le Modular Monolith explique comment rendre ces frontières explicites entre les modules sans subir les coûts liés au réseau, ce qui constitue l’étape suivante logique pour la plupart des systèmes. Lorsqu’une frontière doit réellement devenir une unité déployable, le guide sur les Microservices détaille l’origine des limites de service et les changements qui surviennent lorsqu’un appel devient distant. Si vous souhaitez que vos ports soient simples et s’auto-documentent, le guide TypeScript traite des interfaces et des types, et PostgreSQL est la base de données pour laquelle la majorité des adapters sont écrits.