// Clean Architecture — Référence complète

La Clean
Architecture

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.

04 — Frameworks & DriversExpress · NestJS · MySQL · React · Stripe SDK
03 — Interface AdaptersControllers · Presenters · Gateways
02 — Use CasesPorts · Interactors
Entities
dépendances
🟢
Cercle 01 — entities/
Entities
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 class Commande { private statut: StatutCommande; private items: Item[]; constructor(items: Item[]) { if (items.length === 0) throw new Error('Une commande doit avoir des items'); this.items = items; this.statut = 'pending'; } payer(montant: number): void { if (this.statut !== 'pending') throw new Error('Commande déjà traitée'); if (montant <= 0) throw new Error('Montant invalide'); this.statut = 'paid'; } annuler(): void { if (this.statut === 'paid') throw new Error('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.

src/ ├── entities/ ← Cercle 01 — noyau pur, zero import │ ├── Commande.ts // payer() annuler() — règles métier │ ├── Produit.ts // prixHT() tva() stockDisponible() │ └── Client.ts // peuxCommander() adresseValide()├── usecases/ ← Cercle 02 — orchestration │ ├── PasserCommandeUseCase.ts // executer(cmd) — séquence applicative │ └── ports/ ← interfaces POSSÉDÉES par le UseCase │ ├── CommandeRepository.ts // <<interface>> save() findById() │ ├── PaiementService.ts // <<interface>> debiter() rembourser() │ └── NotificationService.ts // <<interface>> envoyer(dest, msg)├── adapters/ ← Cercle 03 — traducteurs │ ├── controllers/ │ │ └── CommandeController.ts // reçoit HTTP, appelle UseCase │ ├── presenters/ │ │ └── CommandePresenter.ts // formate la réponse JSON/ViewModel │ └── gateways/ │ ├── PostgresCommandeRepo.ts // implements CommandeRepository │ └── StripeService.ts // implements PaiementService└── frameworks/ ← Cercle 04 — détails techniques ├── express/ │ └── app.ts // NestFactory.create() ou Express ├── database/ │ └── postgres.ts // connexion pool, config driver └── config/ └── container.ts // IoC — bind interfaces → implémentations
04 — frameworks/
Frameworks & Drivers
express/app.tsdatabase/postgres.tsconfig/container.tsNestJS · TypeORM · Stripe SDK
03 — adapters/
Interface Adapters
CommandeController.tsCommandePresenter.tsPostgresCommandeRepo.tsStripeService.ts
02 — usecases/
Use Cases & Ports
PasserCommandeUseCase.tsports/CommandeRepository.tsports/PaiementService.tsports/NotificationService.ts
01 — entities/
Entities — domaine pur
Commande.tsProduit.tsClient.tsStatutCommande (VO)
06

Exemples de code — couche par couche

usecases/PasserCommandeUseCase.ts — Cercle 02
// Le UseCase ne connaît QUE des interfaces (ports) export class PasserCommandeUseCase { constructor( private repo: CommandeRepository, // ← interface, pas MySQL private paiement: PaiementService, // ← interface, pas Stripe private notif: NotificationService // ← interface, pas SMTP ) {} async executer(cmd: PasserCommandeCmd): Promise<void> { const client = await this.repo.findClientById(cmd.clientId); client.peuxCommander(); // règle Entity const commande = new Commande(cmd.items); // Entity await this.repo.save(commande); await this.paiement.debiter(commande.total(), client); commande.payer(commande.total()); await this.notif.envoyer(client.email, 'Commande confirmée'); await this.repo.save(commande); } }
usecases/ports/CommandeRepository.ts — Interface (port)
// Défini PAR le UseCase. Stripe et MySQL ne décident pas de cette interface. export interface CommandeRepository { save(commande: Commande): Promise<void>; findById(id: string): Promise<Commande>; findClientById(id: string): Promise<Client>; }
adapters/gateways/PostgresCommandeRepo.ts — Cercle 03
// Implémente l'interface du UseCase. C'est la SEULE couche qui connaît TypeORM. export class PostgresCommandeRepo implements CommandeRepository { async save(commande: Commande): Promise<void> { const row = this.toORM(commande); // Entity → modèle ORM await this.typeormRepo.save(row); } async findById(id: string): Promise<Commande> { const row = await this.typeormRepo.findOneBy({ id }); return this.toDomain(row); // ligne SQL → Entity } private toORM(c: Commande): CommandeORM { /* mapping... */ } private toDomain(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 port PaiementService. 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 = new InMemoryCommandeRepo(); // implements CommandeRepository const stripe = new MockPaiementService(); // implements PaiementService const useCase = new PasserCommandeUseCase(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.