Skip to content

docs(adr): trancher la politique d'erreur — Result aux frontières ou exceptions #24

Description

@arenier

De quoi il s'agit

Le repo porte deux idiomes d'erreur, et un seul est utilisé. libs/shared/result est complet et
testé (Result, ok/err, isOk/isErr, unwrap, map, README, entrée tsconfig) — mais il
n'a aucun consommateur : hors de la lib, la seule occurrence dans le repo est sa référence de
projet. Le code en place fait l'inverse : tout jette.

Cette issue tranche la grammaire d'erreur du projet dans un ADR, puis l'applique.

Recadrage (l'idiome exception est déjà en place). À la rédaction initiale de ce draft, #10
n'existait pas et il fallait « trancher avant #10 ». C'est fait : #10 est sur main (PR #39,
endpoint POST /scan), et #43 en rajoute. #24 n'est donc plus un choix sur page blanche mais
entériner et infléchir l'idiome exception déjà installé — avec un coût de migration assumé.

État actuel (sur main)

Toute la chaîne recognition signale l'échec par throw :

Aujourd'hui
ShelfPhoto.of, Confidence.of (+ Author/BookTitle/DetectedBook) throw Error/TypeError à la construction
ShelfScannerPort.scan (port, dans domain) contrat : throw ShelfScanFailed si source indisponible ou hors-contrat
ScanShelfUseCase.execute (application) laisse tout remonter
ScanController (apps/api) try/catch + instanceof : ShelfScanFailed → 502, le reste → 400

Le instanceof du controller est le symptôme : il reconstruit à la main le type d'erreur que
throw avait effacé de la signature.

Décision actée (à formaliser dans l'ADR) — option A

Result aux frontières domain/application pour les échecs attendus ; domain et
application ne jettent JAMAIS.
Les exceptions n'existent plus que dans infrastructure et
apps (adapters, framework), où elles sont soit rattrapées et traduites en Err au passage d'un
port, soit laissées à un filtre HTTP global (→ #26).

Concrètement :

  • un échec attendu (image vide/trop grosse/type non supporté, fournisseur hors-contrat) fait
    partie du vocabulaire du domaine → il est une valeur Err, inscrite dans le type ;
  • l'exceptionnel (bug, invariant réellement impossible) n'a pas de canal throw dans ces
    deux couches sous l'option A : il devient lui aussi un Err, ou il vit en infrastructure/apps.
    C'est la version stricte, retenue parce que le garde-fou lint devient total (voir plus bas) et
    colle à l'ADN « garde-fou exécutable » du repo.

La frontière exacte : les fabriques de value objects rendent un Result (plus de constructeur
qui jette), les use cases rendent Promise<Result<…, E>>, le port ShelfScannerPort.scan
rend Promise<Result<DetectedBook[], ShelfScanFailed>>, et la traduction en HTTP (le switch
sur E) vit côté apps/filtre — à articuler avec #26.

Livrables (le « où c'est écrit »)

Cette issue ne produit pas « un ADR » seul, mais quatre artefacts cohérents :

  1. ADR docs/adr/0010-… (prochain numéro libre) — la décision, les options A/B, le pourquoi de A,
    les conséquences. Gabarit 0000-template.md, procédure docs/adr/README.md.
  2. Ligne dans CLAUDE.md › Conventions — le réflexe court, à côté de « Pas de as » :
    « Échecs attendus aux frontières domain/applicationResult (shared/result). Ces deux
    couches ne jettent jamais ; exceptions réservées à infrastructure/apps. »
    + lien vers la rule.
  3. .claude/rules/error-policy.md — le mode d'emploi appliqué (où passe la frontière, fabriques
    de VO, contrat de port, mapping HTTP, exemples du repo), sur le modèle de
    always-work-in-a-worktree.md.
  4. Garde-fou lintno-restricted-syntax (règle core ESLint, pas custom) bannissant
    ThrowStatement, scopé libs/*/domain/** + libs/*/application/**, dans eslint.config.mjs
    (couche ESLint = « ce qu'oxlint ne sait pas exprimer », ADR 0008). Vérifié : oxlint 1.78 ne
    connaît pas no-restricted-syntax
    (Rule 'no-restricted-syntax' not found), d'où ESLint ; et
    eslint-plugin-oxlint (dernier) ne l'éteindra pas puisque oxlint ne l'a pas.

Migration (indissociable du garde-fou)

Le garde-fou passe la CI au rouge immédiatement sur les VO qui jettent : on ne peut pas le
poser sans migrer la chaîne recognition dans la même PR. shared/result (aujourd'hui mort) y gagne
ses premiers consommateurs — sa survie était l'autre moitié de la décision.

Surface touchée : les value objects (domain), ScanShelfUseCase (application), le contrat
ShelfScannerPort et ses trois adapters (Gemini, Qwen, stub, en infrastructure), le
ScanController (le instanceof disparaît au profit d'un switch sur E), et les specs
concernées (.rejects.toThrow(/empty/u) → assertions isErr).

Critères d'acceptation

  • ADR 0010 écrit (décision A, options, conséquences), conforme à docs/adr/README.md.
  • CLAUDE.md (Conventions) porte la ligne error-policy + lien rule ; .claude/rules/error-policy.md créé.
  • eslint.config.mjs : no-restricted-syntax/ThrowStatement scopé aux deux couches ; un throw
    ajouté dans libs/recognition/domain fait échouer yarn lint
    (garde-fou démontré opérant,
    dans l'esprit des tests de CLAUDE.md).
  • domain et application ne contiennent plus aucun throw ; la chaîne recognition rend des Result.
  • shared/result a au moins un consommateur réel (il n'est plus mort).
  • Contrat HTTP de /scan inchangé côté client (toujours 400 / 502) — la migration est interne.
  • yarn check vert (lint + typecheck + test + build).

Découpage en tâches (TDD)

  1. Écrire l'ADR 0010 (décision A + pourquoi).
  2. shared/result : ajouter les combinateurs minimaux si la migration les réclame (flatMap/mapErr),
    test d'abord — ne pas gonfler au-delà du besoin.
  3. Migrer les value objects domain en fabriques Result (specs rouges d'abord).
  4. Migrer ShelfScannerPort + les trois adapters, puis ScanShelfUseCase.
  5. Adapter ScanController : switch sur E → codes HTTP (contrat client identique).
  6. Poser le garde-fou no-restricted-syntax, vérifier le rouge/vert (throw ajouté → yarn lint échoue).
  7. CLAUDE.md + .claude/rules/error-policy.md.

Points d'attention

Hors périmètre

Dépendances

Aucune bloquante. Articulée avec #26 (mapping HTTP). Le gros du refactor touche du code déjà
mergé (#39/#43) — d'où une PR non triviale malgré l'étiquette « ADR ».

Références

libs/shared/result/src/lib/result.ts (lib sans consommateur), libs/recognition/domain/src/lib/
(value objects + shelf-scanner.port.ts), libs/recognition/application/src/lib/scan-shelf.use-case.ts,
apps/api/src/recognition/scan.controller.ts (le instanceof à retirer), eslint.config.mjs
(couche ESLint frontières + ...oxlint.configs['flat/all'] en dernier), docs/adr/README.md +
0000-template.md, ADR 0002 (frontières, couches pures), ADR 0008 (oxlint/oxfmt, ESLint réduit),
CLAUDE.md, #10/#39 (idiome exception en place), #26 (frontière HTTP).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions