ADR-007 : Handlers MVC (Phase 4)
ADR-007 : Handlers MVC (Phase 4)
Date : 2026-Q2 (Phase 4 refactoring) — Statut : en cours / accepté
Contexte
Avec le repo pattern (ADR-006), les routes étaient devenues plus propres :
// routes/games.ts (avant Phase 4)
app.post('/api/games/ingest', requireAuth, requireConfirmed, requireCanAddGames, async (c) => {
const body = await c.req.parseBody();
const validated = gameIngestMetadataSchema.parse(body);
// 50 lignes de logique : upload PDF, validation extension parent, démarrage ingest...
const game = await gamesRepo.upsert(...);
// … encore 30 lignes
return c.json({ gameId: game.id });
});
Mais les routes contenaient encore beaucoup de logique métier :
- Validation cross-table (parent existe ?)
- Décisions (ingest immédiat vs scheduled ?)
- Manipulation fichiers (PDF upload sur disque)
- Orchestration services (startIngestJob)
Conséquences :
- Routes de 100+ lignes
- Difficile à tester sans démarrer Hono
- Logique métier mélangée avec préoccupations HTTP
Décision
Architecture en 4 couches : routes → handlers → services → repositories.
// routes/games.ts (Phase 4)
import { ingestGameHandler } from '../handlers/games/ingest.js';
app.post('/api/games/ingest', requireAuth, requireConfirmed, requireCanAddGames, async (c) => {
const formData = await c.req.parseBody();
const validated = gameIngestMetadataSchema.parse(formData);
const pdfBuffer = await (formData['pdf'] as File).arrayBuffer();
const userId = c.get('user').id;
const result = await ingestGameHandler(validated, Buffer.from(pdfBuffer), userId);
if (!result.ok) return c.json({ error: result.error }, result.status);
return c.json({ gameId: result.gameId, scheduled: result.scheduled });
});
// handlers/games/ingest.ts
type IngestResult =
| { ok: true; gameId: string; scheduled?: boolean; scheduledStartAt?: string }
| { ok: false; status: 400 | 404; error: string };
export async function ingestGameHandler(
metadata: GameIngestMetadata,
pdfBuffer: Buffer,
userId: string,
): Promise<IngestResult> {
// Validation cross-table
if (metadata.parentGameId) {
const parent = await gamesRepo.getById(metadata.parentGameId);
if (!parent) return { ok: false, status: 404, error: 'parent game not found' };
}
// Manipulation fichiers
const sourceFile = `/app/pdfs/${slug}-${Date.now()}.pdf`;
await fs.writeFile(sourceFile, pdfBuffer);
// Décision ingest immédiat vs scheduled
if (metadata.scheduledStartAt) {
const game = await gamesRepo.upsert({ ..., ingestStatus: 'scheduled' });
scheduleIngestStart(game.id, new Date(metadata.scheduledStartAt));
return { ok: true, gameId: game.id, scheduled: true, scheduledStartAt };
}
// Ingest immédiat
const game = await gamesRepo.upsert({ ..., ingestStatus: 'idle' });
startIngestJob(game.id);
return { ok: true, gameId: game.id };
}
Règles du handler
- Pas d'import Hono : un handler ne connaît pas le framework. Pas de
c.json, pas dec.req, pas deContext. - Reçoit des données validées : le handler suppose que l'input est déjà passé par Zod côté route.
- Reçoit les dépendances explicites :
pdfBuffer(pasformData),userId(pas le cookie session). - Retourne un Result discriminé :
{ ok: true; ... } | { ok: false; status: ...; error: string }pour les erreurs attendues. Pour les bugs / timeouts / erreurs non-récupérables, onthrownormalement (capturé par le handler global Hono). - Status HTTP dans le Result : la route mappe le
statusauc.json(..., status). Garde la connaissance HTTP côté route.
Conséquences
Bonnes
- Routes plus courtes : 5-15 lignes, juste parsing + délégation
- Handlers testables sans Hono :
await ingestGameHandler(meta, buf, 'user-123')directement en Vitest - Logique métier centralisée : ouvrir
handlers/games/ingest.ts, voir tout ce qui se passe - Result discriminé : TypeScript force à gérer tous les cas (ok / 400 / 404)
- Réutilisabilité : un handler peut être appelé par plusieurs routes ou par un script CLI
Mauvaises
- Plus de fichiers :
handlers/games/{ingest,delete,update,...}.ts. Friction au refactor (~50% plus de fichiers). - Verbosité du Result : pour des erreurs simples, le Result discriminé est plus verbeux que
throw new HTTPError(404, 'not found'). - Migration en cours : pas tous les routes sont passées au pattern. Cohabitation
routes-styleancien etroutes + handlersnouveau pendant la transition.
État de la migration (2026-05-10)
| Route | Statut |
|---|---|
POST /api/games/ingest |
✅ Phase 4 (handler) |
DELETE /api/games/:id |
✅ Phase 4 (handler) |
POST /api/ask/stream |
⏳ encore en pattern routes-direct (logique RAG complexe, refactor reporté) |
POST /api/auth/* |
⏳ pattern routes-direct (logique simple, pas urgent) |
POST /api/admin/* |
⏳ mixte |
À chaque nouveau besoin sur une route, l'extraire en handler en passant.
Workflow "ajouter une route"
- Ajouter le schéma Zod dans
src/lib/schemas.ts(si réutilisable) - Créer le handler dans
src/handlers/<domaine>/<action>.ts - Définir le type
Resultdiscriminé - Implémenter la logique en utilisant les services + repos
- Écrire le test Vitest du handler (mock services / repos)
- Brancher la route Hono qui parse + valide + délègue + map le Result en réponse HTTP
No comments to display
No comments to display