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 :
| Où |
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 :
- 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.
- Ligne dans
CLAUDE.md › Conventions — le réflexe court, à côté de « Pas de as » :
« Échecs attendus aux frontières domain/application → Result (shared/result). Ces deux
couches ne jettent jamais ; exceptions réservées à infrastructure/apps. » + lien vers la rule.
.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.
- Garde-fou lint —
no-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
Découpage en tâches (TDD)
- Écrire l'ADR
0010 (décision A + pourquoi).
shared/result : ajouter les combinateurs minimaux si la migration les réclame (flatMap/mapErr),
test d'abord — ne pas gonfler au-delà du besoin.
- Migrer les value objects
domain en fabriques Result (specs rouges d'abord).
- Migrer
ShelfScannerPort + les trois adapters, puis ScanShelfUseCase.
- Adapter
ScanController : switch sur E → codes HTTP (contrat client identique).
- Poser le garde-fou
no-restricted-syntax, vérifier le rouge/vert (throw ajouté → yarn lint échoue).
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).
De quoi il s'agit
Le repo porte deux idiomes d'erreur, et un seul est utilisé.
libs/shared/resultest complet ettesté (
Result,ok/err,isOk/isErr,unwrap,map, README, entréetsconfig) — mais iln'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.
État actuel (sur
main)Toute la chaîne
recognitionsignale l'échec parthrow:ShelfPhoto.of,Confidence.of(+Author/BookTitle/DetectedBook)throw Error/TypeErrorà la constructionShelfScannerPort.scan(port, dansdomain)throw ShelfScanFailedsi source indisponible ou hors-contratScanShelfUseCase.execute(application)ScanController(apps/api)try/catch+instanceof:ShelfScanFailed → 502, le reste→ 400Le
instanceofdu controller est le symptôme : il reconstruit à la main le type d'erreur quethrowavait effacé de la signature.Décision actée (à formaliser dans l'ADR) — option A
Resultaux frontièresdomain/applicationpour les échecs attendus ;domainetapplicationne jettent JAMAIS. Les exceptions n'existent plus que dansinfrastructureetapps(adapters, framework), où elles sont soit rattrapées et traduites enErrau passage d'unport, soit laissées à un filtre HTTP global (→ #26).
Concrètement :
partie du vocabulaire du domaine → il est une valeur
Err, inscrite dans le type ;throwdans cesdeux couches sous l'option A : il devient lui aussi un
Err, ou il vit eninfrastructure/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 constructeurqui jette), les use cases rendent
Promise<Result<…, E>>, le portShelfScannerPort.scanrend
Promise<Result<DetectedBook[], ShelfScanFailed>>, et la traduction en HTTP (leswitchsur
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 :
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éduredocs/adr/README.md.CLAUDE.md› Conventions — le réflexe court, à côté de « Pas deas» :« Échecs attendus aux frontières
domain/application→Result(shared/result). Ces deuxcouches ne jettent jamais ; exceptions réservées à
infrastructure/apps. » + lien vers la rule..claude/rules/error-policy.md— le mode d'emploi appliqué (où passe la frontière, fabriquesde VO, contrat de port, mapping HTTP, exemples du repo), sur le modèle de
always-work-in-a-worktree.md.no-restricted-syntax(règle core ESLint, pas custom) bannissantThrowStatement, scopélibs/*/domain/**+libs/*/application/**, danseslint.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 ; eteslint-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
recognitiondans la même PR.shared/result(aujourd'hui mort) y gagneses premiers consommateurs — sa survie était l'autre moitié de la décision.
Surface touchée : les value objects (
domain),ScanShelfUseCase(application), le contratShelfScannerPortet ses trois adapters (Gemini, Qwen, stub, eninfrastructure), leScanController(leinstanceofdisparaît au profit d'unswitchsurE), et les specsconcernées (
.rejects.toThrow(/empty/u)→ assertionsisErr).Critères d'acceptation
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.mdcréé.eslint.config.mjs:no-restricted-syntax/ThrowStatementscopé aux deux couches ; unthrowajouté dans
libs/recognition/domainfait échoueryarn lint(garde-fou démontré opérant,dans l'esprit des tests de
CLAUDE.md).domainetapplicationne contiennent plus aucunthrow; la chaînerecognitionrend desResult.shared/resulta au moins un consommateur réel (il n'est plus mort)./scaninchangé côté client (toujours 400 / 502) — la migration est interne.yarn checkvert (lint + typecheck + test + build).Découpage en tâches (TDD)
0010(décision A + pourquoi).shared/result: ajouter les combinateurs minimaux si la migration les réclame (flatMap/mapErr),test d'abord — ne pas gonfler au-delà du besoin.
domainen fabriquesResult(specs rouges d'abord).ShelfScannerPort+ les trois adapters, puisScanShelfUseCase.ScanController:switchsurE→ codes HTTP (contrat client identique).no-restricted-syntax, vérifier le rouge/vert (throw ajouté →yarn lintéchoue).CLAUDE.md+.claude/rules/error-policy.md.Points d'attention
unwrapreste une trappe.shared/result.unwrapjette « at boundaries only » ; le garde-foubannit la syntaxe
throw, pas un appel àunwrap. L'ADR/rule doit interdireunwrapdansdomain/application(réservé àapps/infra) — sinon on rouvre la porte de côté.forme (
Resulttypé) ; feat(api): durcir la frontière HTTP — CORS et filtre d'exception global #26 décide où leswitchatterrit (controller vs filtre).infrastructurea le droit de jeter en interne, mais rend unResultau passage du port.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(leinstanceofà 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).