Principes, couches concentriques, règle de dépendance et exemple NestJS complet — Jeffrey Palermo (2008), dans l’esprit de Robert C. Martin.
🧅
Jeffrey Palermo — 2008
L’Onion Architecture, introduite par Jeffrey Palermo en 2008, partage le même ADN que la Clean Architecture d’Uncle Bob : le domaine métier ne dépend de rien d’extérieur. Les frameworks, les bases de données, l’interface utilisateur sont des détails périphériques qui s’enroulent autour du cœur stable — comme les couches d’un oignon autour de son centre.
01
La métaphore : l’oignon et le château fort
Quand tu épluches un oignon, tu traverses des couches successives. Plus tu vas vers le centre, plus tu atteins quelque chose de fondamental, stable, essentiel. La couche extérieure (la peau) est ce qui change le plus facilement. Le cœur, lui, reste.
C’est exactement la philosophie de l’Onion Architecture : le domaine métier est le cœur immuable. L’infrastructure — la DB, le framework HTTP, l’interface utilisateur — est la peau extérieure, volatile et remplaçable.
Deuxième métaphore : imagine un château médiéval. Le donjon central (le domaine) est inviolable. Les douves (l’infrastructure) peuvent être déplacées, asséchées, modifiées — le donjon ne le sait pas et ne s’en préoccupe pas. Les visiteurs s’adaptent au château, jamais l’inverse.
02
Les 4 couches concentriques
L’Onion Architecture s’organise en cercles concentriques. La règle absolue : les dépendances ne pointent que vers le centre. Jamais vers l’extérieur.
Le noyau inviolable. Entities et Value Objects qui encodent les règles métier. Zéro import externe — ni TypeORM, ni NestJS, ni Stripe. Du TypeScript pur. C’est la couche la plus stable : elle ne change que si les règles métier changent.
EntitiesValue ObjectsZero importRègles métier
« new Commande() doit fonctionner sans infrastructure. »
🟣
Couche 02 — domain/services/
Domain Services
La grande spécificité de l’Onion. Contient les interfaces (ports) dont le domaine a besoin — ICommandeRepository, IPaiementService — et la logique métier impliquant plusieurs entités. Ces interfaces appartiennent au domaine, pas à l’infrastructure qui les implémente.
InterfacesPortsContratsMulti-entités
« La norme de prise est définie par l’appareil, pas par EDF. »
🟢
Couche 03 — application/
Application Services
Les Use Cases de l’application. Orchestrent le flux : récupèrent des entités, appliquent des règles, utilisent les interfaces de couche 2. Ne connaissent ni PostgreSQL, ni Stripe, ni NestJS. Testables sans infrastructure.
Use CasesOrchestrationTestableAgnostique
🟠
Couche 04 — infrastructure/
Infrastructure
Tout ce qui est concret, technique, remplaçable. NestJS, Express, TypeORM, PostgreSQL, Stripe SDK, les Controllers HTTP, les Guards, les tests d’intégration. Implémente les interfaces définies en couche 2. Si cette couche change entièrement, le domaine ne le sait pas.
NestJSTypeORMPostgreSQLStripeRemplaçable
« La DB est un détail. Le framework est un détail. »
03
La Dependency Rule — la loi absolue
“Les dépendances ne pointent QUE vers le centre.” Rien dans une couche intérieure ne peut mentionner quoi que ce soit d’une couche extérieure. Ni son nom, ni sa fonction, ni son type, ni sa variable.
04 Infrastructure
→dépend de
03 Application
→dépend de
02 Domain Svc
→dépend de
01 Domain Model
✗interdit
vers l’extérieur
Cette règle est rendue possible par l’inversion de dépendance (DIP). Quand un Use Case a besoin de persister une commande, il ne s’adresse pas directement à PostgreSQL — il utilise une interfaceICommandeRepository définie en couche 2. L’adapter PostgreSQL de couche 4 implémente cette interface. Le UseCase définit le contrat, l’Infrastructure s’y conforme.
❌ Sans Onion Architecture
Le UseCase importe directement PostgresRepo
Changer de DB = modifier le UseCase
Impossible de tester sans lancer une DB
Le domaine connaît Stripe, TypeORM, NestJS
Le schéma DB dicte la structure objet
✓ Avec Onion Architecture
Le UseCase dépend d’une interface uniquement
Swap PostgreSQL → MongoDB : 1 seul fichier
Tests avec InMemoryRepo sans infra
Le domaine ignore tout de l’extérieur
La structure objet suit le métier, pas la DB
04
Onion vs Clean Architecture — Les correspondances
Les deux architectures partagent le même principe fondamental. Leurs différences sont essentiellement de vocabulaire et d’organisation, pas de philosophie.
La différence clé : L’Onion crée une couche explicite “Domain Services” pour les interfaces et contrats. Clean Architecture les appelle “Ports” et les intègre dans le cercle Use Cases. En pratique, l’effet est identique — les interfaces appartiennent au domaine, pas à l’infrastructure qui les implémente.
05
Structure src/ — Feature “Passer une commande”
Voici comment les 4 couches se traduisent concrètement en fichiers pour une feature e-commerce.
C’est la question qui dérange le plus les développeurs NestJS. La réponse nuancée : NestJS est un détail de couche 4, mais il faut être précis sur ce que “NestJS” veut dire — ce n’est pas monolithique.
Élément NestJS
Couche
Statut
Raison
@Module(), NestFactory
04 — Infrastructure
✓ OK
Bootstrap framework — détail pur
@Controller(), @Get(), @Post()
04 — Infrastructure
✓ OK
Routing HTTP — remplaçable par Express
@Guard(), @Interceptor(), @Pipe()
04 — Infrastructure
✓ OK
Cycle de vie HTTP — infrastructure pure
TypeORM @Entity, @Column sur le modèle ORM
04 — Infrastructure
✓ OK
Modèle ORM séparé de l’Entity domaine
@Injectable() sur un Use Case
03 — Application
⚠ Tolérable
Métadonnée DI — n’affecte pas le comportement, test manuel toujours possible
@Injectable() sur un Domain Service
02 — Domain Services
✗ Éviter
Couche 2 doit rester agnostique
@Entity() TypeORM sur une Domain Entity
01 — Domain Model
✗ Interdit
Violation grave — le schéma DB dicterait ta structure métier
Le péché capital des tutos NestJS : mettre @Entity() et @Column() directement sur tes classes domaine. C’est la façon la plus rapide de polluer le noyau de ton oignon. La solution : deux classes distinctes — une Entity domaine pure et un modèle ORM séparé, reliés par un Mapper.
❌ Mauvaise pratique — Entity polluée
Import TypeORM en tête de Commande.ts
Décorateurs @Entity @Column partout
Propriétés publiques — encapsulation impossible
Impossible de tester sans démarrer une DB
✓ Bonne pratique — deux modèles séparés
Commande.ts (couche 1) : zéro import
CommandeORM.ts (couche 4) : décorateurs TypeORM
CommandeMapper.ts : traduit entre les deux
new Commande() suffit pour tester
🔌
Le compromis pragmatique de @Injectable()
@Injectable() sur un Use Case (couche 3) est techniquement une violation — c’est une dépendance vers NestJS dans une couche qui devrait être agnostique. Mais c’est un décorateur de métadonnées : il n’affecte pas le comportement de la classe. Tu peux toujours instancier new PasserCommandeService(mockRepo, mockPaiement) dans tes tests sans lancer NestJS. C’est la violation tolérable selon Uncle Bob — celle qui ne nuit pas à la testabilité ni à la maintenabilité.
infrastructure/config/CommandeModule.ts — DI NestJS en couche 04
// Le seul endroit qui sait tout. Relie les interfaces à leurs implémentations.@Module({
imports: [TypeOrmModule.forFeature([CommandeORM])],
providers: [
// Bind interface → implémentation concrète
{ provide: 'ICommandeRepository', useClass: PostgresCommandeRepository },
{ provide: 'IPaiementService', useClass: StripeService },
{ provide: 'INotificationService', useClass: EmailService },
PasserCommandeService,
CommandeVerificationService,
],
controllers: [CommandeController],
})
export classCommandeModule {}
// Pour les tests : swap sans toucher au UseCase// { provide: 'ICommandeRepository', useClass: InMemoryCommandeRepository }// { provide: 'IPaiementService', useClass: MockPaiementService }
08
Le test ultime d’une Onion Architecture réussie
Le critère de succès est simple et implacable : peut-on tester toute la logique métier sans lancer une seule infrastructure ?
🧪
Test des Entities
Tester sans infrastructure
new Commande([item]) et test d’une règle métier sans DB, sans NestJS, sans HTTP. Si c’est possible, le Domain Model (couche 1) est propre.
Jest purZero infra
🔄
Test du Swap DB
Remplacer PostgreSQL par MongoDB
Combien de fichiers modifiés ? En Onion correcte : exactement 1 — le Repository d’infrastructure. Les couches 1, 2, 3 restent intactes.
1 seul fichierDomaine intact
🌐
Test du Swap Framework
Remplacer NestJS par Express
Seule la couche 4 change. Controllers, modules, config DI — mais les Use Cases et le domaine ne voient rien. C’est la preuve que NestJS est bien un détail.
Framework agnostiqueDomaine stable
Test unitaire pur — zéro infrastructure, zéro NestJS
// Ce test ne nécessite aucune DB, aucun NestJS, aucune connexion réseau.// InMemoryCommandeRepository et MockPaiementService implémentent les interfaces couche 02.import { PasserCommandeService } from'../application/services/PasserCommandeService';
import { InMemoryCommandeRepository } from'./fakes/InMemoryCommandeRepository';
import { MockPaiementService } from'./fakes/MockPaiementService';
describe('PasserCommandeService', () => {
it('passe une commande avec succès', async () => {
// Arrange — fakes qui implémentent les interfaces de couche 02const repo = newInMemoryCommandeRepository();
const paiement = newMockPaiementService();
const notif = newMockNotificationService();
const verif = newCommandeVerificationService();
// Instanciation SANS NestJS — preuve que le UseCase est agnostiqueconst useCase = newPasserCommandeService(repo, paiement, notif, verif);
// Actconst commandeId = await useCase.executer({
clientId: ClientId.de('client-1'),
lignes: [{ produitId: 'p1', quantite: 2, prixUnitaire: Montant.de(50) }]
});
// Assertexpect(repo.commandes).toHaveLength(1);
expect(paiement.debits).toHaveLength(1);
expect(notif.messages).toHaveLength(1);
});
it('refuse une commande vide', () => {
// Domain Model testé directement — encore plus directexpect(() => newCommande(CommandeId.nouveau(), clientId, []))
.toThrow('Une commande ne peut pas être vide');
});
});
Si ce test nécessite docker-compose up pour démarrer — si tu dois lancer une base de données, 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 dans tes couches internes.
09
Résumé — Une phrase par couche
01 — Domain Model
« Quelles sont mes règles métier ? »
EntitiesValue ObjectsZero importPlus stable
02 — Domain Services
« De quoi mon domaine a-t-il besoin ? »
Interfaces (ports)Domain ServicesContrats appartenant au domaine
03 — Application
« Comment s’enchaînent les étapes ? »
Use CasesOrchestrationAgnostique du framework
04 — Infrastructure
« Comment je l’implémente concrètement ? »
NestJSTypeORMStripeEmailPlus volatile
La règle universelle : les flèches de dépendance ne pointent que vers le centre. PostgreSQL connaît ICommandeRepository. ICommandeRepository ne connaît pas PostgreSQL. NestJS connaît tes Use Cases. Tes Use Cases ne connaissent pas NestJS. C’est tout.