Principes, cercles, règles de dépendance et exemple concret sur une feature e-commerce — selon Robert C. Martin (Uncle Bob).
📖
La loi fondamentale — Robert C. Martin
« Le but d’une bonne architecture est de minimiser les ressources humaines nécessaires pour construire et maintenir un système. » Une architecture n’est pas une technologie — c’est une décision sur ce qui peut changer facilement et ce qui doit rester stable. Elle sépare la politique (les règles métier) des détails (les frameworks, les bases de données, le réseau).
01
La métaphore : l’ambassade diplomatique
Imagine une ambassade française à Tokyo. À l’intérieur, les lois françaises s’appliquent — c’est le domaine. L’ambassade ne se transforme pas selon les lois japonaises. Les visiteurs (HTTP, base de données, UI) s’adaptent à elle, jamais l’inverse.
C’est exactement la Clean Architecture : le domaine métier ne connaît pas le monde extérieur. Express.js, MySQL, React… ce sont des détails. Ils peuvent tous être remplacés sans toucher à une ligne de logique métier.
02
Les 4 cercles concentriques
La Clean Architecture s’organise en cercles concentriques. Plus on est au centre, plus le code est stable, abstrait et précieux. Plus on est à l’extérieur, plus le code est volatile, concret et remplaçable.
Le noyau inviolable. Encapsule les règles métier d’entreprise. Ces objets existent indépendamment de toute application, framework ou base de données. Ce sont les plus stables.
Zero importRègles métierJamais changé
« Si votre Entity a un import TypeORM, ce n’est plus une Entity. »
🟣
Cercle 02 — usecases/
Use Cases
Orchestrent le flux de données vers et depuis les Entities pour réaliser les objectifs de l’application. Contiennent aussi les ports — interfaces que les adapters devront implémenter.
InteractorsPorts (interfaces)Orchestration
🔵
Cercle 03 — adapters/
Interface Adapters
Traducteurs de format. Convertissent les données du format Use Case/Entity vers le monde extérieur (et vice versa). Controllers, Presenters, Gateways, Repositories concrets.
ControllersPresentersGateways
🟠
Cercle 04 — frameworks/
Frameworks & Drivers
Express, NestJS, React, MySQL, Redis, Stripe SDK… Ce sont des détails. Uncle Bob : "Don’t think too much about them." Ils peuvent tous être remplacés sans toucher au domaine.
NestJSTypeORMPostgreSQLRemplaçables
« The web is a detail. The database is a detail. »
03
La Dependency Rule — la loi absolue
“Source code dependencies must point only inward.” Rien dans un cercle intérieur ne peut mentionner quoi que ce soit d’un cercle extérieur. Ni son nom, ni sa fonction, ni son type, ni sa variable.
04 Frameworks
→dépend de
03 Adapters
→dépend de
02 Use Cases
→dépend de
01 Entities
✗interdit
vers l’extérieur
Cette règle est rendue possible grâce à l’inversion de dépendance (DIP). Quand un Use Case a besoin d’une base de données, il ne l’importe pas directement — il définit une interface (un port) que l’adapter du cercle 3 viendra implémenter. Le UseCase définit le contrat, l’Adapter s’y conforme.
❌ Sans Clean Architecture
Le UseCase importe directement MySQLRepo
Changer de DB = modifier le UseCase
Impossible de tester sans lancer une DB
Le domaine métier connaît Stripe, TypeORM…
✓ Avec Clean Architecture
Le UseCase dépend uniquement d’une interface
Swap MySQL → MongoDB : 1 seul fichier modifié
Tests avec un MockRepo sans infrastructure
Le domaine métier ne connaît rien de l’extérieur
04
Entity Uncle Bob vs Entity Framework
C’est la source de confusion la plus fréquente. Une Entity NestJS/TypeORM n’est pas une Entity au sens de la Clean Architecture. Uncle Bob ne dit pas “n’utilisez aucun framework” — il dit “les Entities ne doivent pas dépendre d’un framework”.
❌ Entity TypeORM — modèle de persistance
Imports depuis TypeORM/NestJS en tête de fichier
Décorateurs @Entity@Column partout
Sac de données — aucune méthode métier
Le schéma DB dicte la structure objet
Impossible de tester sans démarrer une DB
✓ Entity Uncle Bob — domaine pur
Zero import — TypeScript pur
Propriétés privées, encapsulation réelle
Méthodes métier : payer()annuler()valider()
Structure dictée par le métier, pas la DB
new Commande() suffit pour tester
La solution pragmatique : deux modèles séparés. En pratique, on crée une Entity domaine pure (entities/Commande.ts) et un modèle ORM séparé (adapters/gateways/CommandeORM.ts). Le Gateway traduit entre les deux. C’est le seul endroit où TypeORM existe.
entities/Commande.ts — Cercle 01 ✓
// Zero import. Domaine pur.export classCommande {
private statut: StatutCommande;
private items: Item[];
constructor(items: Item[]) {
if (items.length === 0)
throw newError('Une commande doit avoir des items');
this.items = items;
this.statut = 'pending';
}
payer(montant: number): void {
if (this.statut !== 'pending')
throw newError('Commande déjà traitée');
if (montant <= 0)
throw newError('Montant invalide');
this.statut = 'paid';
}
annuler(): void {
if (this.statut === 'paid')
throw newError('Impossible d\'annuler une commande payée');
this.statut = 'cancelled';
}
}
05
Structure src/ — Feature “Passer une commande”
Voici comment les 4 cercles se traduisent en une structure de fichiers concrète pour une feature e-commerce, en suivant exactement la Clean Architecture d’Uncle Bob.
// Défini PAR le UseCase. Stripe et MySQL ne décident pas de cette interface.export interfaceCommandeRepository {
save(commande: Commande): Promise<void>;
findById(id: string): Promise<Commande>;
findClientById(id: string): Promise<Client>;
}
// Implémente l'interface du UseCase. C'est la SEULE couche qui connaît TypeORM.export classPostgresCommandeRepoimplementsCommandeRepository {
asyncsave(commande: Commande): Promise<void> {
const row = this.toORM(commande); // Entity → modèle ORMawait this.typeormRepo.save(row);
}
asyncfindById(id: string): Promise<Commande> {
const row = await this.typeormRepo.findOneBy({ id });
return this.toDomain(row); // ligne SQL → Entity
}
privatetoORM(c: Commande): CommandeORM { /* mapping... */ }
privatetoDomain(row: CommandeORM): Commande { /* mapping... */ }
}
frameworks/config/container.ts — Cercle 04
// Le seul fichier qui sait tout. Relie les interfaces à leurs implémentations.// C'est le chef d'orchestre du Dependency Injection.
container.bind(CommandeRepository).to(PostgresCommandeRepo);
container.bind(PaiementService).to(StripeService);
container.bind(NotificationService).to(EmailService);
// Pour les tests, on swap sans toucher au UseCase :// container.bind(CommandeRepository).to(InMemoryCommandeRepo);// container.bind(PaiementService).to(MockPaiementService);
07
Pourquoi ports/ vit dans usecases/ ?
C’est une question légitime. Intuitivement, on pourrait penser que les interfaces appartiennent aux adapters qui les implémentent. C’est l’inverse.
🔌
La métaphore de la prise électrique
La norme de prise électrique est définie par les appareils qui en ont besoin, pas par les fabricants de câbles. De même, CommandeRepository est défini par le UseCase qui a besoin de persister des commandes — pas par PostgreSQL qui viendra se brancher dessus.
Stripe dépend du portPaiementService. Jamais l’inverse. Si Stripe disparaît, le UseCase ne change pas d’une virgule.
Principe DIP (Dependency Inversion Principle) : “Depend on abstractions, not on concretions.” Les modules de haut niveau (UseCases) ne dépendent pas des modules de bas niveau (Stripe, MySQL). Les deux dépendent d’abstractions (interfaces/ports).
08
Le test ultime d’une Clean Architecture réussie
Uncle Bob propose trois questions diagnostiques pour évaluer la propreté d’une architecture.
🧪
Test des Entities
Tester sans infrastructure
Peut-on écrire new Commande([item]) et tester une règle métier sans DB, sans NestJS, sans HTTP ? Si oui, les Entities sont propres.
Jest purZero infra
🔄
Test du Swap DB
Remplacer la base de données
Si on remplace MySQL par MongoDB, combien de fichiers sont modifiés ? En Clean Architecture : exactement 1 — le Gateway. Rien d’autre.
1 seul fichierDomain intact
🌐
Test du Swap Framework
Remplacer Express par NestJS
Si on change de framework HTTP, le domaine métier est-il touché ? Non. Seuls les Controllers et app.ts changent. Use Cases et Entities restent intacts.
Framework agnostiqueDomaine stable
Test unitaire pur — zero infrastructure
// Ce test ne nécessite aucune DB, aucun NestJS, aucune connexion réseau.// Si ce test marche → votre architecture est propre.import { PasserCommandeUseCase } from'./usecases/PasserCommandeUseCase';
import { InMemoryCommandeRepo } from'./test/InMemoryCommandeRepo';
import { MockPaiementService } from'./test/MockPaiementService';
it('passe une commande avec succès', async () => {
const repo = newInMemoryCommandeRepo(); // implements CommandeRepositoryconst stripe = newMockPaiementService(); // implements PaiementServiceconst useCase = newPasserCommandeUseCase(repo, stripe, mockNotif);
await useCase.executer({ clientId: 'c1', items: [item1] });
expect(repo.commandes).toHaveLength(1);
expect(stripe.appels).toHaveLength(1);
});
Si ce test échoue sans infrastructure — si vous devez lancer une DB, démarrer NestJS ou appeler Stripe pour tester une règle métier — c’est un signal clair : la Dependency Rule est violée quelque part.