batchCooking/specs/backend-architecture.md
Nicolas 95bd8fab87 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>
2026-08-16 18:21:09 +02:00

5.7 KiB

Architecture backend — Projet Batch-cooking

Documentation de l'organisation d'apps/api et de l'outillage partagé (packages/express-tools, packages/error-tools, packages/shared).


packages/express-tools — outillage Express générique

Package séparé, réutilisable par n'importe quel service Express du monorepo (pas seulement apps/api) : pas de logique métier, juste de l'infra Express.

ExpressServer — init serveur, routes, middlewares

Enveloppe une application Express derrière une API typée, au lieu que chaque service refasse le même express() à la main :

const server = new ExpressServer();
server.setupCore({ corsOrigin: env.CORS_ORIGIN }); // cors + json + cookie-parser
server.addRoute("get", "/health", (_req, res) => res.status(200).json({ status: "ok" }));
server.mountRouter("/auth", authRouter);
server.addMiddleware(notFoundHandler);
server.setErrorHandler(createErrorMiddleware(errorHandlerService));
server.listen(port, () => console.log(`Listening on ${port}`));
  • setupCore(options) — middleware stack commun (CORS avec credentials, JSON, cookies).
  • addRoute(method, path, ...handlers) — enregistre une route ; avertit et ignore au lieu d'écraser silencieusement si la même route (méthode + chemin) est déjà enregistrée.
  • addMiddleware / mountRouter / setErrorHandler — ajout de middleware générique, montage d'un Router complet, middleware d'erreur final (4 arguments — doit être ajouté en dernier).
  • .instance — l'app Express brute, nécessaire pour les outils de test (supertest) qui attendent une instance Express, pas le wrapper.
  • .listen(port, onListening?) — démarre le serveur.

apps/api/src/app.ts expose deux fonctions : createServer(): ExpressServer (utilisée par server.ts, qui appelle .listen()) et createApp(): Express (= createServer().instance, utilisée par les tests).

wrapAsyncHandler — plus de try/catch répété dans les routes

router.post("/signup", wrapAsyncHandler(async (req, res) => {
  const profile = await signup(req.body); // une erreur/rejet ici va automatiquement à next()
  res.status(201).json(profile);
}));

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.

createErrorMiddleware — adaptateur Express pour packages/error-tools

Voir 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, 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.


Auth : res.locals, pas d'augmentation du namespace Express

requireAuth (apps/api/src/middlewares/require-auth.ts) attache le profil authentifié à res.locals.userProfile, typé via l'interface AuthLocals :

export interface AuthLocals {
  userProfile: SafeUserProfile;
}

export async function requireAuth(req: Request, res: Response<unknown, AuthLocals>, next: NextFunction) {
  // ...
  res.locals.userProfile = safeProfile;
  next();
}

Un handler derrière ce middleware type sa réponse Response<unknown, AuthLocals> et lit res.locals.userProfile sans cast :

authRouter.get("/me", requireAuth, (_req, res: Response<unknown, AuthLocals>) => {
  res.status(200).json(res.locals.userProfile);
});

Pourquoi pas declare global { namespace Express { interface Request {...} } } (l'approche initialement utilisée, retirée depuis) : res.locals est le mécanisme natif d'Express prévu exactement pour ça (faire passer des données d'un middleware au handler suivant), typé par route via un paramètre générique — pas une augmentation globale et permanente qui change silencieusement le type de toutes les Request du projet, qu'elles soient passées par ce middleware ou non.


packages/sharedassertIsNever

packages/shared/src/tools/assert-is-never.ts — vérification d'exhaustivité pour un switch/if-chain sur une union :

switch (shape.kind) {
  case "circle": return Math.PI * shape.radius ** 2;
  case "square": return shape.side ** 2;
  default: return assertIsNever(shape); // erreur de compilation si un cas manque
}

Si un membre de l'union n'est pas traité par une branche précédente, shape n'est plus de type never au niveau du defaulterreur de compilation (vérifié : tsc rejette bien un cas manquant). Lève aussi une vraie erreur au runtime, en filet de sécurité si une valeur invalide échappe au système de types (ex. donnée externe non validée).

Pas encore de point d'usage réel dans le code métier actuel (aucun switch/if-chain exhaustif sur une union n'existe encore) — prêt à l'emploi dès qu'un cas s'y prête (le module « Calcul batch-cooking » ou le pipeline d'import de recette, tous deux encore à construire, en auront probablement).


Pas de fichiers .d.ts écrits à la main

Voir frontend-architecture.md pour le détail côté apps/web. Côté apps/api : aucune augmentation de type globale (declare global) n'est utilisée — voir la section res.locals ci-dessus, qui est précisément ce qui aurait nécessité ce genre de fichier.