Aller au contenu

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.

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.

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

Les flèches de dépendance pointent toujours vers le cœur :

domain ← application ← infrastructure
← http
← workers

Interdictions absolues (vérifiées en revue) :

  • domain/ n’importe jamais fastify, 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.

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.

Les ports ne sont pas préfixés I : on écrit SessionRepository, pas ISessionRepository. Les use cases portent un verbe : CreateSession, SubmitSession. (Voir Conventions.)

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).

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).