Couche Application
À quoi sert cette couche, et pourquoi
Section intitulée « À quoi sert cette couche, et pourquoi »La couche Application (api/src/application/) est le chef d’orchestre du backend. Elle ne contient ni règle métier pure (ça, c’est la Couche Domain), ni détail technique comme SQL ou HTTP (ça, c’est la Couche Infrastructure et la Couche HTTP).
Son rôle tient en trois verbes :
- Coordonner — un use case charge une entité du domaine, lui demande de changer d’état, puis persiste. Exemple : « ajouter une carte cadeau à une session », « rembourser une session ».
- Décrire ses besoins — elle déclare des ports (interfaces TypeScript) pour tout ce qu’elle ne sait pas faire elle-même : lire en base, appeler un PSP, signer un manifest, envoyer un email. Elle ne sait pas comment c’est fait, seulement ce qu’elle attend.
- Rester pure — aucun
importde Fastify, d’un driver SQL ou denode:fetchici. Tout passe par injection de dépendances dans le constructeur. C’est ce qui rend les use cases testables sans base ni réseau (voir Architecture hexagonale).
flowchart LR HTTP["HTTP (handlers)"] --> UC["Use case (application)"] UC --> DOM["Domain (entités pures)"] UC --> PORTS["Ports (interfaces)"] PORTS -.implémentés par.-> INFRA["Infrastructure (adapters)"]
Anatomie d’un use case
Section intitulée « Anatomie d’un use case »Tous les use cases suivent le même squelette : une classe avec un constructeur qui reçoit ses dépendances (les ports), et une unique méthode execute(input). Pas d’état partagé, pas de méthode statique magique.
Voici api/src/application/sessions/create-session.ts, dépouillé à l’essentiel :
export class CreateSession { constructor( private readonly uow: UnitOfWork, private readonly merchants: MerchantRepository, private readonly clock: Clock, private readonly ids: IdGenerator, ) {}
async execute(input: CreateSessionInput): Promise<PaymentSession> { const merchantId = id<'merchant'>('merchant', input.merchantId) as MerchantId const merchant = await this.merchants.findById(merchantId) if (!merchant || !merchant.active) { throw new ValidationError('Merchant not found or inactive', { merchantId: input.merchantId }) }
return this.uow.run(async ({ sessions }) => { const existing = await sessions.findByReference(merchantId, input.reference) if (existing) return existing // idempotence métier sur (merchant, reference)
const now = this.clock.now() const session = PaymentSession.create({ /* … */ }) // le DOMAINE crée l'entité await sessions.save(session) return session }) }}Les invariants à retenir :
- Injection par constructeur :
uow,merchants,clock,idssont des ports. Aucunnew FirebirdRepository()dans le use case. clocketidssont des ports : on n’appelle jamaisnew Date()nicrypto.randomUUID()directement → le temps et les identifiants sont déterministes en test.- La logique métier reste dans le domaine :
PaymentSession.create(...),session.addGiftLeg(...),session.submit(...). Le use case séquence, l’entité décide et valide. - Les erreurs sont des erreurs domaine :
ValidationError,InvalidStateTransition(depuisdomain/shared/errors.js), traduites en HTTP par la couche au-dessus. - L’
inputest un type plat (souvent desstring/numberbruts) ; le use case le « brande » en identifiants typés (MerchantId,SessionId) viaid<'…'>(…).
Use cases « session » (le cœur de l’orchestration)
Section intitulée « Use cases « session » (le cœur de l’orchestration) »Ces use cases pilotent le cycle de vie d’une PaymentSession et de ses PaymentLeg. La plupart écrivent dans la base et déposent une intention dans l’outbox (voir plus bas). Détail des transitions d’état dans Machines à états.
Fichier (api/src/application/sessions/) |
Classe | Ce qu’il orchestre |
|---|---|---|
create-session.ts |
CreateSession |
Vérifie le marchand actif, crée la session (TTL = sessionTtlMinutes du marchand). Idempotent sur (merchantId, reference) : un appel répété renvoie la session existante. |
get-session.ts |
GetSession |
Lecture seule d’une session par id (renvoie null si absente). |
list-sessions.ts |
ListSessions |
Liste paginée par marchand (filtres statut/référence/dates), pagination cursor base64url encodée/décodée ici (encodeCursor/decodeCursor). |
add-gift-leg.ts |
AddGiftLeg |
Crée un PaymentLeg carte cadeau (montant plafonné au min(solde, reste à couvrir)), l’attache à la session, enqueue DEBIT_GIFT dans l’outbox (idempotency debit:<legId>). |
set-psp-leg.ts |
SetPspLeg |
Pose le leg PSP couvrant le reste à payer. Vérifie qu’une config PSP active existe pour le marchand (MERCHANT_PSP_NOT_CONFIGURED sinon). N’enqueue rien : l’autorisation part au submit. |
remove-leg.ts |
RemoveLeg |
Retire un leg. Si c’est un leg cadeau déjà CAPTURED, ne le supprime pas en base mais enqueue CANCEL_GIFT (compensation) ; sinon retrait direct. |
submit-session.ts |
SubmitSession |
Valide la session (session.submit). S’il y a un leg PSP → enqueue AUTHORIZE_PSP et passe PROCESSING. Sinon, si tous les legs sont CAPTURED → COMPLETED. |
confirm-psp-action.ts |
ConfirmPspAction |
Après une action 3-D Secure côté client (leg PSP HELD), efface l’exigence d’action et enqueue CAPTURE_PSP (idempotency capture:<legId>). Renvoie ALREADY_CAPTURED/ENQUEUED/NO_OP. |
check-gift-balance.ts |
CheckGiftBalance |
Interroge le solde d’une carte dans le contexte d’une session via le GiftCardRegistry, avec les accès du marchand de la session. Valide/normalise/masque le numéro. Renvoie un token + 4 derniers chiffres réutilisables par add-gift-leg. Écrit gift.balance.checked ou gift.balance.check_failed au journal métier. |
refund-session.ts |
RefundSession |
Rembourse les legs désignés par legIds, chacun en entier ; sans legIds, tous les legs CAPTURED. La sélection est validée en bloc : un identifiant étranger à la session ou un leg non CAPTURED annule toute la demande. Enqueue un REFUND par leg (idempotency refund:<sessionId>:<legId>), recalcule le statut, écrit session.refund_requested. Renvoie refundedLegIds, refundedAmount, currency, outboxIds. |
L’orchestrateur asynchrone qui consomme les entrées d’outbox produites ci-dessus, api/src/application/workers/process-outbox-entry.ts, est documenté à part : voir L’orchestrateur. Le worker cleanup-expired-sessions.ts est couvert dans Workers.
Use cases « gift » (cartes cadeaux sans session)
Section intitulée « Use cases « gift » (cartes cadeaux sans session) »API directe sur les cartes cadeaux externes (API Titréo REST), découplée de toute session. Tous passent par GiftCardRegistry.get(type, merchantId) → le GiftCardProvider du marchand authentifié, construit avec ses accès ; sans accès enregistrés, GIFT_PROVIDER_NOT_CONFIGURED (409). L’idempotencyKey reçu (en-tête Idempotency-Key) sert aussi de ticket provider : double protection contre le re-débit.
Ces opérations ont lieu hors transaction métier : leur trace au journal passe par BestEffortJournal (gift.balance.checked, gift.direct.debited, gift.direct.refunded et leurs échecs). Voir Traçabilité.
Fichier (api/src/application/gift/) |
Classe / export | Ce qu’il orchestre |
|---|---|---|
lookup-gift-balance.ts |
LookupGiftBalance |
Lit le solde d’une carte (/v1/gift-cards/check-balance), sans session. |
debit-gift-card.ts |
DebitGiftCard |
Débit immédiat (/v1/gift-cards/debit). TITREO_HTTP/TITREO_UNKNOWN = transport transitoire (5xx retentable) ; autres FAILED = refus métier déterministe (200). |
refund-gift-card.ts |
RefundGiftCard |
Recrédit / reversal (/v1/gift-cards/refund) — couvre release (annulation) et refund (post-paiement). |
gift-card-shared.ts |
normalizeCardNumber, isValidCardNumber, normalizeCvv, isValidCvv, maskCardNumber |
Helpers de validation/masquage partagés (pas un use case). |
Use cases « webhooks »
Section intitulée « Use cases « webhooks » »Fichier (api/src/application/webhooks/) |
Classe / export | Ce qu’il orchestre |
|---|---|---|
handle-psp-webhook.ts |
HandlePspWebhook |
Reçoit un webhook PSP : vérifie la signature (provider.verifyWebhook), déduplique via webhookEvents.markReceived, retrouve la session par authId, applique l’événement au leg PSP (succès/échec/capture/refund/dispute), déclenche la compensation des legs cadeaux (CANCEL_GIFT) si rollback, puis enqueue un webhook marchand si statut terminal. |
merchant-webhook.ts |
buildMerchantPayload, signMerchantPayload, enqueueMerchantWebhook, eventForStatus, TERMINAL_EVENTS |
Helpers (pas une classe use case) : construisent et signent (HMAC-SHA256) le payload sortant vers le marchand, et l’enqueuent en outbox MERCHANT_WEBHOOK (idempotency webhook:<sessionId>:<event>, suivie de :<legId> quand un leg est en cause : deux remboursements successifs restent deux notifications). |
Use cases « psp-configs » (configuration PSP par marchand)
Section intitulée « Use cases « psp-configs » (configuration PSP par marchand) »Fichier (api/src/application/psp-configs/) |
Classe | Ce qu’il orchestre |
|---|---|---|
upsert-psp-config.ts |
UpsertPspConfig |
Crée/met à jour la config PSP d’un marchand (clé secrète, secret webhook, clé publique…). Reçoit un PspConfigValidatorFn injecté ; calcule les …Last4 pour affichage. |
get-psp-config.ts |
GetPspConfig |
Lecture d’une config par (merchantId, providerType). |
list-psp-configs.ts |
ListPspConfigs |
Liste les configs d’un marchand. |
delete-psp-config.ts |
DeletePspConfig |
Supprime la config d’un provider pour un marchand. |
Use cases « gift-configs » (accès cartes cadeaux par marchand)
Section intitulée « Use cases « gift-configs » (accès cartes cadeaux par marchand) »Fichier (api/src/application/gift-configs/) |
Classe | Ce qu’il orchestre |
|---|---|---|
upsert-gift-config.ts |
UpsertGiftConfig |
Vérifie les accès auprès du fournisseur (GiftCredentialVerifier) puis les enregistre, avec verifiedAt. Accès refusés → GiftCredentialsRejected (422) ; fournisseur injoignable → GiftProviderUnreachable (503). Dans les deux cas rien n’est écrit. |
get-gift-config.ts |
GetGiftConfig |
Lecture par (merchantId, providerType), clé masquée. |
list-gift-configs.ts |
ListGiftConfigs |
Liste les accès d’un marchand, clés masquées. |
delete-gift-config.ts |
DeleteGiftConfig |
Supprime les accès d’un fournisseur pour un marchand. |
Use cases « logs » (logs techniques, console d’administration)
Section intitulée « Use cases « logs » (logs techniques, console d’administration) »Fichier (api/src/application/logs/) |
Classe / export | Ce qu’il orchestre |
|---|---|---|
search-logs.ts |
SearchLogs, GetLogHistogram, ListLogServices |
Recherche paginée par curseur à la nanoseconde, histogramme, services connus sur 24 h. |
get-trace.ts |
GetTrace |
Trace d’une requête (corrélation) ou d’un paiement (session) : le journal métier donne les corrélations et la fenêtre, le LogStore les lignes. |
trace-builder.ts |
buildTrace |
Pose les lignes du journal d’appels sur des couloirs par acteur ; le début d’un segment se déduit de sa durée. |
log-range.ts |
resolveRange, stepFor, MAX_RANGE_MS |
Plage bornée à 7 jours, pas de l’histogramme. |
Détail sur Logs techniques.
Use cases « merchants »
Section intitulée « Use cases « merchants » »Fichier (api/src/application/merchants/) |
Classe | Ce qu’il orchestre |
|---|---|---|
create-merchant.ts |
CreateMerchant |
Crée un marchand (TTL session 1..1440 min, branding vide par défaut). Refuse un id déjà existant. |
get-merchant.ts |
GetMerchant |
Lecture d’un marchand par id. |
list-merchants.ts |
ListMerchants |
Liste paginée (cursor) des marchands, filtre active. |
update-merchant.ts |
UpdateMerchant |
Mise à jour côté marchand : valide nom, URL webhook (http/https), secret webhook (≥ 16 car.), TTL. |
admin-update-merchant.ts |
AdminUpdateMerchant |
Variante admin (vérifie l’existence préalable du marchand). |
get-onboarding-state.ts |
GetMerchantOnboardingState |
Calcule l’avancement d’onboarding en croisant 4 repositories (marchand, configs PSP, clés API, accès cartes cadeaux). Les étapes dépendent de la plateforme (STEPS_BY_PLATFORM) ; giftConfigured — des accès actifs et vérifiés — est requise sur toutes. brandingSet est facultative. |
Use cases « api-keys »
Section intitulée « Use cases « api-keys » »Fichier (api/src/application/api-keys/) |
Classe | Ce qu’il orchestre |
|---|---|---|
create-api-key.ts |
CreateApiKey |
Génère une clé tep_live_/tep_test_ (via un ApiKeyHasher injecté), vérifie que l’acteur détient déjà les scopes accordés (hasScope), n’expose le token en clair qu’une fois. |
authenticate-api-key.ts |
AuthenticateApiKey |
Authentifie une requête : hash du token → findByHash, rejette révoquée/expirée, vérifie le marchand actif, met à jour lastUsed en fire-and-forget. |
list-api-keys.ts |
ListApiKeys |
Liste les clés d’un marchand (sans le secret). |
revoke-api-key.ts |
RevokeApiKey |
Révoque une clé (vérifie l’appartenance au marchand ; idempotent si déjà révoquée). |
Les scopes (Scope, hasScope) viennent du domaine domain/auth/scope.ts. Voir Sécurité.
Use cases « auth » (sessions utilisateur console/admin)
Section intitulée « Use cases « auth » (sessions utilisateur console/admin) »Authentification humaine (console marchand, dashboard admin) par session à cookie, distincte des clés API machine.
Fichier (api/src/application/auth/) |
Classe | Ce qu’il orchestre |
|---|---|---|
create-admin-user.ts |
CreateAdminUser |
Crée un utilisateur admin (mot de passe haché via PasswordHasher). |
login-admin.ts |
LoginAdmin |
Login admin : vérifie le mot de passe, ouvre une UserSession, émet un token via UserSessionTokenService. |
login-merchant.ts |
LoginMerchant |
Idem pour un utilisateur marchand. |
logout-user.ts |
LogoutUser |
Révoque la session utilisateur associée au token. |
resolve-user-session.ts |
ResolveUserSession |
Résout un token de cookie → utilisateur admin ou marchand (TTL glissant : 12 h admin, 24 h marchand). Pivot d’autorisation des routes console/admin. |
signup-merchant.ts |
SignupMerchant |
Auto-inscription marchand : crée marchand + 1er utilisateur, envoie l’email de vérification (EmailSender) vers consoleBaseUrl. |
verify-merchant-email.ts |
VerifyMerchantEmail |
Valide le token d’email reçu et marque l’utilisateur vérifié. |
Use cases « merchant-users » (équipe d’un marchand)
Section intitulée « Use cases « merchant-users » (équipe d’un marchand) »Fichier (api/src/application/merchant-users/) |
Classe | Ce qu’il orchestre |
|---|---|---|
invite-merchant-user.ts |
InviteMerchantUser |
Crée une invitation et envoie l’email d’invitation vers la console. |
accept-merchant-invite.ts |
AcceptMerchantInvite |
Transforme une invitation en utilisateur (création + mot de passe haché). |
revoke-merchant-invite.ts |
RevokeMerchantInvite |
Révoque une invitation en attente. |
list-merchant-users.ts |
ListMerchantUsers |
Liste les membres et les invitations en attente d’un marchand. |
update-merchant-user.ts |
UpdateMerchantUser |
Met à jour le rôle / l’état (désactivé) d’un membre. |
Use cases « audit » et « branding »
Section intitulée « Use cases « audit » et « branding » »| Fichier | Classe | Ce qu’il orchestre |
|---|---|---|
audit/write-audit-log.ts |
WriteAuditLog |
Écrit une entrée de journal d’audit (action, acteur, cible, IP, payload). |
audit/list-audit-logs.ts |
ListAuditLogs |
Liste paginée (cursor) des entrées d’audit, filtres marchand/action/dates. |
branding/get-branding.ts |
GetBranding |
Lit le branding (thème, logo) d’un marchand sous forme de BrandingDto. |
branding/update-branding.ts |
UpdateBranding |
Met à jour le branding d’un marchand. |
Use case « bootstrap »
Section intitulée « Use case « bootstrap » »Fichier (api/src/application/bootstrap/) |
Classe | Ce qu’il orchestre |
|---|---|---|
generate-manifest.ts |
GenerateBootstrapManifest |
Construit le manifest de bootstrap signé envoyé au SDK client : résout le SDK + l’adapter PSP (et gift) via ClientAdapterRegistry, injecte le branding du marchand et la clé publique PSP, canonicalise et signe le payload via ManifestSigner. Les cartes cadeaux ne sont proposées (gift_card_provider) que si le marchand a des accès actifs chez le fournisseur : le type déclaré sur le marchand ne suffit pas. Écrit session.manifest.served au journal métier. Ne pose aucun leg, ne touche pas l’outbox. |
Les ports : tout ce que la couche Application réclame au monde extérieur
Section intitulée « Les ports : tout ce que la couche Application réclame au monde extérieur »Un port est une interface (type/interface TypeScript) dans api/src/application/ports/. Le use case s’en sert ; un adapter d’infrastructure l’implémente. Aucune dépendance concrète n’apparaît jamais ici.
Ports « persistance » (repositories)
Section intitulée « Ports « persistance » (repositories) »Port (ports/) |
Type/interface clé | Rôle |
|---|---|---|
session-repository.ts |
SessionRepository |
CRUD/lecture des PaymentSession : findById, findByReference, findByPspAuthId, save, list (paginé), listExpiredActive. |
outbox-repository.ts |
OutboxRepository, OutboxAction, OutboxEntry |
File transactionnelle d’intentions : enqueue, claim (atomique PENDING→PROCESSING), markCompleted, markFailed, listReady. Cœur du pattern outbox. |
merchant-repository.ts |
MerchantRepository, Merchant, MerchantBranding |
Marchands : findById, create, update, list. Porte aussi emptyBranding(). |
merchant-psp-config-repository.ts |
MerchantPspConfigRepository, MerchantPspConfig |
Configs PSP par marchand : findActive, findByProvider, upsert, listForMerchant, delete. |
api-key-repository.ts |
ApiKeyRepository, ApiKey |
Clés API : findByHash, findById, insert, listForMerchant, markRevoked, touchLastUsed. |
audit-log-repository.ts |
AuditLogRepository, AuditLog |
Journal d’audit : insert, list (paginé). |
admin-user-repository.ts |
AdminUserRepository |
Utilisateurs admin : findById, findByEmail, insert, updateLastLogin. |
merchant-user-repository.ts |
MerchantUserRepository |
Utilisateurs marchands : recherche, vérif email, maj mot de passe / rôle / désactivation. |
merchant-user-invite-repository.ts |
MerchantUserInviteRepository |
Invitations d’équipe : findByTokenHash, insert, markAccepted, markRevoked, listPendingByMerchant. |
user-session-repository.ts |
UserSessionRepository |
Sessions humaines (cookie) : insert, findByTokenHash, updateLastSeen, revoke, deleteExpired. |
webhook-deliveries-repository.ts |
WebhookDeliveriesRepository |
Historique des livraisons de webhooks marchands (statut, retries), pour observabilité. |
webhook-events-repository.ts |
WebhookEventsRepository |
Déduplication des événements PSP entrants : markReceived (renvoie duplicate), markProcessed. |
Ports « transaction »
Section intitulée « Ports « transaction » »| Port | Type clé | Rôle |
|---|---|---|
unit-of-work.ts |
UnitOfWork, TransactionalRepos |
Exécute un bloc dans une seule transaction DB, en exposant { sessions, outbox, webhookEvents? }. C’est le mécanisme qui garantit l’atomicité « écrire la session et enqueue l’outbox » (voir ci-dessous). |
Ports « services externes / providers »
Section intitulée « Ports « services externes / providers » »| Port | Type clé | Rôle |
|---|---|---|
payment-provider.ts |
PaymentProvider, AuthorizeResult, WebhookEvent… |
Contrat d’un PSP : authorize, capture, void, refund, confirmAction, verifyWebhook, mapNativeError + capabilities. Voir Contrats. |
psp-registry.ts |
PspRegistry |
Résout un PaymentProvider pour (providerType, merchantId) : get, has. |
gift-card-provider.ts |
GiftCardProvider, BalanceResult, DebitResult… |
Contrat d’un émetteur de cartes cadeaux : checkBalance, debit, cancel, refund. |
gift-card-registry.ts |
GiftCardRegistry |
Résout le GiftCardProvider du marchand : get(type, merchantId) (asynchrone), has(type). Sans accès configurés : GiftProviderNotConfigured. |
gift-credential-verifier.ts |
GiftCredentialVerifier, GiftCredentialCheck |
Vérifie des accès auprès du fournisseur sans toucher à une carte : supports, verify → ok, REJECTED ou UNREACHABLE. |
merchant-gift-config-repository.ts |
MerchantGiftConfigRepository, MerchantGiftConfig |
Accès cartes cadeaux d’un marchand. findActive rend la clé déchiffrée ; les autres lectures la masquent. |
log-store.ts |
LogStore, LogCriteria, LogLine |
Lecture des logs techniques par critères. Le port ignore le langage de requête du collecteur. |
client-adapter-registry.ts |
ClientAdapterRegistry |
Résout les specs d’adapters côté client pour le manifest : resolveSdk, resolvePsp, resolveGiftCard. |
manifest-signer.ts |
ManifestSigner |
Signe le manifest de bootstrap : sign(message), keyId. |
webhook-sender.ts |
WebhookSender |
Émet un webhook HTTP sortant vers le marchand : send → WebhookSendResult. |
email-sender.ts |
EmailSender |
Envoie un email (send(message)) — vérif email, invitations. |
Ports « utilitaires déterministes »
Section intitulée « Ports « utilitaires déterministes » »| Port | Type clé | Rôle |
|---|---|---|
clock.ts |
Clock |
now(): Date. Temps injecté → tests déterministes, jamais new Date() dans un use case. |
id-generator.ts |
IdGenerator |
next(): string. Génération d’identifiants injectée. |
idempotency-store.ts |
IdempotencyStore, IdempotencyRecord |
Cache de réponses idempotentes HTTP : save, deleteExpired (+ lecture). Voir Idempotence. |
password-hasher.ts |
PasswordHasher |
hash / verify de mots de passe. |
user-session-token-service.ts |
UserSessionTokenService |
Génère/hash les tokens de session cookie : generate, hash. |
UnitOfWork + outbox : écrire l’état et l’intention dans la même transaction
Section intitulée « UnitOfWork + outbox : écrire l’état et l’intention dans la même transaction »C’est le pattern central de l’orchestration. Quand un use case modifie une session et déclenche une action externe (débiter une carte, autoriser un PSP, notifier le marchand), il ne fait pas l’appel réseau lui-même. Il fait deux écritures DB dans une seule transaction :
sessions.save(session)— le nouvel état métier.outbox.enqueue({...})— une intention d’action externe (« débite cette carte »).
Soit les deux sont commitées ensemble, soit aucune. Un worker séparé (L’orchestrateur) lira l’outbox plus tard et exécutera l’appel réseau réel. C’est ce qui rend le système résilient aux crashs : aucune action externe ne peut être « perdue » ou « jouée sans son état ».
sequenceDiagram participant UC as "Use case (ex: AddGiftLeg)" participant UoW as "UnitOfWork (1 transaction DB)" participant SR as "sessions (repo)" participant OB as "outbox (repo)" UC->>UoW: run(work) UoW->>SR: findById(sessionId) UC->>UC: session.addGiftLeg(leg, now) UC->>OB: enqueue(action DEBIT_GIFT, idempotencyKey debit:legId) UC->>SR: save(session) UoW-->>UC: COMMIT (état + intention, atomique)
Extrait réel de add-gift-leg.ts (chemin d’appel commenté) :
return this.uow.run(async ({ sessions, outbox }) => { const session = await sessions.findById(sessionId) // (1) charge l'agrégat // … validations de montant … session.addGiftLeg(leg, now) // (2) le DOMAINE applique la transition
const outboxId = this.ids.next() await outbox.enqueue({ // (3) intention d'action externe id: outboxId, sessionId, legId, action: 'DEBIT_GIFT', // => le worker débitera la carte payload: { token: input.cardToken, amount: debitAmount, emitter: input.emitter }, idempotencyKey: `debit:${legId}`, // clé d'idempotence stable (rejeu sûr) now, })
await sessions.save(session) // (4) état persisté DANS la même tx return { legId, amount: debitAmount, outboxId }}) // COMMIT atomique en sortie de run()Les use cases en lecture seule (GetSession, ListSessions, GetMerchant, ListApiKeys…) n’utilisent pas UnitOfWork : ils prennent directement le repository concerné et appellent une méthode de lecture. UnitOfWork n’est requis que lorsqu’il faut une atomicité écriture-état + écriture-outbox (ou plusieurs écritures cohérentes).
Où aller ensuite
Section intitulée « Où aller ensuite »- Couche Domain — les entités (
PaymentSession,PaymentLeg) et leurs invariants que les use cases pilotent. - L’orchestrateur — le worker qui consomme l’outbox et appelle réellement les providers.
- Couche Infrastructure — les adapters qui implémentent ces ports (Firebird, PSP, Titreo, email…).
- Couche HTTP — les handlers qui parsent la requête, appellent un use case et sérialisent.
- Parcours d’appel — un flow complet de bout en bout, HTTP → use case → outbox → worker.