Le routeur métier, les services et les gardes
D’où vient réellement ctx, le routeur d’authentification lu ligne à ligne, puis le passage à un backend réel : la logique dans des services, la sécurité dans des gardes, la règle du projet pour décider ce qui descend dans un service, et ce que tRPC apporte vraiment.
Précédemment. Une requête se lit en deux dimensions : le contexte (dans quel cadre) et la procédure (quelle intention). Le routeur regroupe les procédures d’un domaine, et
appRouterassemble les domaines.
Mouvement 2 — Composer une procédure#
Savoir que l’environnement et l’intention sont deux choses distinctes, c’est un début. Mais dans un backend réel, une procédure n’est jamais seule : elle vit au milieu d’autres couches, chacune avec son rôle. Ce mouvement est celui de la composition — apprendre qui fait quoi, et dans quel ordre.
Voici les deux étapes que nous allons traverser :
- Ce chapitre — Nous ouvrons un vrai routeur métier et nous clarifions une ambiguïté qui piège beaucoup de débutants : d’où vient réellement
ctx? Puis nous sortons la logique de la procédure pour la placer dans un service, nous déplaçons la sécurité dans des gardes, et nous voyons ce que tRPC apporte vraiment — et ce qu’il n’apporte pas. - Chapitre 5 — Nous apprenons à fabriquer une procédure concrète, dans le bon ordre, avec les bonnes contraintes, pour qu’elle se lise d’un seul regard.
À la fin de ce mouvement, nous saurons non seulement comprendre une procédure existante, mais aussi en écrire une nouvelle, dans un backend réel, sans mélanger les responsabilités.
Le router métier (là où l’intention devient action)#
Maintenant que le contexte est clarifié, nous pouvons enfin ouvrir un vrai router métier.
Mais avant d’aller plus loin, un point doit être verrouillé.
Parce que c’est le point où la plupart des développeurs débutants se perdent.
🧠 D’où vient réellement ctx ?#
En lisant une procédure pour la première fois, nous pourrions croire que ctx est créé ici.
Ce n’est pas le cas.
👉 ctx est produit en amont par createTRPCContext.
Et surtout :
👉 il n’est jamais appelé manuellement dans le router.
Il serait même impossible de l’appeler manuellement : le router n’a pas accès à la requête HTTP. La requête vit à un niveau supérieur, dans la route App Router. Le router ne connaît que le résultat — l’objet ctx déjà construit, qui lui est remis.
Le vrai flux#
Scroller horizontalement si nécessaire
👉 Concrètement :
- la requête arrive dans
/api/trpc fetchRequestHandlerprend la maincreateTRPCContext(req)est appelé- un objet
ctxest construit - ce
ctxest injecté dans la procédure
Une procédure ne construit jamais le contexte.
Nous recevons toujours un environnement déjà reconstruit.
Ce que nous venons réellement observer ici#
Nous n’ouvrons pas ce fichier pour voir “une route utilisateur”.
Nous l’ouvrons pour observer comment tRPC articule :
- une intention (procédure)
- un contexte déjà prêt (
ctx) - une exécution (Prisma)
Un vrai router métier#
import { router, publicProcedure, protectedProcedure } from "@backend/trpc/core";import { deleteSessionFromCookie } from "@backend/lib/session/session.server";import { loginService } from "@backend/modules/auth/services/auth.service";import { createPasswordResetToken, hashResetToken, RESET_TOKEN_COOLDOWN_MS, RESET_TOKEN_TTL_MS,} from "@backend/modules/auth/services/passwordReset.service";import { sendPasswordResetEmail } from "@backend/email/services/sendPasswordResetEmail";import { TRPCError } from "@trpc/server";import { hashPassword, verifyPassword } from "@backend/lib/auth/password";import { getToken, verifyJwt } from "@backend/lib/session/session.server";import { changePasswordSchema, loginSchema, requestPasswordResetSchema, resetPasswordSchema,} from "@contracts/auth/auth.schema";import { assertPasswordOfConnectedUser } from "@backend/lib/security/passwordCheck";/** * Connexion, déconnexion, session courante, mot de passe (changement, oubli, réinitialisation). * `getSession` est appelée en permanence par le front : elle répond à « qui suis-je, maintenant ? ». */export const authRouter = router({ login: publicProcedure // Même schéma que le formulaire de connexion (packages/contracts) : une seule règle, un seul message. .input(loginSchema) .mutation(async ({ input }) => { const { email, password } = input; try { await loginService(email, password); return { success: true as const, }; } catch (error) { // Un mauvais identifiant fait partie du fonctionnement normal du formulaire : c'est un // résultat. Tout le reste (base injoignable, bug) est une vraie erreur : elle remonte, // est journalisée par la route /api/trpc, et le formulaire affiche son message générique. if (error instanceof TRPCError && error.message === "INVALID_CREDENTIALS") { return { success: false as const, error: "INVALID_CREDENTIALS" as const, }; } // Trop d'échecs pour cette adresse : un résultat aussi, que le formulaire explique. if (error instanceof TRPCError && error.message === "TOO_MANY_ATTEMPTS") { return { success: false as const, error: "TOO_MANY_ATTEMPTS" as const, }; } throw error; } }), logout: protectedProcedure.mutation(async () => { await deleteSessionFromCookie(); return { success: true, }; }),🧠 Lecture guidée du router auth#
Anatomie d’un routeur métier
Clique une étape pour mettre en évidence la zone correspondante dans le fichier complet.
import { router, publicProcedure, protectedProcedure } from "@backend/trpc/core";import { deleteSessionFromCookie } from "@backend/lib/session/session.server";import { loginService } from "@backend/modules/auth/services/auth.service";import { createPasswordResetToken, hashResetToken, RESET_TOKEN_COOLDOWN_MS, RESET_TOKEN_TTL_MS,} from "@backend/modules/auth/services/passwordReset.service";import { sendPasswordResetEmail } from "@backend/email/services/sendPasswordResetEmail";import { TRPCError } from "@trpc/server";import { hashPassword, verifyPassword } from "@backend/lib/auth/password";import { getToken, verifyJwt } from "@backend/lib/session/session.server";import { changePasswordSchema, loginSchema, requestPasswordResetSchema, resetPasswordSchema,} from "@contracts/auth/auth.schema";import { assertPasswordOfConnectedUser } from "@backend/lib/security/passwordCheck";/** * Connexion, déconnexion, session courante, mot de passe (changement, oubli, réinitialisation). * `getSession` est appelée en permanence par le front : elle répond à « qui suis-je, maintenant ? ». */export const authRouter = router({ login: publicProcedure // Même schéma que le formulaire de connexion (packages/contracts) : une seule règle, un seul message. .input(loginSchema) .mutation(async ({ input }) => { const { email, password } = input; try { await loginService(email, password); return { success: true as const, }; } catch (error) { // Un mauvais identifiant fait partie du fonctionnement normal du formulaire : c'est un // résultat. Tout le reste (base injoignable, bug) est une vraie erreur : elle remonte, // est journalisée par la route /api/trpc, et le formulaire affiche son message générique. if (error instanceof TRPCError && error.message === "INVALID_CREDENTIALS") { return { success: false as const, error: "INVALID_CREDENTIALS" as const, }; } // Trop d'échecs pour cette adresse : un résultat aussi, que le formulaire explique. if (error instanceof TRPCError && error.message === "TOO_MANY_ATTEMPTS") { return { success: false as const, error: "TOO_MANY_ATTEMPTS" as const, }; } throw error; } }), logout: protectedProcedure.mutation(async () => { await deleteSessionFromCookie(); return { success: true, }; }), /** * Changement de mot de passe (utilisateur connecté). * Vérifie le mot de passe actuel, refuse un nouveau identique, re-hash, et * DÉCONNECTE LES AUTRES APPAREILS (supprime leurs sessions ; garde la * courante via le sessionId du cookie). */ changePassword: protectedProcedure .input(changePasswordSchema) .mutation(async ({ ctx, input }) => { const userId = ctx.sessionClient.user.id; const user = await ctx.prisma.user.findUnique({ where: { id: userId }, select: { password: true }, }); if (!user) { throw new TRPCError({ code: "NOT_FOUND", message: "Membre introuvable." }); } // BAD_REQUEST, pas UNAUTHORIZED : le membre EST connecté ; c'est la saisie qui est fausse. // UNAUTHORIZED signifie « aucune session », et un client pourrait le traiter comme une déconnexion. // Essais limités : une session volée ne permet pas d'essayer des mots de passe sans fin. await assertPasswordOfConnectedUser({ userId, password: input.currentPassword, hash: user.password, wrongMessage: "Mot de passe actuel incorrect.", }); const same = await verifyPassword(input.newPassword, user.password); if (same) { throw new TRPCError({ code: "BAD_REQUEST", message: "Le nouveau mot de passe doit être différent de l'actuel.", }); } const hashed = await hashPassword(input.newPassword); const payload = verifyJwt(await getToken()); const currentSessionId = payload?.sessionId ?? null; await ctx.prisma.$transaction(async (tx) => { await tx.user.update({ where: { id: userId }, data: { password: hashed }, }); await tx.session.deleteMany({ where: { userId, ...(currentSessionId ? { NOT: { id: currentSessionId } } : {}), }, }); }); return { success: true }; }), getSession: publicProcedure.query(async ({ ctx }) => { return ctx.sessionClient; }), requestPasswordReset: publicProcedure .input(requestPasswordResetSchema) .mutation(async ({ input, ctx }) => { // Déjà nettoyée et mise en minuscules par emailSchema, comme toutes les adresses enregistrées. const user = await ctx.prisma.user.findUnique({ where: { email: input.email }, select: { id: true, email: true }, }); if (!user) { return { ok: true as const }; } const lastToken = await ctx.prisma.passwordResetToken.findFirst({ where: { userId: user.id }, orderBy: { createdAt: "desc" }, select: { createdAt: true }, }); if (lastToken) { const elapsed = Date.now() - lastToken.createdAt.getTime(); if (elapsed < RESET_TOKEN_COOLDOWN_MS) { return { ok: true as const }; } } const { token, tokenHash } = createPasswordResetToken(); const expiresAt = new Date(Date.now() + RESET_TOKEN_TTL_MS); await ctx.prisma.passwordResetToken.create({ data: { userId: user.id, tokenHash, expiresAt, requestIp: ctx.requestIp ?? null, userAgent: ctx.userAgent ?? null, }, }); // L'envoi n'est PAS attendu : il prend plusieurs secondes, et une réponse lente pour une // adresse connue, rapide pour une inconnue, trahirait qui a un compte. Jamais le jeton dans // les journaux ; un échec d'envoi est journalisé, la réponse reste la même. void sendPasswordResetEmail({ toEmail: user.email, token }).catch((err: unknown) => { console.error( "[auth] e-mail de réinitialisation non envoyé :", err instanceof Error ? err.message : err, ); }); return { ok: true as const }; }), resetPassword: publicProcedure .input(resetPasswordSchema) .mutation(async ({ ctx, input }) => { const token = input.token.trim(); const tokenHash = hashResetToken(token); const entry = await ctx.prisma.passwordResetToken.findUnique({ where: { tokenHash }, select: { id: true, userId: true, expiresAt: true, usedAt: true, }, }); if (!entry) { throw new TRPCError({ code: "BAD_REQUEST", message: "Ce lien n'est pas valide. Demandez-en un nouveau avec « Mot de passe oublié »." }); } if (entry.usedAt) { throw new TRPCError({ code: "BAD_REQUEST", message: "Ce lien a déjà servi. Connectez-vous, ou demandez-en un nouveau avec « Mot de passe oublié »." }); } if (entry.expiresAt.getTime() < Date.now()) { throw new TRPCError({ code: "BAD_REQUEST", message: "Ce lien a expiré. Demandez-en un nouveau avec « Mot de passe oublié »." }); } const hashedPassword = await hashPassword(input.newPassword); await ctx.prisma.$transaction(async (tx) => { await tx.user.update({ where: { id: entry.userId }, // `isFirstLogin` n'est pas touché : choisir un mot de passe ne complète pas le profil. // Un nouveau membre qui passe par « Mot de passe oublié » y reste invité. data: { password: hashedPassword }, }); // Consommation atomique : la ligne n'est marquée que si elle ne l'était pas encore. Deux // envois simultanés du même lien passent tous deux les vérifications ci-dessus ; un seul // obtient ici count === 1, l'autre annule sa transaction. const consumed = await tx.passwordResetToken.updateMany({ where: { id: entry.id, usedAt: null }, data: { usedAt: new Date() }, }); if (consumed.count !== 1) { throw new TRPCError({ code: "BAD_REQUEST", message: "Ce lien a déjà servi. Connectez-vous, ou demandez-en un nouveau avec « Mot de passe oublié »." }); } await tx.session.deleteMany({ where: { userId: entry.userId }, }); }); return { ok: true as const }; }),});Une dernière procédure de ce routeur mérite un regard, plus bas dans le fichier : getSession.
getSession: publicProcedure.query(async ({ ctx }) => { return ctx.sessionClient; }),Elle est publique — pas besoin d’être connecté pour l’appeler — et pourtant elle lit le contexte, où sessionClient peut être null. Le client obtient toujours une réponse : la session, ou rien. C’est la procédure que le front appelle pour savoir « qui suis-je, maintenant ? ». Elle tient en une ligne parce que tout le travail a été fait avant elle, par createTRPCContext.
1) L’intention vit dans la procédure#
👉 ici, nous ne définissons pas une route HTTP
👉 nous exprimons une intention métier :
- créer
- lire
- mettre à jour
- supprimer
Où vit l’intention ?
Lecture | REST | tRPC |
|---|---|---|
Support principal | URL + méthode HTTP | nom de procédure |
Exemple | POST /users | user.create(...) |
Effet mental | il faut interpréter | l’intention est explicite |
Showing entries1to3entries
2) Le contexte est déjà prêt#
Quand la procédure s’exécute :
👉 ctx est déjà disponible
Donc nous pouvons directement :
- accéder à Prisma via
ctx.prisma - lire l’utilisateur via
ctx.sessionClient.user - appliquer la logique métier
👉 Nous ne faisons pas :
- lire les cookies
- reconstruire la session
- deviner l’utilisateur
Toute cette plomberie a été faite en amont par le middleware et createTRPCContext. Si nous nous retrouvions à écrire req.headers.get("cookie") dans une procédure, ce serait le signe d’une fuite architecturale — quelque chose devrait vivre dans core.ts, pas ici.
3) Prisma termine le flux#
Prisma n’est pas le début.
👉 c’est la fin logique du flux
- contexte construit
- intention définie
- action exécutée
Dans notre architecture, Prisma est toujours le dernier maillon. Quand nous voyons ctx.prisma.user.create(...), c’est que toutes les étapes précédentes ont réussi : authentification, validation, autorisation. Si l’une d’elles avait échoué, nous ne serions jamais arrivés ici.
🔥 Le circuit complet#
Scroller horizontalement si nécessaire
🚀 Conclusion#
ctxvient decreateTRPCContext- il est injecté automatiquement
- la procédure porte l’intention
- Prisma exécute
👉 séparation claire → backend lisible
🧠 À ce stade, nous appelons encore Prisma directement pour simplifier la lecture.
Mais dans un backend réel, cette logique est généralement extraite dans des services et protégée par des guards.
👉 C’est ce que nous allons introduire juste après.
👉 Où nous en sommes : nous avons ouvert un vrai router et dissipé l’ambiguïté sur ctx — il vient toujours d’en amont, jamais de la procédure elle-même. Mais dans cette première lecture, nous avons simplifié : la procédure parle directement à Prisma. Reste à voir ce qui se glisse entre les deux dans un backend réel — la séparation qui fait qu’un router reste lisible même quand la logique métier grossit.
Jusqu’ici, nous avons volontairement simplifié.
Dans les exemples précédents, la procédure appelait directement Prisma.
C’est utile pour comprendre le flux.
Mais dans un backend réel, ce n’est pas ce que nous faisons.
Et il y a une raison profonde à cela.
🧠 Le problème d’un router “trop intelligent”#
Si nous écrivons :
Logique métier directement dans la procédure
protectedProcedure.mutation(({ ctx, input }) => { return ctx.prisma.user.create({ data: input })})👉 nous mélangeons plusieurs responsabilités :
- intention (procédure)
- logique métier
- accès base de données
💥 Résultat :
- duplication quand plusieurs procédures font la même chose
- code difficile à tester parce que dépendant de tRPC
- logique dispersée dans les routers
Et surtout, un effet pernicieux : quand une règle métier change — disons, “nous devons désormais envoyer un e-mail de bienvenue lors de la création d’un membre” — nous devons la répliquer partout où un membre est créé. Y compris dans les scripts de seed, les tâches CRON, les tests. Et si nous en oublions un endroit, l’incohérence s’installe silencieusement.
🔥 La séparation des responsabilités#
Dans un backend propre, nous introduisons une séparation explicite :
- procédure → intention
- guard / middleware → autorisation
- service → logique métier
- Prisma → accès aux données
Chaque couche a un rôle unique. Et chaque couche peut changer sans affecter les autres.
🧱 Nouvelle structure#
Scroller horizontalement si nécessaire
1) Le service : la vraie logique métier#
Nous extrayons la logique dans un fichier séparé :
Service métier
export const userService = { async createUser(ctx, input) { return ctx.prisma.user.create({ data: input }) }}👉 Le service :
- contient la logique métier, isolée
- peut être testé sans tRPC
- peut être réutilisé par plusieurs procédures, des scripts, des jobs
Regardons un vrai service du projet, celui qui vérifie un identifiant et un mot de passe :
import { TRPCError } from "@trpc/server";import { prisma } from "@backend/prisma";import { createSessionJWT } from "@backend/lib/session/session.server";import { UNKNOWN_ACCOUNT_HASH, verifyPassword } from "@backend/lib/auth/password";import { createThrottle } from "@backend/lib/security/throttle";/** * Essais de mot de passe : 10 échecs par quart d'heure et par adresse. Au-delà, l'adresse est * refusée sans comparaison jusqu'à ce que la fenêtre glisse. La clé est l'adresse, pas l'adresse IP : * l'IP vient d'un en-tête qu'un client peut écrire lui-même, et tout un club peut partager la même. */const loginThrottle = createThrottle({ limit: 10, windowMs: 15 * 60 * 1000 });/** * Connexion : vérifie l'adresse et le mot de passe, puis ouvre une session (ligne en base, jeton * signé dans un cookie). * * Une adresse inconnue et un mauvais mot de passe produisent la même erreur, EN LE MÊME TEMPS : * sans compte, le mot de passe est quand même comparé, à une empreinte factice. Ni le message ni * la durée de la réponse ne disent si une adresse a un compte au club. La limite d'essais * s'applique de même à toute adresse, qu'elle ait un compte ou non. */export async function loginService(email: string, password: string): Promise<void> { const key = `login:${email}`; if (loginThrottle.isLimited([key])) { throw new TRPCError({ code: "TOO_MANY_REQUESTS", message: "TOO_MANY_ATTEMPTS" }); } const user = await prisma.user.findUnique({ where: { email }, select: { id: true, password: true }, }); const valid = await verifyPassword(password, user?.password ?? UNKNOWN_ACCOUNT_HASH); if (!user || !valid) { loginThrottle.record([key]); throw new TRPCError({ code: "UNAUTHORIZED", message: "INVALID_CREDENTIALS" }); } loginThrottle.clear([key]); await createSessionJWT(user.id);}Ce service fait un travail précis : il cherche le membre par son adresse, compare le mot de passe saisi à l’empreinte enregistrée (bcrypt) — à une empreinte factice si l’adresse n’a pas de compte, pour que la réponse prenne le même temps —, puis crée la session — une ligne en base et un jeton signé dans un cookie, que la partie suivante ouvrira en détail. Aucune trace de requête HTTP, aucun cookie lu, aucune procédure : il reçoit deux chaînes et fait son travail.
Une seule trace de tRPC subsiste : l’erreur qu’il lève est une TRPCError. Un service tout à fait indépendant lèverait sa propre erreur, que la procédure traduirait. Le projet accepte ce raccourci, parce que ses services ne servent que ses procédures ; il faut seulement savoir qu’il existe.
C’est cette indépendance qui le rend testable, réutilisable, et maintenable.
2) La procédure devient simple#
Procédure orchestratrice
protectedProcedure.mutation(({ ctx, input }) => { return userService.createUser(ctx, input)})👉 Elle :
- reçoit l’intention
- délègue au service approprié
- ne contient plus de logique lourde
Cette légèreté n’est pas une perte — c’est un gain. Quand nous lisons un router, nous devons pouvoir embrasser en un coup d’œil toutes les intentions qu’il expose. Si chaque procédure fait trente lignes de logique métier, la carte du système devient illisible.
3) Les guards : sécuriser le flux#
Avant, nous écrivions peut-être ceci :
Vérification manuelle
if (!ctx.session.user) { throw new Error('Unauthorized')}Dans chaque procédure. À chaque fois. Avec le risque d’oublier une fois sur cent.
Avec tRPC :
👉 cette logique est déplacée dans :
protectedProcedure(un middleware attaché globalement)- ou un middleware spécialisé (
isAdmin, réservé aux administrateurs)
La sécurité ne doit pas être répétée dans chaque procédure.
Elle doit être centralisée.
🔥 Le rôle de protectedProcedure#
👉 protectedProcedure fait deux choses :
- bloque l’accès si l’utilisateur n’est pas authentifié (jette
UNAUTHORIZED) - garantit que
ctx.sessionClient.userexiste (requalification TypeScript)
💡 Donc dans le service appelé par une protectedProcedure :
👉 nous pouvons supposer que l’utilisateur est présent, sans vérifier
TypeScript le garantit. Le middleware isAuthed a requalifié ctx.sessionClient de SessionClient | null vers AuthenticatedSessionClient. Quand le service lit ctx.sessionClient.user.id, aucun ? n’est nécessaire. La garantie est portée par les types.
🧠 Vue finale#
Responsabilités dans le backend
Couche | Rôle |
|---|---|
procédure | porte l’intention |
guard | sécurise l’accès |
service | implémente la logique métier |
Prisma | exécute sur la base de données |
Showing entries1to4entries
🚀 Le vrai flux final#
Scroller horizontalement si nécessaire
🧭 Ce que fait réellement le projet#
Il serait faux de dire que chaque procédure du projet délègue à un service. Beaucoup portent leur logique elles-mêmes, et ce n’est pas un défaut en soi. La règle suivie est plus simple : une logique utilisée à un seul endroit reste dans sa procédure ; dès qu’elle sert à deux endroits, elle descend dans un service.
Les services vivent à côté du routeur de leur domaine, dans un dossier services/ (modules/media/services/syncPageMediaReferences.service.ts, modules/auth/services/auth.service.ts…). Ils reçoivent ce dont ils ont besoin en paramètres — le client Prisma, ou la transaction en cours — plutôt que le contexte tRPC tout entier.
Le routeur des disciplines en donne un exemple récent. La création et la modification d’une discipline vérifiaient chacune, ligne à ligne, que la catégorie, l’instructeur, la famille et l’origine existaient : une cinquantaine de lignes, écrites deux fois, déjà légèrement différentes (la catégorie, non modifiable, n’est vérifiée qu’à la création). Ces vérifications sont désormais un service :
import type { PrismaClient } from "@prisma/client";import { TRPCError } from "@trpc/server";/** * Les références d'une discipline (catégorie, instructeur, famille, origine) existent-elles ? * * Partagé par `discipline.create` et `discipline.update`. Une référence absente (`undefined`) n'est * pas vérifiée : elle ne change pas. `null` (famille, origine) signifie « détacher » : rien à * vérifier non plus. Sans ce contrôle, la base refuserait l'écriture avec une erreur de clé * étrangère, incompréhensible pour l'administrateur ; ici, chaque référence manquante a son message. */export async function assertDisciplineReferences( prisma: PrismaClient, refs: { categoryId?: number; instructorId?: string; familyId?: number | null; originId?: number | null; },): Promise<void> { if (refs.categoryId !== undefined) { const category = await prisma.category.findUnique({ where: { id: refs.categoryId }, select: { id: true } }); if (!category) { throw new TRPCError({ code: "BAD_REQUEST", message: `Catégorie introuvable (id=${refs.categoryId}).` }); } } if (refs.instructorId !== undefined) { const instructor = await prisma.user.findUnique({ where: { id: refs.instructorId }, select: { id: true } }); if (!instructor) { throw new TRPCError({ code: "BAD_REQUEST", message: `Instructeur introuvable (id=${refs.instructorId}).` }); } } if (refs.familyId !== undefined && refs.familyId !== null) { const family = await prisma.disciplineFamily.findUnique({ where: { id: refs.familyId }, select: { id: true } }); if (!family) { throw new TRPCError({ code: "BAD_REQUEST", message: `Famille de disciplines introuvable (id=${refs.familyId}).` }); } } if (refs.originId !== undefined && refs.originId !== null) { const origin = await prisma.origin.findUnique({ where: { id: refs.originId }, select: { id: true } }); if (!origin) { throw new TRPCError({ code: "BAD_REQUEST", message: `Origine introuvable (id=${refs.originId}).` }); } }}Et les deux procédures l’appellent, chacune avec ce qu’elle reçoit :
create: protectedProcedure .use(isAdmin) .input(createInput) .mutation(async ({ ctx, input }) => { assertOwnAvatars(ctx.sessionClient.user.id, input.description, input.summary); // Chaque référence doit exister : sinon, un message précis plutôt qu'une erreur de clé étrangère. await assertDisciplineReferences(ctx.prisma, { categoryId: input.categoryId, instructorId: input.instructorId, familyId: input.familyId, originId: input.originId, });L’exemple inverse existe aussi. La création d’un membre (user.create) envoie elle-même l’e-mail de bienvenue, puis prévient la boîte du club. Tant qu’elle est le seul chemin par lequel un membre est créé, c’est acceptable. Le jour où un script devra créer des membres en masse, cette logique devra descendre dans un service ; sinon, les membres créés par le script ne recevraient rien, sans que personne s’en aperçoive.
💥 Ce que tRPC apporte vraiment#
Il faut être précis ici, parce qu’il y a souvent une incompréhension.
👉 tRPC ne remplace pas le backend
tRPC ne contient pas notre logique métier. Il ne gère pas nos règles d’autorisation complexes. Il ne persiste pas nos données.
👉 Il organise :
- le flux (comment une requête traverse le système)
- le typage (comment les types circulent sans rupture)
- l’injection du contexte (comment chaque procédure hérite d’un environnement)
tRPC est une couche d’orchestration. Comme un chef d’orchestre, il ne joue pas lui-même les instruments. Il s’assure que chaque musicien entre au bon moment, dans la bonne tonalité.
tRPC est une couche d’orchestration.
Le cœur métier reste dans les services.
🎯 Conclusion#
Nous avons maintenant une vision complète :
- requête → contexte
- intention → procédure
- sécurité → guard
- logique → service
- données → Prisma
👉 chaque responsabilité est à sa place
🚀 Nous obtenons une architecture backend claire, testable et scalable.
Et surtout : lisible. Un développeur qui rejoint le projet peut ouvrir un router et comprendre, en quelques minutes, ce que fait chaque procédure, ce qui la protège, et où chercher la logique métier associée.
👉 Où nous en sommes : nous avons les quatre couches — procédure, guard, service, Prisma — et nous savons pourquoi chacune doit rester à sa place. Reste à apprendre comment les composer dans l’écriture concrète d’une procédure : dans quel ordre, avec quelles contraintes, et surtout, comment faire en sorte que cette composition se lise d’un seul regard.
À suivre#
Nous avons les quatre couches — procédure, garde, service, Prisma — et nous savons pourquoi chacune doit rester à sa place. Reste à apprendre à les composer dans l’écriture concrète d’une procédure.
Chapitre suivant : fabriquer une procédure. La forme canonique, l’ordre de construction, query ou mutation, puis trois exemples réels : une procédure publique, une procédure protégée, une écriture complète.