Le problème
Beaucoup d’équipes comprennent l’idée des ports et adapters, mais le design se dégrade quand même pour une raison simple :
les dépendances partent dans le mauvais sens.
On voit souvent ce genre de situation :
- le domaine importe des types HTTP
- un use case dépend directement d’un repository ORM concret
- les objets métier connaissent le schéma SQL
- la logique métier manipule des DTO d’API externe
- un service applicatif dépend d’un SDK Stripe, AWS ou d’un client Kafka
À ce moment-là, l’architecture hexagonale existe peut-être dans les dossiers, mais plus dans le code.
Le vrai sujet n’est pas seulement “avoir des couches”. Le vrai sujet est :
qui a le droit de dépendre de quoi.
L’idée simple
Dans une architecture hexagonale, les dépendances doivent pointer vers le cœur du système, pas l’inverse.
Autrement dit :
- l’infrastructure dépend de l’application ou du domaine
- l’application dépend du domaine
- le domaine ne dépend pas de l’infrastructure
La base de données, le framework, le transport HTTP ou les services externes doivent se brancher sur le cœur. Le cœur ne doit pas être construit autour d’eux.
La bonne question à se poser n’est donc pas :
“Dans quel dossier je mets cette classe ?”
La bonne question est :
“Dans quel sens cette dépendance part-elle ?”
Comment ça fonctionne
La direction des dépendances est une règle simple à énoncer et difficile à garder si on ne la rend pas explicite.
1. Le domaine doit rester stable
Le domaine porte ce qui change le moins pour de mauvaises raisons :
- les règles métier
- les invariants
- les concepts métier
- les comportements importants
Il peut évoluer, bien sûr, mais il ne devrait pas changer parce que :
- on remplace PostgreSQL par DynamoDB
- on passe d’Express à Fastify
- on change de provider de paiement
- on ajoute Kafka
- on change un format HTTP
Si le domaine change pour des raisons purement techniques, la dépendance va déjà dans le mauvais sens.
2. L’infrastructure doit s’adapter
L’infrastructure contient les détails volatils :
- base de données
- framework web
- bus de messages
- clients d’API
- système de fichiers
- cache
- authentification externe
Ces éléments changent plus souvent et varient selon le contexte du projet.
Ils doivent donc dépendre du cœur pour l’utiliser ou l’implémenter.
Par exemple :
- un controller HTTP dépend d’un use case
- un repository PostgreSQL implémente un port défini côté application ou domaine
- un client Stripe implémente un port de paiement
3. Les ports servent à contrôler la direction
La direction des dépendances ne repose pas sur la bonne volonté. Elle repose sur des contrats placés au bon endroit.
Exemple :
- le domaine ou l’application déclare
PaymentGateway - l’infrastructure fournit
StripePaymentGateway
Le besoin vient du cœur. L’implémentation vient du bord.
C’est ce placement qui inverse le couplage attendu par défaut.
Schéma
Lecture utile du schéma :
- les détails techniques dépendent de la couche application
- la couche application dépend du domaine
- le domaine reste au centre
- aucune flèche ne remonte du domaine vers l’infrastructure
Exemple concret
Prenons un use case simple : confirmer une réservation.
Le système doit :
- charger une réservation
- vérifier qu’elle peut être confirmée
- enregistrer le nouvel état
- envoyer une confirmation
Mauvaise direction de dépendance
import { Request } from "express";
import { PrismaClient } from "@prisma/client";
import { Resend } from "resend";
const prisma = new PrismaClient();
const resend = new Resend(process.env.RESEND_API_KEY);
export class ConfirmBookingService {
async execute(req: Request): Promise<void> {
const booking = await prisma.booking.findUnique({
where: { id: req.body.bookingId },
});
if (!booking) {
throw new Error("Booking not found");
}
if (booking.status !== "pending") {
throw new Error("Booking cannot be confirmed");
}
await prisma.booking.update({
where: { id: booking.id },
data: { status: "confirmed" },
});
await resend.emails.send({
to: booking.email,
subject: "Booking confirmed",
html: "<p>Your booking is confirmed.</p>",
});
}
}
Ce code mélange tout :
- transport HTTP
- accès base
- logique métier
- service d’email externe
Le résultat :
- difficile à tester
- difficile à faire évoluer
- dépendant de détails techniques partout
Bonne direction de dépendance
Ports
export interface ConfirmBookingUseCase {
execute(command: ConfirmBookingCommand): Promise<void>;
}
export type ConfirmBookingCommand = {
bookingId: string;
};
export interface BookingRepository {
findById(id: string): Promise<Booking | null>;
save(booking: Booking): Promise<void>;
}
export interface NotificationGateway {
sendBookingConfirmed(booking: Booking): Promise<void>;
}
Use case
export class ConfirmBookingService implements ConfirmBookingUseCase {
constructor(
private readonly bookingRepository: BookingRepository,
private readonly notificationGateway: NotificationGateway
) {}
async execute(command: ConfirmBookingCommand): Promise<void> {
const booking = await this.bookingRepository.findById(command.bookingId);
if (!booking) {
throw new Error("Booking not found");
}
booking.confirm();
await this.bookingRepository.save(booking);
await this.notificationGateway.sendBookingConfirmed(booking);
}
}
Adapter HTTP
import type { Request, Response } from "express";
export class ConfirmBookingController {
constructor(
private readonly confirmBookingUseCase: ConfirmBookingUseCase
) {}
async handle(req: Request, res: Response): Promise<void> {
await this.confirmBookingUseCase.execute({
bookingId: req.body.bookingId,
});
res.status(204).send();
}
}
Adapter PostgreSQL
export class PgBookingRepository implements BookingRepository {
async findById(id: string): Promise<Booking | null> {
// SQL ou ORM + mapping vers Booking
return null;
}
async save(booking: Booking): Promise<void> {
// Persistance
}
}
Adapter email
export class ResendNotificationGateway implements NotificationGateway {
async sendBookingConfirmed(booking: Booking): Promise<void> {
// Appel provider email
}
}
Ici, la direction est correcte :
- le controller dépend du use case
- les adapters techniques implémentent les ports
- le cœur ne connaît pas Express, Prisma ni Resend
Comment vérifier la direction des dépendances
En pratique, trois questions suffisent souvent.
Cette classe connaît-elle un détail technique inutile ?
Exemples de signaux faibles :
Request,Response- entités ORM
- clients SDK
- modèles SQL
- payloads bruts d’un broker
Si oui, demande-toi si cette connaissance doit vraiment exister à cet endroit.
Cette dépendance existe-t-elle parce que le métier en a besoin ou parce qu’une technologie est là ?
Exemple :
- “le métier a besoin d’envoyer une confirmation” → bon signal
- “on a mis le SDK SendGrid ici parce qu’il fallait bien l’appeler quelque part” → mauvais signal
Le cœur exprime un besoin. Le bord choisit la technologie.
Est-ce que je peux tester cette logique sans framework ni I/O réelle ?
Si la réponse est non, il y a souvent un problème de direction de dépendance.
Exemple de structure saine
src/
booking/
domain/
booking.ts
application/
confirm-booking/
confirm-booking.use-case.ts
confirm-booking.command.ts
booking-repository.port.ts
notification-gateway.port.ts
confirm-booking.service.ts
infrastructure/
http/
confirm-booking.controller.ts
persistence/
pg-booking-repository.ts
notification/
resend-notification-gateway.ts
Ce rangement n’est pas magique à lui seul. Mais il devient cohérent si les imports suivent réellement cette direction :
infrastructure -> applicationapplication -> domain
et jamais l’inverse.
Points importants
- Le sujet n’est pas seulement le découpage en couches.
- Le sujet est la direction réelle des imports et des dépendances.
- Le domaine ne doit pas dépendre de détails volatils.
- Les ports servent à faire remonter les besoins du cœur sans faire remonter la technique.
- Une architecture visuellement propre peut être fausse si les dépendances vont dans le mauvais sens.
- La testabilité est souvent une conséquence directe d’une bonne direction de dépendance.
Erreurs fréquentes
Croire qu’un dossier domain/ suffit
Mettre du code dans un dossier domain n’isole rien si ce code importe toujours :
- des entités ORM
- des types HTTP
- des SDK externes
Le nom du dossier ne protège pas l’architecture.
Confondre injection de dépendances et bonne direction
Injecter une classe concrète ne règle pas le problème si cette classe tire déjà le cœur vers l’infrastructure.
L’injection aide. Elle ne remplace pas le bon placement des contrats.
Laisser les types techniques traverser les couches
Une requête HTTP transformée trop tard, ou un DTO Stripe remonté jusqu’au domaine, sont des fuites de dépendances.
La traduction doit se faire au bord.
Créer des ports trop techniques
Un port comme PrismaBookingRepository ou StripeApiClient révèle déjà un problème.
Un port doit exprimer un besoin du cœur, pas le nom d’une technologie.
Mettre la logique métier dans les implémentations concrètes
Quand le repository concret ou le client externe contient des règles métier, le cœur perd la main.
Les implémentations concrètes servent à brancher la technique. La décision métier doit rester ailleurs.
Conclusion
La direction des dépendances est la règle qui tient toute l’architecture hexagonale.
Quand les dépendances pointent vers le cœur :
- le métier reste lisible
- les détails techniques restent remplaçables
- les tests deviennent plus simples
- le système vieillit mieux
Le réflexe le plus utile est simple :
les détails dépendent du cœur, jamais l’inverse.