Architecture hexagonale
Le backend api/ suit une architecture hexagonale (aussi appelée ports & adapters). C’est la
décision structurante du projet : tout le reste en découle.
L’idée en une image
Section intitulée « L’idée en une image »flowchart LR
subgraph ext_in["Entrées"]
HTTP["http/ (Fastify)"]
WK["workers/"]
end
subgraph core["Cœur (indépendant du framework)"]
APP["application/<br/>use cases + ports"]
DOM["domain/<br/>entités + règles"]
APP --> DOM
end
subgraph ext_out["Sorties"]
INFRA["infrastructure/<br/>Firebird, Stripe, crypto…"]
end
HTTP --> APP
WK --> APP
INFRA -.implémente les ports.-> APP
Le cœur (domain/ + application/) ne connaît rien du monde extérieur. Il déclare ses besoins
sous forme de ports (interfaces), que l’infrastructure vient remplir.
Les cinq couches
Section intitulée « Les cinq couches »| Couche | Dossier | Rôle | Dépend de |
|---|---|---|---|
| Domain | api/src/domain/ |
Entités, value objects, machines à états, règles métier pures | rien (sauf lib std + Zod) |
| Application | api/src/application/ |
Use cases, orchestration, ports | domain |
| Infrastructure | api/src/infrastructure/ |
Implémentations concrètes des ports (DB, PSP, crypto, workers) | domain + ports |
| HTTP | api/src/http/ |
Couche Fastify : routes, plugins, schémas — jetable | application |
| Workers | api/src/workers/ |
Points d’entrée des process asynchrones | application + infrastructure |
La règle de dépendance (stricte)
Section intitulée « La règle de dépendance (stricte) »Les flèches de dépendance pointent toujours vers le cœur :
domain ← application ← infrastructure ← http ← workersInterdictions absolues (vérifiées en revue) :
domain/n’importe jamaisfastify,firebird,redis,stripe, etc.application/ne dépend jamais d’une implémentation concrète — uniquement de ses ports.http/ne contient aucune logique métier : un handler = parse + appel use case + sérialise.
Ports & adapters, concrètement
Section intitulée « Ports & adapters, concrètement »Un port est une interface dans application/ports/. Exemple, le dépôt de sessions :
// api/src/application/ports/session-repository.ts (forme)export interface SessionRepository { findById(id: string): Promise<PaymentSession | null> findByReference(merchantId: string, reference: string): Promise<PaymentSession | null> save(session: PaymentSession): Promise<void> // …}Son adapter concret vit dans l’infrastructure : FirebirdSessionRepo
(api/src/infrastructure/db/firebird-session-repo.ts). En test, on injecte une implémentation mémoire.
Le composition root — l’unique endroit qui instancie les adapters concrets et les injecte dans les
use cases — est api/src/container.ts. L’injection se fait par constructeur, sans singleton global.
Convention « port nommé par son rôle »
Section intitulée « Convention « port nommé par son rôle » »Les ports ne sont pas préfixés I : on écrit SessionRepository, pas ISessionRepository. Les
use cases portent un verbe : CreateSession, SubmitSession. (Voir Conventions.)
Les transactions ne sont pas dans le domaine
Section intitulée « Les transactions ne sont pas dans le domaine »Le domaine ignore tout des transactions. Les use cases qui en ont besoin reçoivent un Unit of Work
(port unit-of-work.ts) : unitOfWork.run(work) ouvre une transaction Firebird, fournit des repos
scopés, commit en cas de succès, rollback sinon. Implémentation : FirebirdUnitOfWork
(api/src/infrastructure/db/firebird-uow.ts).
Pour aller plus loin
Section intitulée « Pour aller plus loin »- Le détail couche par couche → Backend : vue d’ensemble
- Les interfaces partagées entre briques (pas seulement internes) → Contrats inter-briques
Source de vérité dans le dépôt :
ARCHITECTURE.md(règles de dépendance, composition root, gestion des transactions, hiérarchie d’erreurs).