Développement Web

Architecture hexagonale avec NestJS : ports et adaptateurs

14 min de lecture
📍 Vue d’ensemble : ce tutoriel s’inscrit dans une série sur l’architecture logicielle. Pour la carte conceptuelle, lisez le guide principal Architecture logicielle moderne : DDD, microservices et event-driven.

Vous avez un domaine métier propre — par exemple le module de commandes modélisé avec le Domain-Driven Design — et vous devez maintenant le brancher sur le monde réel : une API HTTP, une base de données, peut-être une messagerie. Le piège classique consiste à mélanger tout cela, jusqu’à ce que la règle « on ne confirme pas une commande vide » se retrouve coincée dans un contrôleur web, impossible à tester sans démarrer un serveur. L’architecture hexagonale, décrite par Alistair Cockburn dès le milieu des années 1990 et rebaptisée ports et adaptateurs en 2005, empêche exactement cela. Dans ce tutoriel, vous allez structurer le service de commandes avec NestJS de façon à ce que son cœur ignore totalement qu’il est exposé en HTTP et stocké en base.

🎯 Ce que vous allez apprendre

  • Distinguer un port pilote (ce que l’application offre) d’un port piloté (ce dont elle a besoin).
  • Écrire un cas d’usage qui orchestre le domaine sans dépendre d’aucun outil.
  • Brancher un adaptateur primaire (contrôleur NestJS) et un adaptateur secondaire (dépôt) sur ces ports.
  • Utiliser l’injection de dépendances de NestJS pour inverser le sens des dépendances.
  • Tester le cas d’usage en quelques millisecondes, sans HTTP ni base, grâce à un faux dépôt.

🛠️ Ce que vous allez construire

Une petite application NestJS exposant une route POST /orders qui crée et confirme une commande. Mais l’essentiel est invisible depuis l’extérieur : la logique vivra dans un cas d’usage indépendant, branché sur un dépôt en mémoire interchangeable. Vous prouverez cette indépendance par un test qui instancie le cas d’usage sans NestJS du tout.

Prérequis

  • Node.js 20+ et l’interface en ligne de commande NestJS : npm i -g @nestjs/cli (NestJS 11 au moment d’écrire).
  • Avoir suivi le tutoriel DDD, ou disposer d’un domaine équivalent (classes Order, Money).
  • Niveau intermédiaire. Test express : si vous savez ce qu’est un constructeur injecté dans une classe NestJS, vous êtes prêt.
  • ⏱️ Temps estimé : ~50 minutes.

Étape 1 — Le modèle mental de l’hexagone

Imaginez votre application comme un hexagone. Au centre, le domaine et la logique applicative — le code qui a de la valeur métier. Sur les bords, des ports : de simples interfaces qui décrivent les points de contact. À l’extérieur, des adaptateurs : le code qui traduit le monde réel (une requête HTTP, une requête SQL) dans le langage des ports, et inversement. La règle d’or est que les flèches de dépendance pointent toujours vers l’intérieur : les adaptateurs connaissent le domaine, jamais le contraire.

On distingue deux familles de ports. Les ports pilotes (ou primaires) sont ceux par lesquels on actionne l’application : « passer une commande » est un service que l’application offre. Les ports pilotés (ou secondaires) sont ceux dont l’application a besoin pour fonctionner : « sauvegarder une commande » est un service qu’elle réclame à l’extérieur. Cette distinction guide tout le découpage : un contrôleur web est un adaptateur qui branche un port pilote, tandis qu’un dépôt PostgreSQL est un adaptateur qui branche un port piloté.

Pour saisir l’apport, comparons avec l’architecture en couches classique, celle qu’on rencontre par défaut : présentation, puis métier, puis accès aux données, empilées de haut en bas. Le problème est que, dans ce modèle, la couche métier dépend de la couche d’accès aux données : elle importe le code de la base. Le sens de la dépendance va donc du métier vers la technique, exactement à l’envers de ce qu’on souhaite. Conséquence : impossible de tester le métier sans une base, et tout changement de stockage remonte jusqu’au cœur. L’hexagonale ne supprime pas ces couches, elle inverse une flèche : le métier déclare un port, et c’est la technique qui s’y conforme. Ce renversement, en apparence anodin, est ce qui rend le cœur testable et durable.

Point d’étape — Sur un schéma, placez votre cas d’usage au centre. Tracez une flèche entrante depuis le contrôleur (port pilote) et une flèche sortante vers le dépôt (port piloté). Si vous arrivez à dessiner cela, le reste n’est que de la traduction en code.

Étape 2 — Déclarer les ports comme des interfaces

Un port n’est rien d’autre qu’une interface TypeScript. Commençons par le port piloté dont le domaine a besoin : la persistance. Nous l’accompagnons d’un jeton d’injection, car les interfaces TypeScript disparaissent à la compilation et ne peuvent pas servir directement d’identifiant à NestJS.

// domain/order-repository.port.ts
import { Order } from './order';

export interface OrderRepository {
  save(order: Order): Promise<void>;
  findById(id: string): Promise<Order | null>;
}

// Jeton d'injection : un Symbol stable que NestJS pourra résoudre
export const ORDER_REPOSITORY = Symbol('OrderRepository');

Ce fichier vit dans le dossier domain et ne dépend que du domaine. Aucun import de NestJS, de PostgreSQL ou d’Express : c’est la garantie que notre cœur reste pur. Le Symbol nous servira de pont à l’étape de câblage, sans introduire la moindre dépendance technique dans le contrat lui-même.

Étape 3 — Écrire le cas d’usage

Le cas d’usage est le port pilote concrétisé : il décrit une action que l’application sait accomplir, ici « passer une commande ». Il orchestre le domaine et s’appuie sur le port piloté pour persister, mais il ignore d’où vient la demande et où vont les données.

// application/place-order.service.ts
import { Inject, Injectable } from '@nestjs/common';
import { Order } from '../domain/order';
import { Money } from '../domain/money';
import { OrderRepository, ORDER_REPOSITORY } from '../domain/order-repository.port';

export interface PlaceOrderCommand {
  orderId: string;
  customerId: string;
  lines: { productId: string; unitPriceCents: number; quantity: number }[];
}

@Injectable()
export class PlaceOrderService {
  constructor(
    @Inject(ORDER_REPOSITORY) private readonly orders: OrderRepository
  ) {}

  async execute(cmd: PlaceOrderCommand): Promise<{ id: string; totalCents: number }> {
    const order = Order.create(cmd.orderId, cmd.customerId);
    for (const line of cmd.lines) {
      order.addLine(line.productId, Money.of(line.unitPriceCents), line.quantity);
    }
    order.confirm();
    await this.orders.save(order);
    // À ce point, on publierait order.pullEvents() sur une messagerie
    return { id: order.id, totalCents: order.total.amount };
  }
}

Observez la dépendance : PlaceOrderService reçoit un OrderRepository — l’interface, pas une implémentation. Il ne sait pas si les commandes finissent dans PostgreSQL, dans MongoDB ou dans une simple Map. C’est l’inversion de dépendance évoquée dans le guide principal, rendue concrète par le décorateur @Inject. Le commentaire sur pullEvents() rappelle où se brancherait l’architecture orientée événements : le cas d’usage produit les faits, un adaptateur de messagerie les diffuserait.

Étape 4 — L’adaptateur secondaire : un dépôt en mémoire

Le port piloté réclame une implémentation. Commençons par la plus simple, en mémoire, parfaite pour démarrer et pour les tests. Elle implémente l’interface, ce qui force le compilateur à vérifier qu’elle respecte le contrat.

// infrastructure/in-memory-order.repository.ts
import { Injectable } from '@nestjs/common';
import { Order } from '../domain/order';
import { OrderRepository } from '../domain/order-repository.port';

@Injectable()
export class InMemoryOrderRepository implements OrderRepository {
  private readonly store = new Map<string, Order>();

  async save(order: Order): Promise<void> {
    this.store.set(order.id, order);
  }

  async findById(id: string): Promise<Order | null> {
    return this.store.get(id) ?? null;
  }
}

Cet adaptateur vit dans infrastructure, le seul dossier autorisé à connaître les détails techniques. Le jour où vous passerez à une vraie base, vous écrirez un PostgresOrderRepository dans le même dossier, implémentant la même interface — et pas une ligne du cas d’usage ni du domaine ne changera. C’est exactement le scénario qui rend l’hexagonale rentable : le changement de technologie reste confiné à la périphérie.

Étape 5 — L’adaptateur primaire : le contrôleur HTTP

Côté entrée, le contrôleur NestJS traduit une requête HTTP en appel du cas d’usage. Il ne contient aucune règle métier : il extrait le corps de la requête, le passe au service, et renvoie le résultat. S’il disparaissait au profit d’une interface en ligne de commande ou d’un consommateur de messages, le cas d’usage resterait identique.

// infrastructure/orders.controller.ts
import { Body, Controller, Post } from '@nestjs/common';
import { PlaceOrderService, PlaceOrderCommand } from '../application/place-order.service';

@Controller('orders')
export class OrdersController {
  constructor(private readonly placeOrder: PlaceOrderService) {}

  @Post()
  async create(@Body() body: PlaceOrderCommand) {
    return this.placeOrder.execute(body);
  }
}

Dans un vrai projet, on validerait le corps de la requête avec un DTO et le ValidationPipe de NestJS avant de le transmettre — la validation d’entrée est elle aussi un rôle d’adaptateur, jamais du domaine. On garde ici la version minimale pour ne pas brouiller le propos, mais retenez le principe : l’adaptateur protège le cœur des entrées malformées.

Étape 6 — Câbler le tout avec le conteneur NestJS

Il manque le lien entre le jeton ORDER_REPOSITORY et son implémentation concrète. C’est le rôle du module NestJS, qui joue ici le rôle de « racine de composition » : l’unique endroit où l’on décide quel adaptateur branche quel port.

// orders.module.ts
import { Module } from '@nestjs/common';
import { OrdersController } from './infrastructure/orders.controller';
import { PlaceOrderService } from './application/place-order.service';
import { ORDER_REPOSITORY } from './domain/order-repository.port';
import { InMemoryOrderRepository } from './infrastructure/in-memory-order.repository';

@Module({
  controllers: [OrdersController],
  providers: [
    PlaceOrderService,
    { provide: ORDER_REPOSITORY, useClass: InMemoryOrderRepository },
  ],
})
export class OrdersModule {}

La ligne { provide: ORDER_REPOSITORY, useClass: InMemoryOrderRepository } est le cœur du câblage : elle dit à NestJS « quand quelqu’un réclame le jeton ORDER_REPOSITORY, fournis une instance de InMemoryOrderRepository ». Pour basculer en production, vous remplacez useClass: InMemoryOrderRepository par useClass: PostgresOrderRepository, et rien d’autre ne bouge. Le choix de l’implémentation est devenu une décision d’une seule ligne, isolée du reste.

Point d’étape — Lancez npm run start:dev puis envoyez une requête : curl -X POST http://localhost:3000/orders -H "Content-Type: application/json" -d '{"orderId":"ord-1","customerId":"c1","lines":[{"productId":"p1","unitPriceCents":1500,"quantity":2}]}'. Vous devez recevoir {"id":"ord-1","totalCents":3000}. Si vous obtenez une erreur d’injection, vérifiez que le provider du jeton figure bien dans le module.

Étape 7 — Vérification : tester sans NestJS

Voici la récompense de tout ce découpage. Comme le cas d’usage ne dépend que d’une interface, on peut le tester sans démarrer NestJS, sans HTTP et sans base : on lui passe simplement le dépôt en mémoire à la main.

// place-order.service.spec.ts (ou un script tsx)
import { PlaceOrderService } from './application/place-order.service';
import { InMemoryOrderRepository } from './infrastructure/in-memory-order.repository';
import { strict as assert } from 'node:assert';

async function main() {
  const repo = new InMemoryOrderRepository();
  // On instancie le service à la main : aucune magie NestJS ici
  const service = new PlaceOrderService(repo);

  const result = await service.execute({
    orderId: 'ord-1',
    customerId: 'cust-1',
    lines: [{ productId: 'p1', unitPriceCents: 1500, quantity: 2 }],
  });

  assert.equal(result.totalCents, 3000);

  const saved = await repo.findById('ord-1');
  assert.equal(saved?.status, 'CONFIRMED');

  console.log('Cas d\'usage validé sans démarrer NestJS ✓');
}

main();

Le détail crucial est new PlaceOrderService(repo) : on construit le service nous-mêmes, en lui injectant manuellement un faux dépôt. Le décorateur @Inject n’a aucun effet hors du conteneur NestJS, et c’est tant mieux — cela prouve que notre logique applicative est du TypeScript ordinaire, totalement découplé du framework. Un test qui s’exécute en quelques millisecondes, sans dépendance externe, est un test qu’on lance à chaque sauvegarde sans y penser.

Quand l’hexagonale est-elle rentable ?

Cette structure a un coût : plus de fichiers, une indirection par port, un peu de cérémonie à l’injection. Il serait malhonnête de prétendre qu’elle se justifie toujours. Pour un script jetable ou un prototype destiné à être jeté dans la semaine, elle est sur-dimensionnée. Son rendement devient évident dès que trois conditions apparaissent : la logique métier porte de vraies règles (pas du simple passe-plat vers la base), l’application est censée vivre plusieurs années, et plusieurs personnes la maintiennent. Dans ce cas, le surcoût initial est remboursé dès la première migration technique ou la première vague de tests à écrire.

Un signe pratique permet de trancher : demandez-vous si vous pourriez avoir besoin de piloter votre logique par un autre moyen que le web — une commande planifiée, un consommateur de messages, un import par fichier. Si oui, l’hexagonale vous offre ces points d’entrée gratuitement : il suffit d’ajouter un adaptateur primaire, le cas d’usage ne bouge pas. À l’inverse, si votre application n’est qu’une fine couche HTTP au-dessus d’une base, sans règle propre, le bénéfice s’amenuise. L’architecture se met au service du problème, jamais l’inverse.

🐞 Pièges fréquents

Symptôme Cause probable Correctif
Nest can't resolve dependencies of PlaceOrderService Le provider du jeton manque dans le module Ajouter { provide: ORDER_REPOSITORY, useClass: ... } aux providers
On tente d’injecter une interface directement Les interfaces TS n’existent pas à l’exécution Utiliser un jeton Symbol (ou une chaîne) avec @Inject
Le domaine importe @nestjs/common Confusion entre couche application et domaine Le domaine reste pur ; seules application et infrastructure connaissent NestJS
Une règle métier dans le contrôleur Réflexe de tout mettre « là où c’est rapide » Déplacer la règle dans le domaine ou le cas d’usage
Impossible de tester sans base Le cas d’usage dépend d’une implémentation concrète Dépendre du port (interface) et injecter un faux en test

✅ Récapitulatif

Vous avez organisé une application autour de son domaine, et non autour de son framework. Le cas d’usage PlaceOrderService orchestre le métier en ne connaissant que des interfaces ; le contrôleur et le dépôt sont devenus des adaptateurs interchangeables, branchés en une ligne dans le module. La conséquence pratique est double : changer de base de données ne touche plus le métier, et chaque règle se teste instantanément sans infrastructure. C’est cette structure qui rend ensuite réaliste l’extraction d’un service vers un déploiement séparé.

🧾 Aide-mémoire

Élément Rôle Dossier
Port pilote Action offerte (cas d’usage) application
Port piloté Besoin externe (interface de dépôt) domain
Adaptateur primaire Contrôleur HTTP, CLI, consommateur infrastructure
Adaptateur secondaire Dépôt base de données ou mémoire infrastructure
Jeton d’injection Pont Symbol port → implémentation domain
Module Racine de composition (le câblage) racine

💪 À vous de jouer

Ajoutez un second cas d’usage GetOrderService qui retrouve une commande par son identifiant et renvoie son statut et son total. Réutilisez le même port OrderRepository sans en ajouter de méthode, puis exposez une route GET /orders/:id.

Voir une solution
@Injectable()
export class GetOrderService {
  constructor(@Inject(ORDER_REPOSITORY) private readonly orders: OrderRepository) {}
  async execute(id: string) {
    const order = await this.orders.findById(id);
    if (!order) throw new Error('Commande introuvable');
    return { id: order.id, status: order.status, totalCents: order.total.amount };
  }
}
// Contrôleur : @Get(':id') get(@Param('id') id: string) { return this.getOrder.execute(id); }

Tutoriels liés

Pour aller plus loin

Questions fréquentes

Hexagonale, oignon, propre : est-ce la même chose ?
Les architectures en oignon (Jeffrey Palermo) et « propre » (Robert C. Martin) partagent l’idée centrale de l’hexagonale : le domaine au centre, les dépendances qui pointent vers l’intérieur. Elles diffèrent surtout par le nombre de couches nommées. Maîtriser l’hexagonale vous donne les trois.

Faut-il un dossier par couche dès le premier jour ?
Pour un petit projet, trois dossiers — domain, application, infrastructure — suffisent largement. Multipliez les sous-dossiers seulement quand la taille l’impose ; la structure doit servir la lisibilité, pas l’inverse.

L’injection de dépendances est-elle obligatoire ?
Non, mais elle rend l’inversion naturelle. Sans conteneur, vous câbleriez les dépendances à la main dans un point d’entrée unique — ce que fait d’ailleurs notre test. NestJS automatise simplement ce câblage.

Mots-clés : architecture hexagonale, ports et adaptateurs, NestJS, inversion de dépendance, injection de dépendances, cas d’usage, testabilité.

Service ITSkillsCenter

Application mobile Android et iOS

Création d'application mobile Android et iOS. À partir de 350 000 FCFA.

Démarrer mon projet
Publicité