refactor: move ErrorHandlerService/HttpError out of express-tools

ErrorHandlerService has zero dependency on Express — it's a plain
"map an error to {status, body}" service that works identically
behind any HTTP framework. It had no business living in a package
named express-tools.

Extracted HttpError, ErrorHandlerService, and ErrorHandlingResult
into a new packages/error-tools package (same tsc-build-to-dist
pattern as shared/express-tools). express-tools now only keeps the
actual Express-specific layer: ExpressServer, wrapAsyncHandler, and
createErrorMiddleware (which adapts ErrorHandlerService, imported
from error-tools, onto Express).

- packages/error-tools: new package, depends on shared + zod
- packages/express-tools: drops zod dependency, adds error-tools
  dependency for error-middleware.ts's type import
- apps/api: adds error-tools dependency; app.ts, auth.service.ts,
  require-auth.ts now import HttpError/errorHandlerService from
  error-tools instead of express-tools
- apps/api/Dockerfile: adds COPY for packages/error-tools in the
  runtime stage
- specs/error-handling.md, specs/backend-architecture.md, README.md
  updated to reflect the new package split

Verified: pnpm lint, pnpm build (all packages, correct dependency
order), pnpm test (9/9 Mocha), pnpm test:bdd (5/5 Cucumber), full
Docker rebuild + compose up (no crash-loop), curl + browser checks
of /health, unknown-route 404, signup (201), duplicate-email 409
(code 4001 EMAIL_ALREADY_IN_USE) — all going through the moved
ErrorHandlerService/HttpError correctly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-08-16 18:21:09 +02:00
parent 3a5dc83bf9
commit 95bd8fab87
17 changed files with 153 additions and 61 deletions

View file

@ -11,15 +11,20 @@ Monorepo pnpm workspaces :
`loginSchema`), types (`SafeUserProfile`), et le contrat d'erreurs (`ErrorCode`
numérique, `ApiErrorResponse`, voir [specs/error-handling.md](specs/error-handling.md)) —
même règles des deux côtés, pas de risque de dérive entre front et back.
- `packages/error-tools` — gestion des erreurs, **indépendante de tout framework
HTTP** (n'importe pas `express`) : `HttpError`, `ErrorHandlerService`. Séparé
d'`express-tools` précisément parce que rien ici ne dépend d'Express. Détail :
[specs/error-handling.md](specs/error-handling.md).
- `packages/express-tools` — outillage Express générique et réutilisable : `ExpressServer`
(init serveur, routes, middlewares), `wrapAsyncHandler`, `HttpError`,
`ErrorHandlerService`, `createErrorMiddleware` — séparé d'`apps/api`, pas de
logique métier. Détail : [specs/backend-architecture.md](specs/backend-architecture.md).
(init serveur, routes, middlewares), `wrapAsyncHandler`, `createErrorMiddleware`
(adapte `ErrorHandlerService` de `error-tools` à Express) — séparé d'`apps/api`,
pas de logique métier. Détail : [specs/backend-architecture.md](specs/backend-architecture.md).
`packages/shared` et `packages/express-tools` ont un vrai build (`tsc` → `dist/`,
voir leur `package.json`) : consommés en JS compilé, pas en TS brut — nécessaire
pour un runtime Node pur (Docker, pas de transpilation à la volée), voir la note
dans [specs/frontend-architecture.md](specs/frontend-architecture.md#note-sur-les-fichiers-dts).
`packages/shared`, `packages/error-tools` et `packages/express-tools` ont un vrai
build (`tsc` → `dist/`, voir leur `package.json`) : consommés en JS compilé, pas en
TS brut — nécessaire pour un runtime Node pur (Docker, pas de transpilation à la
volée), voir la note dans
[specs/frontend-architecture.md](specs/frontend-architecture.md#note-sur-les-fichiers-dts).
## Prérequis
@ -195,10 +200,11 @@ found, `500x` interne — et `ApiErrorResponse`) : l'API renvoie toujours
`{ code, message, details? }` (message en anglais, dev-facing — jamais affiché tel
quel), et le client traduit `code` en libellé français via **i18next**
(`ErrorMessageService`, `apps/web/src/services/error-message.service.ts`
`apps/web/src/locales/fr/translation.json`). Côté API, `ErrorHandlerService` et
`createErrorMiddleware` (`packages/express-tools`) centralisent la transformation
de toute erreur levée en réponse HTTP conforme — aucune valeur `ErrorCode` codée
en dur nulle part (toujours `ErrorCode.XXX`, y compris dans les mocks Cypress).
`apps/web/src/locales/fr/translation.json`). Côté API, `ErrorHandlerService`
(`packages/error-tools`) et `createErrorMiddleware` (`packages/express-tools`)
centralisent la transformation de toute erreur levée en réponse HTTP conforme —
aucune valeur `ErrorCode` codée en dur nulle part (toujours `ErrorCode.XXX`, y
compris dans les mocks Cypress).
Détail complet (schéma, exemples, comment ajouter un nouveau code d'erreur) :
[specs/error-handling.md](specs/error-handling.md).

View file

@ -24,6 +24,7 @@ COPY --from=build /repo/node_modules ./node_modules
COPY --from=build /repo/package.json ./package.json
COPY --from=build /repo/pnpm-workspace.yaml ./pnpm-workspace.yaml
COPY --from=build /repo/packages/shared ./packages/shared
COPY --from=build /repo/packages/error-tools ./packages/error-tools
COPY --from=build /repo/packages/express-tools ./packages/express-tools
COPY --from=build /repo/apps/api/node_modules ./apps/api/node_modules
COPY --from=build /repo/apps/api/dist ./apps/api/dist

View file

@ -14,6 +14,7 @@
"postinstall": "prisma generate"
},
"dependencies": {
"@batch-cooking/error-tools": "workspace:*",
"@batch-cooking/express-tools": "workspace:*",
"@batch-cooking/shared": "workspace:*",
"@prisma/client": "^5.22.0",

View file

@ -1,8 +1,5 @@
import {
ExpressServer,
createErrorMiddleware,
errorHandlerService,
} from "@batch-cooking/express-tools";
import { errorHandlerService } from "@batch-cooking/error-tools";
import { ExpressServer, createErrorMiddleware } from "@batch-cooking/express-tools";
import { ErrorCode } from "@batch-cooking/shared";
import type { Express, Request, Response } from "express";
import { env } from "./config/env.js";
@ -34,7 +31,7 @@ export function createServer(): ExpressServer {
// Final error-handling middleware: every thrown/`next(err)`-ed error in
// the app ends up here. All the "what status/body does this error map
// to" logic lives in ErrorHandlerService, from @batch-cooking/express-tools
// to" logic lives in ErrorHandlerService, from @batch-cooking/error-tools
// — this stays a thin adapter.
server.setErrorHandler(createErrorMiddleware(errorHandlerService));

View file

@ -1,4 +1,4 @@
import { HttpError } from "@batch-cooking/express-tools";
import { HttpError } from "@batch-cooking/error-tools";
import { ErrorCode, type SafeUserProfile } from "@batch-cooking/shared";
import type { NextFunction, Request, Response } from "express";
import { env } from "../config/env.js";

View file

@ -1,4 +1,4 @@
import { HttpError } from "@batch-cooking/express-tools";
import { HttpError } from "@batch-cooking/error-tools";
import { ErrorCode, type LoginInput, type SignupInput } from "@batch-cooking/shared";
import type { UserProfile } from "@prisma/client";
import argon2 from "argon2";

View file

@ -0,0 +1,27 @@
{
"name": "@batch-cooking/error-tools",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"scripts": {
"test": "echo \"no tests yet\" && exit 0",
"build": "tsc -p tsconfig.json",
"postinstall": "tsc -p tsconfig.json"
},
"devDependencies": {
"@types/node": "^22.9.0",
"typescript": "^5.7.2"
},
"dependencies": {
"@batch-cooking/shared": "workspace:*",
"zod": "^3.25.76"
}
}

View file

@ -2,7 +2,7 @@ import { type ApiErrorResponse, ErrorCode } from "@batch-cooking/shared";
import { ZodError } from "zod";
import { HttpError } from "./http-error.js";
/** Return value of {@link ErrorHandlerService.handle}: everything an Express error middleware needs to send a response. */
/** Return value of {@link ErrorHandlerService.handle}: everything a caller needs to send an HTTP response. */
export interface ErrorHandlingResult {
/** HTTP status code to respond with. */
status: number;
@ -12,9 +12,11 @@ export interface ErrorHandlingResult {
/**
* Centralizes every "how do we turn a thrown error into an HTTP response"
* decision for an Express app in one place, so route handlers and the
* error middleware never duplicate this logic. Wire it into Express via
* {@link createErrorMiddleware} (see error-middleware.ts).
* decision in one place, so route handlers and framework-specific
* middleware (e.g. `createErrorMiddleware` in `@batch-cooking/express-tools`)
* never duplicate this logic. Framework-agnostic on purpose it only maps
* an error to `{ status, body }` and has zero dependency on Express or any
* other HTTP framework.
*
* Recognizes three error shapes today (zod validation failures, our own
* `HttpError`, and anything else) and always falls back to a safe, generic

View file

@ -7,7 +7,7 @@ import type { ErrorCode } from "@batch-cooking/shared";
* Route handlers throw this (or let it bubble from a service call) instead
* of manually setting a status/body {@link ErrorHandlerService} is the
* single place that turns it into an actual HTTP response, so every error
* path in an Express app built with these tools is shaped consistently.
* path in an app built with these tools is shaped consistently.
*/
export class HttpError extends Error {
/** HTTP status code to respond with (e.g. 401, 404, 409). */

View file

@ -0,0 +1,11 @@
// Public entry point of the framework-agnostic error-handling tooling
// shared across apps in this monorepo. Everything here — HttpError,
// ErrorHandlerService — has zero dependency on Express or any other HTTP
// framework; it only knows how to map an error to a {status, body} pair.
//
// Framework-specific adapters (e.g. Express's `createErrorMiddleware`) live
// in their own package (`@batch-cooking/express-tools`) and consume these
// types instead of duplicating the mapping logic.
export * from "./error-handler.service.js";
export * from "./http-error.js";

View file

@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"declaration": true,
"outDir": "dist",
"rootDir": "src",
"types": ["node"]
},
"include": ["src"]
}

View file

@ -24,10 +24,10 @@
"typescript": "^5.7.2"
},
"dependencies": {
"@batch-cooking/error-tools": "workspace:*",
"@batch-cooking/shared": "workspace:*",
"cookie-parser": "^1.4.7",
"cors": "^2.8.6",
"express": "^4.21.1",
"zod": "^3.25.76"
"express": "^4.21.1"
}
}

View file

@ -1,5 +1,5 @@
import type { ErrorHandlerService } from "@batch-cooking/error-tools";
import type { NextFunction, Request, Response } from "express";
import type { ErrorHandlerService } from "./error-handler.service.js";
/** Express error-handling middleware signature (the 4-arg form Express detects as an error handler). */
type ExpressErrorMiddleware = (

View file

@ -3,15 +3,11 @@
// infrastructure lives here — domain-specific code (auth, business logic)
// stays in the consuming app.
//
// Note the split: ErrorHandlerService (error-handler.service.ts) has zero
// dependency on Express — it's a plain "map an error to {status, body}"
// service that would work identically behind Fastify or any other
// framework. ExpressServer and createErrorMiddleware are the actual
// Express-specific layer, adapting framework-agnostic pieces (like
// ErrorHandlerService) onto Express's API.
// Framework-agnostic error-handling pieces (ErrorHandlerService, HttpError)
// live in `@batch-cooking/error-tools` instead, since they have zero
// dependency on Express. `createErrorMiddleware` here is the thin Express
// adapter that wires that service into an Express app.
export * from "./async-handler.js";
export * from "./error-handler.service.js";
export * from "./error-middleware.js";
export * from "./express-server.js";
export * from "./http-error.js";

View file

@ -17,6 +17,9 @@ importers:
apps/api:
dependencies:
'@batch-cooking/error-tools':
specifier: workspace:*
version: link:../../packages/error-tools
'@batch-cooking/express-tools':
specifier: workspace:*
version: link:../../packages/express-tools
@ -134,8 +137,27 @@ importers:
specifier: ^5.4.11
version: 5.4.21(@types/node@22.20.1)(sass@1.102.0)
packages/error-tools:
dependencies:
'@batch-cooking/shared':
specifier: workspace:*
version: link:../shared
zod:
specifier: ^3.25.76
version: 3.25.76
devDependencies:
'@types/node':
specifier: ^22.9.0
version: 22.20.1
typescript:
specifier: ^5.7.2
version: 5.9.3
packages/express-tools:
dependencies:
'@batch-cooking/error-tools':
specifier: workspace:*
version: link:../error-tools
'@batch-cooking/shared':
specifier: workspace:*
version: link:../shared
@ -148,9 +170,6 @@ importers:
express:
specifier: ^4.21.1
version: 4.22.2
zod:
specifier: ^3.25.76
version: 3.25.76
devDependencies:
'@types/cookie-parser':
specifier: ^1.4.10

View file

@ -1,7 +1,7 @@
# Architecture backend — Projet Batch-cooking
> Documentation de l'organisation d'`apps/api` et de l'outillage partagé
> (`packages/express-tools`, `packages/shared`).
> (`packages/express-tools`, `packages/error-tools`, `packages/shared`).
---
@ -54,15 +54,17 @@ Sans ça, une exception dans un handler `async` ne remonte jamais tout seule au
middleware d'erreur d'Express — chaque route devait faire son propre
`try { ... } catch (err) { next(err); }`. `wrapAsyncHandler` l'automatise.
### `HttpError` / `ErrorHandlerService` / `createErrorMiddleware`
### `createErrorMiddleware` — adaptateur Express pour `packages/error-tools`
Voir [error-handling.md](./error-handling.md) pour le détail. À noter :
Voir [error-handling.md](./error-handling.md) pour le détail. `HttpError` et
`ErrorHandlerService` vivent dans **`packages/error-tools`**, pas ici :
`ErrorHandlerService` **n'a aucune dépendance à Express** — c'est un service
générique `erreur → { status, body }` qui fonctionnerait à l'identique derrière
Fastify ou n'importe quel autre framework. `ExpressServer` et
`createErrorMiddleware` sont la vraie couche Express : elles adaptent des
pièces indépendantes du framework (comme `ErrorHandlerService`) à l'API
d'Express. C'est pour ça que `ErrorHandlerService` n'importe jamais `express`.
Fastify ou n'importe quel autre framework, donc il n'a rien à faire dans un
package *express*-tools. `ExpressServer` et `createErrorMiddleware` (ici) sont
la vraie couche Express : elles adaptent des pièces indépendantes du framework
(`ErrorHandlerService`, importé depuis `@batch-cooking/error-tools`) à l'API
d'Express.
---

View file

@ -14,20 +14,29 @@ l'affichage utilisateur, passe par un chemin unique et prévisible :
réponse d'erreur de l'API). Ni l'API ni le web ne définissent leur propre liste
de codes, et aucune valeur n'est jamais codée en dur ailleurs (toujours
`ErrorCode.XXX`, jamais un nombre/une chaîne littérale).
- **`packages/error-tools`** — package séparé, **indépendant de tout framework
HTTP** (n'importe pas `express`) : `HttpError`, `ErrorHandlerService`. Le mapping
« erreur → `{ status, body }` » n'a rien de spécifique à Express, donc il ne vit
pas dans `express-tools`.
- **`packages/express-tools`** — package séparé pour l'outillage Express générique
(réutilisable par n'importe quel service Express du monorepo, pas seulement
`apps/api`) : `HttpError`, `ErrorHandlerService`, `createErrorMiddleware`.
- **`apps/api`** — consomme `express-tools` : lève des `HttpError`, le middleware
d'erreur final n'est qu'un appel à `createErrorMiddleware(errorHandlerService)`.
`apps/api`) : `createErrorMiddleware` (adapte `ErrorHandlerService` à l'API
Express), `ExpressServer`, `wrapAsyncHandler`.
- **`apps/api`** — consomme les deux : lève des `HttpError` (`error-tools`), le
middleware d'erreur final n'est qu'un appel à
`createErrorMiddleware(errorHandlerService)` (`express-tools`).
- **`apps/web``ErrorMessageService`** — associe chaque `ErrorCode` à une clé de
traduction, résolue via **i18next** (fichiers de locale sous `src/locales/`).
Les composants n'écrivent jamais de texte d'erreur en dur.
```mermaid
flowchart LR
subgraph TOOLS["packages/express-tools"]
subgraph ERRTOOLS["packages/error-tools"]
HTTPERR["HttpError"]
EHS["ErrorHandlerService.handle()"]
end
subgraph TOOLS["packages/express-tools"]
MW["createErrorMiddleware()"]
end
@ -55,6 +64,7 @@ flowchart LR
SHARED -. contrat .-> EMS
style SHARED fill:none,stroke:#888,stroke-width:1px
style ERRTOOLS fill:none,stroke:#888,stroke-width:1px
style TOOLS fill:none,stroke:#888,stroke-width:1px
```
@ -97,12 +107,7 @@ Pour ajouter un nouveau cas d'erreur :
---
## `packages/express-tools` — les pièces liées aux erreurs
`packages/express-tools` contient aussi `ExpressServer` (init serveur,
enregistrement de routes/middlewares) et `wrapAsyncHandler` — voir
[backend-architecture.md](./backend-architecture.md) pour le détail complet du
package. Les pièces qui concernent spécifiquement les erreurs :
## `packages/error-tools` — les pièces liées aux erreurs, indépendantes du framework
- **`http-error.ts`** — `HttpError` : erreur typée portant `status` (code HTTP) et
`code` (`ErrorCode`). C'est ce que lèvent les routes/services au lieu de
@ -112,24 +117,37 @@ package. Les pièces qui concernent spécifiquement les erreurs :
d'autre) en `{ status, body }`. Le cas générique (`INTERNAL_ERROR`, 500) logue
l'erreur côté serveur sans jamais exposer de détail interne au client.
**N'importe pas `express`** — c'est un service générique, indépendant du
framework HTTP, qui fonctionnerait à l'identique derrière Fastify ou autre.
- **`error-middleware.ts`** — `createErrorMiddleware(service)` : construit le
middleware d'erreur Express (signature à 4 arguments) à partir du service —
c'est LUI la vraie couche Express, `ErrorHandlerService` reste agnostique.
framework HTTP, qui fonctionnerait à l'identique derrière Fastify ou autre. C'est
précisément pour ça qu'il vit dans son propre package plutôt que dans
`express-tools` : rien ici ne dépend d'Express, donc rien ici n'a sa place dans
un package *express*-tools.
Build réel (`tsc` → `dist/`, comme `packages/shared`) : consommé en JS compilé,
pas en TS brut — voir la note dans
[frontend-architecture.md](./frontend-architecture.md#note-sur-les-fichiers-dts)
sur pourquoi ça compte pour un runtime Node pur (Docker).
## `packages/express-tools` — l'adaptateur Express
`packages/express-tools` contient `ExpressServer` (init serveur, enregistrement
de routes/middlewares) et `wrapAsyncHandler` — voir
[backend-architecture.md](./backend-architecture.md) pour le détail complet du
package. La pièce qui concerne spécifiquement les erreurs :
- **`error-middleware.ts`** — `createErrorMiddleware(service: ErrorHandlerService)` :
construit le middleware d'erreur Express (signature à 4 arguments) à partir
d'un `ErrorHandlerService` importé de `@batch-cooking/error-tools` — c'est LUI
la vraie couche Express, `ErrorHandlerService` reste agnostique. `express-tools`
dépend de `error-tools`, jamais l'inverse.
## Côté API (`apps/api`)
- **`app.ts`** — le middleware d'erreur final est enregistré via
`server.setErrorHandler(createErrorMiddleware(errorHandlerService))` (voir
[backend-architecture.md](./backend-architecture.md) pour `ExpressServer`) ;
aucune logique de mapping n'y vit directement, tout est dans `express-tools`.
aucune logique de mapping n'y vit directement, tout est dans `error-tools`.
- Les modules métier (`modules/auth/auth.service.ts`, `middlewares/require-auth.ts`)
importent `HttpError` depuis `@batch-cooking/express-tools` et `ErrorCode` depuis
importent `HttpError` depuis `@batch-cooking/error-tools` et `ErrorCode` depuis
`@batch-cooking/shared`.
## Côté Web (`apps/web`)