- NestJS
- Architecture
- TypeScript
- Tests
Les ports et adaptateurs appliqués à NestJS : la structure exacte, le découpage qui tient dans la durée, et — plus utile — les cas où cette architecture ne vaut pas son prix.
L'architecture hexagonale isole la logique métier des détails techniques : base de données, HTTP, services externes. Le principe tient en une phrase — le métier ne dépend de rien, ce sont les adaptateurs qui dépendent du métier.
Dans un projet NestJS ordinaire, la dépendance va dans l'autre sens : le service importe le client Prisma, donc le métier dépend du schéma de base. Changer de stockage, ou simplement tester une règle métier, devient une opération à ciel ouvert.
Le découpage
Quatre couches, une seule direction de dépendance — de l'extérieur vers l'intérieur :
src/modules/annonces/
domain/ # entités, règles, interfaces de dépôt
application/ # cas d'usage
infrastructure/ # Prisma, stockage objet, mailer
presentation/ # contrôleurs HTTP, DTOdomain— les entités et les règles. Aucun import de framework, aucun import de Prisma. Si@nestjs/commonapparaît dans ce dossier, la couche a déjà fui.application— les cas d'usage, qui orchestrent le domaine. « Publier une annonce » vit ici, pas dans le contrôleur.infrastructure— les implémentations concrètes des interfaces déclarées par le domaine.presentation— les contrôleurs et les DTO. Cette couche traduit du HTTP vers le métier, et rien d'autre.
Le point de bascule : le dépôt est déclaré par le domaine
Tout le mécanisme tient dans cette inversion. L'interface appartient au domaine ; l'implémentation appartient à l'infrastructure.
// domain/ports/annonce.repository.ts
export const ANNONCE_REPOSITORY = Symbol("ANNONCE_REPOSITORY");
export interface AnnonceRepository {
findById(id: AnnonceId): Promise<Annonce | null>;
save(annonce: Annonce): Promise<void>;
}Le jeton est un Symbol parce qu'une interface TypeScript n'existe pas à l'exécution : elle ne peut donc pas servir de clé d'injection. C'est le détail qui bloque tout le monde une fois, et une seule.
// infrastructure/prisma-annonce.repository.ts
@Injectable()
export class PrismaAnnonceRepository implements AnnonceRepository { /* … */ }
// annonces.module.ts
providers: [{ provide: ANNONCE_REPOSITORY, useClass: PrismaAnnonceRepository }]Le cas d'usage, lui, ne connaît que l'interface :
@Injectable()
export class PublierAnnonce {
constructor(
@Inject(ANNONCE_REPOSITORY) private readonly annonces: AnnonceRepository,
) {}
}Le vrai bénéfice : des tests métier sans base de données
C'est le retour sur investissement le plus immédiat, et il est souvent sous-estimé. Une règle métier se teste avec un dépôt en mémoire — dix lignes, aucun conteneur, aucune migration :
const annonces = new AnnonceRepositoryEnMemoire();
const usecase = new PublierAnnonce(annonces);
await expect(usecase.execute(brouillonSansPrix)).rejects.toThrow(PrixRequis);Ces tests s'exécutent en millisecondes. Ils tournent donc réellement, à chaque sauvegarde — ce qui n'est jamais le cas d'une suite qui démarre Postgres.
Le second bénéfice apparaît plus tard, et c'est celui qui justifie l'exercice : le jour où le stockage change — passage à un service externe, ajout d'un cache, changement de fournisseur — le métier n'est pas touché. On écrit un adaptateur de plus.
Ce que ça coûte, honnêtement
Trois coûts réels, qu'il faut assumer :
- Plus de fichiers. Un CRUD trivial passe de deux fichiers à six. Sur une entité sans règles, c'est de la cérémonie pure.
- Des traductions à écrire. Entité de domaine vers modèle Prisma, modèle Prisma vers DTO. C'est fastidieux, et c'est le prix de la frontière.
- Une discipline collective. Un seul import de Prisma dans le domaine annule le bénéfice, sans produire la moindre erreur. La règle doit être vérifiée automatiquement, par une règle de lint sur les frontières d'import — pas laissée à la vigilance en revue.
Quand ne pas le faire
C'est la partie que l'on trouve rarement écrite.
Sur un module sans règles métier. Une table de référence, un CRUD d'administration, une gestion de tags : le contrôleur peut appeler Prisma directement. Y appliquer l'hexagone produit six fichiers pour zéro règle protégée.
Sur un prototype dont on ignore encore le domaine. Cette architecture protège des frontières. Tracer des frontières avant de savoir où elles passent revient à figer un découpage que l'on va devoir défaire.
À l'échelle du projet entier, uniformément. Le bon usage est par module. Sur une marketplace, l'annonce, le paiement et la modération le méritent ; l'envoi de la lettre d'information non.
La question à se poser module par module est simple : « ai-je ici des règles que je veux pouvoir tester et faire évoluer sans toucher au stockage ? » Si la réponse est non, l'hexagone ne protège rien — il facture seulement son loyer.