Fastify 5 — Deep Dive senior
TL;DR — Fastify 5 (stable depuis fin 2024) est le framework Node le plus rapide de l'écosystème mainstream et le plus rigoureux d'un point de vue architecture. Sa philosophie : schema-driven (JSON Schema partout, validation via ajv, sérialisation compilée), encapsulation (les plugins sont des scopes isolés, comme un système de modules), hooks granulaires (10+ points d'extension par requête), TypeScript-friendly sans renier le JS pur. En benchmarks indépendants Fastify gère 50-80k req/s/instance là où Express en fait 15-25k. Cette page couvre l'architecture interne (encapsulation, decorators), tous les hooks du cycle de vie, les patterns de plugins, la validation/serialization, l'écosystème (swagger, cookie, static, jwt), et la comparaison avec NestJS (qui peut utiliser Fastify comme adapter HTTP). On finit sur quand choisir Fastify et quand préférer Hono ou Express.
🧠 Mental model — ASCII + analogie
Fastify n'est pas un pipeline linéaire à la Express. C'est une machine d'état à 10 phases par requête, où chaque phase a un hook nommé. À l'intérieur d'une phase, les middlewares (ou plutôt les "hooks") s'exécutent dans l'ordre de leur enregistrement, mais scopés par plugin grâce à l'encapsulation.
HTTP request
│
▼
┌────────────────────────────────────────────────────────────────┐
│ onRequest ← avant tout, req brut, pas de body parsé │
│ │ │
│ ▼ │
│ preParsing ← peut transformer le stream du body │
│ │ │
│ ▼ │
│ ─── PARSING ─── (body parser, content-type) │
│ │ │
│ ▼ │
│ preValidation ← body parsé, schema non encore validé │
│ │ │
│ ▼ │
│ ─── VALIDATION ─── (ajv compile + run) │
│ │ │
│ ▼ │
│ preHandler ← validation OK, juste avant le handler │
│ │ │
│ ▼ │
│ ─── HANDLER ─── (la route) │
│ │ │
│ ▼ │
│ preSerialization ← payload prêt, sérialisation pas encore faite│
│ │ │
│ ▼ │
│ ─── SERIALIZATION ─── (schema → JSON via fast-json-stringify) │
│ │ │
│ ▼ │
│ onSend ← stringifié, peut modifier le payload final │
│ │ │
│ ▼ │
│ onResponse ← après envoi, logging, métriques │
│ │ │
│ ▼ │
│ onError (si throw quelque part) / onTimeout │
└────────────────────────────────────────────────────────────────┘Analogie : Fastify est une chaîne d'assemblage Toyota avec contrôle qualité par étape, alors qu'Express est la même chaîne sans aucun gardien. Chaque hook est une porte de contrôle qualité ; le schéma JSON est le cahier des charges affiché à chaque poste. Le préparateur (fast-json-stringify) ne réécrit pas la même boîte en carton à chaque fois, il a un moule préfabriqué.
L'autre métaphore : Fastify avec ses plugins est un système de modules à la OSGi / monorepo. Chaque plugin a son propre conteneur (encapsulation), ses propres décorateurs, ses propres hooks. Si tu déclares un hook dans un plugin, il ne s'applique qu'aux routes de ce plugin (et de ses enfants), pas à toute l'app. Cette propriété est unique parmi les frameworks Node.
🛠️ Code minimal (ts)
// app.ts
import Fastify from 'fastify'
import { Type, type Static } from '@sinclair/typebox'
import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox'
const app = Fastify({ logger: { level: 'info' } }).withTypeProvider<TypeBoxTypeProvider>()
// 1) Schémas réutilisables
const UserBody = Type.Object({
email: Type.String({ format: 'email' }),
name: Type.String({ minLength: 1, maxLength: 100 }),
})
const UserReply = Type.Object({
id: Type.String(),
email: Type.String(),
name: Type.String(),
createdAt: Type.String({ format: 'date-time' }),
})
// 2) Hooks globaux
app.addHook('onRequest', async (req) => {
req.log.info({ method: req.method, url: req.url }, 'incoming')
})
app.addHook('onResponse', async (req, reply) => {
req.log.info({ status: reply.statusCode, ms: reply.elapsedTime }, 'done')
})
// 3) Plugin auth — scope isolé
async function authPlugin(scope: typeof app) {
scope.decorate('verifyJwt', (token: string) => {
return { sub: '...', roles: ['user'] }
})
scope.decorateRequest('user', null)
scope.addHook('preHandler', async (req, reply) => {
const token = req.headers.authorization?.replace(/^Bearer /, '')
if (!token) return reply.code(401).send({ error: 'missing token' })
try { req.user = scope.verifyJwt(token) }
catch { return reply.code(401).send({ error: 'invalid token' }) }
})
}
// 4) Routes — schema-driven
app.register(async (api) => {
await api.register(authPlugin) // s'applique uniquement aux routes de ce scope
api.post('/users', {
schema: { body: UserBody, response: { 201: UserReply } },
handler: async (req, reply) => {
const user = await createUser(req.body) // req.body typé via TypeBox
reply.code(201).send(user)
},
})
api.get('/users/:id', {
schema: {
params: Type.Object({ id: Type.String() }),
response: { 200: UserReply, 404: Type.Object({ error: Type.String() }) },
},
handler: async (req, reply) => {
const user = await getUser(req.params.id)
if (!user) return reply.code(404).send({ error: 'not found' })
return user
},
})
}, { prefix: '/api/v1' })
// 5) Error handler global — envelope unifié
app.setErrorHandler((err, req, reply) => {
if (err.validation) {
return reply.code(400).send({ error: { code: 'VALIDATION', details: err.validation } })
}
req.log.error(err)
reply.code(err.statusCode ?? 500).send({ error: { code: 'INTERNAL', message: err.message } })
})
app.listen({ port: 3000, host: '0.0.0.0' })Quelques observations clés :
withTypeProvider<TypeBoxTypeProvider>()connecte Fastify à TypeBox pour quereq.body,req.params, etc. soient automatiquement typés à partir des schémas. Pas de double-déclaration TS/JSON Schema.- Le plugin
authPluginest isolé. Le hookpreHandlerqu'il enregistre ne s'applique qu'aux routes définies dans leapiscope. C'est l'encapsulation en action. - Le
schema.response[201]n'est pas juste de la doc : Fastify compile un sérialiseur sur-mesure viafast-json-stringify, ce qui est 10-20× plus rapide queJSON.stringifygénérique. C'est une des sources majeures du gain de perf.
🎯 Patterns courants
Pattern 1 — Schemas réutilisables et partagés
Stocker tous les schémas dans src/schemas/ et les enregistrer via app.addSchema() permet de les référencer par $id partout :
app.addSchema({ $id: 'User', type: 'object', properties: { /* ... */ }, required: ['email'] })
app.post('/users', {
schema: { body: { $ref: 'User#' }, response: { 201: { $ref: 'User#' } } },
handler: async (req) => createUser(req.body),
})Pratique pour grosse API où le même User est utilisé dans 30 endpoints.
Pattern 2 — Encapsulation pour isoler les middlewares
// admin/ — auth obligatoire, role admin
app.register(async (admin) => {
admin.addHook('preHandler', requireAdminRole)
admin.get('/dashboard', handler)
admin.get('/users', handler)
})
// public/ — pas d'auth
app.register(async (pub) => {
pub.get('/health', () => ({ status: 'ok' }))
})Sans encapsulation (Express style), tu devrais coller requireAdminRole sur chaque route admin. Avec Fastify, tu le déclares une fois par scope et c'est tout.
Pattern 3 — Decorators pour injection légère
app.decorate('db', dbInstance) rend app.db accessible partout. app.decorateRequest('user', null) ajoute un slot req.user (faster qu'req.user = ... dynamique car la V8 connaît la shape de l'objet).
app.decorate('db', await createDbClient())
app.get('/users', async () => app.db.users.findAll())Pour de la DI plus structurée (graphe de dépendances), regarder awilix ou tsyringe. Fastify reste léger : les decorators sont juste des helpers, pas un conteneur IoC complet.
Pattern 4 — Validation et sérialisation séparées
Important : schema.body valide l'entrée, schema.response sérialise la sortie. Le second n'est PAS une validation : Fastify fait confiance à ton handler et applique le sérialiseur préfabriqué. Si tu retournes un champ non listé dans le schéma de réponse, il est silencieusement omis. C'est une feature (filtre des secrets) et un piège (oubli d'un champ).
// Le password sera omis automatiquement si non listé dans la réponse
app.get('/me', {
schema: { response: { 200: Type.Object({ id: Type.String(), email: Type.String() }) } },
handler: async (req) => ({ id: '1', email: '[email protected]', password: 'secret' }),
})
// → { "id": "1", "email": "[email protected]" }Pattern 5 — Hooks d'observabilité
onRequest + onResponse permettent de mesurer chaque requête sans middleware tiers :
app.addHook('onRequest', async (req) => { req.startTime = process.hrtime.bigint() })
app.addHook('onResponse', async (req, reply) => {
const ns = Number(process.hrtime.bigint() - req.startTime)
// Fastify 5 : req.routeOptions.url remplace req.routerPath (déprécié).
// Utiliser le *pattern* de route (/users/:id) et non req.url (/users/42)
// pour éviter l'explosion de cardinalité des labels Prometheus.
metrics.histogram('http.duration_ms', ns / 1e6, { route: req.routeOptions.url, status: reply.statusCode })
})Fastify expose reply.elapsedTime qui calcule la même chose, mais la version manuelle te donne le contrôle sur process.hrtime.bigint() (nanoseconde, monotone).
Pattern 6 — Plugins comme units de feature
Convention senior : un plugin = un domaine fonctionnel. src/plugins/auth.ts, src/plugins/orders.ts, src/plugins/billing.ts. Chacun enregistre ses routes, ses hooks, ses decorators. L'app principale est juste un register() séquence.
// app.ts
const app = Fastify()
await app.register(import('./plugins/db'))
await app.register(import('./plugins/auth'))
await app.register(import('./plugins/users'))
await app.register(import('./plugins/orders'))Avec fastify-plugin wrapper, un plugin peut "casser" l'encapsulation et exposer ses decorators globalement (typiquement la connexion DB).
Pattern 7 — Génération OpenAPI gratuite via @fastify/swagger
import swagger from '@fastify/swagger'
import swaggerUI from '@fastify/swagger-ui'
await app.register(swagger, {
openapi: {
info: { title: 'My API', version: '1.0.0' },
servers: [{ url: 'https://api.example.com' }],
},
})
await app.register(swaggerUI, { routePrefix: '/docs' })Tous tes schémas JSON Schema (déjà déclarés sur tes routes) deviennent automatiquement une spec OpenAPI 3 complète, navigable à /docs. Aucune duplication de doc à maintenir. C'est probablement le meilleur ROI de l'écosystème Node : tu écris ton schéma une fois, tu récupères validation + serialization + OpenAPI + UI Swagger gratuitement.
Pattern 8 — Transactions DB via hooks
app.addHook('preHandler', async (req) => {
req.tx = await db.transaction()
})
app.addHook('onSend', async (req) => {
if (req.tx) await req.tx.commit()
})
app.addHook('onError', async (req, _reply, _err) => {
if (req.tx) await req.tx.rollback()
})Une transaction DB par requête, ouverte avant le handler, commit en cas de succès, rollback en cas d'erreur. Combiné avec decorateRequest('tx', null), tu obtiens un pattern "unit of work" propre.
Pattern 9 — Subscriptions WebSocket / SSE
@fastify/websocket pour WS, ou route SSE manuelle :
app.get('/events', (req, reply) => {
reply.hijack() // on prend la main sur la socket : Fastify n'enverra pas de réponse
reply.raw.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
})
const interval = setInterval(() => reply.raw.write(`data: ${Date.now()}\n\n`), 1000)
req.raw.on('close', () => clearInterval(interval))
})🔄 Versions — Fastify 3 → 4 → 5, compat Node
Historique compact
- Fastify 3 (2020) — première version réellement mature, TS officialisé.
- Fastify 4 (2022) — passage à
ajv 8, retrait de certaines API legacy, support Node 14+. - Fastify 5 (octobre 2024, stable en 2026) — drop Node 18 (target 20+), HTTP/2 amélioré, refactor interne du router, breaking sur les types TS.
Compatibilité Node
Fastify 5 supporte officiellement Node 20, 22, 24. Pas de Node 18 (LTS sortant). En 2026, l'écrasante majorité des stacks tournent sur Node 22 LTS ou Node 24.
Breaking changes 4 → 5
- Drop du support Node 18 (EOL avril 2025).
request.routerPath→request.routeOptions.url(l'ancien est déprécié).- Types TS plus stricts — certaines signatures hook ont changé.
reply.getResponseTime()est retiré au profit dereply.elapsedTime(ms,number).request.contextretiré (utiliser decorators).
Migration en pratique : npm i fastify@5, lancer le compilo TS, fixer les erreurs (rares, 30 minutes pour une app moyenne).
Plugins majeurs et versions
| Plugin | Rôle | Version 2026 |
|---|---|---|
@fastify/cors | CORS | 10.x |
@fastify/cookie | parser cookies, signed cookies | 10.x |
@fastify/jwt | auth JWT | 9.x |
@fastify/static | servir fichiers statiques | 8.x |
@fastify/multipart | upload fichiers | 9.x |
@fastify/swagger | génération OpenAPI à partir des schemas | 9.x |
@fastify/swagger-ui | UI Swagger | 5.x |
@fastify/websocket | WS | 11.x |
@fastify/rate-limit | throttling | 10.x |
@fastify/helmet | headers sécurité | 12.x |
⚠️ Pitfalls — 9 erreurs classiques
1. Confondre validation et sérialisation
schema.body valide → throw si KO. schema.response sérialise → filtre silencieusement. Beaucoup de devs débutants ajoutent un champ à un objet retourné et ne comprennent pas pourquoi il n'apparaît pas dans la réponse. C'est le schéma de réponse qui filtre.
2. Hook async qui ne retourne rien et ne call pas reply
app.addHook('preHandler', async (req, reply) => {
if (!req.user) reply.code(401).send({ error: 'unauth' }) // ← oublié return
// continue vers le handler → "headers already sent"
})Solution : return reply.code(401).send(...). Le return court-circuite la chaîne.
3. Encapsulation cassée par fastify-plugin
import fp from 'fastify-plugin'
export default fp(async (app) => {
app.decorate('db', dbClient) // disponible globalement, pas seulement dans le scope
})fastify-plugin "perce" l'encapsulation, ce qui est voulu pour des plugins transverses (db, logger) mais piégeux si appliqué partout. Règle : utiliser fastify-plugin uniquement quand le decorator/hook doit fuiter vers le parent.
4. Schémas mal référencés via $ref
app.addSchema({ $id: 'User', ... })
app.post('/u', { schema: { body: { $ref: 'User' } } }) // KO, il faut 'User#'Le # à la fin pointe vers la racine du schéma. Erreur classique au début.
5. request.body typé unknown sans TypeProvider
Sans withTypeProvider<...>(), req.body est unknown et tu dois caster ou re-déclarer les types via RouteShorthandOptions<...>. C'est verbeux. Recommandation : utiliser TypeBox ou JSON Schema avec type-provider-json-schema-to-ts.
6. ajv strict mode
Par défaut Fastify utilise ajv en mode strict, ce qui rejette les schémas avec des keywords inconnus ou des additionalProperties non spécifiés. Si tu importes un schéma OpenAPI qui contient nullable: true (OpenAPI 3.0, pas JSON Schema), il faut configurer ajv :
const app = Fastify({ ajv: { customOptions: { strict: false, keywords: ['nullable'] } } })7. app.listen({ port }) sans host
app.listen({ port: 3000 }) // bind sur 127.0.0.1 par défautEn Docker, ça ne fonctionne pas (le container n'écoute pas sur l'interface externe). Toujours host: '0.0.0.0' en prod containerisée.
8. Logger Pino mal configuré
Fastify utilise Pino. En dev, on veut du pretty-print ; en prod, du JSON pur pour les ELK / Datadog :
const app = Fastify({
logger: process.env.NODE_ENV === 'production'
? { level: 'info' }
: { level: 'debug', transport: { target: 'pino-pretty' } },
})9. Hooks onRequest vs preHandler pour l'auth
onRequest s'exécute avant le parsing du body. Si ton auth a besoin de lire le body (rare, mais possible avec auth signature), tu dois la mettre en preHandler. À l'inverse, pour rejeter les requêtes mal authentifiées le plus tôt possible (sans gaspiller le parsing), onRequest est mieux.
10. Réutiliser un schéma compilé par ajv
Si tu utilises un même schéma sur 30 routes via $ref, ajv le compile une seule fois (gain mémoire + perf). En revanche, si tu duplique le schéma inline dans chaque route, ajv compile 30 fois et alourdit la mémoire. Toujours préférer app.addSchema({ $id: '...' }) quand un schéma est partagé.
🏗️ Comment un staff engineer raisonne sur la perf de Fastify
La question naïve est « Fastify est rapide ». La question senior est « d'où vient exactement le gain, et où s'évapore-t-il en prod ? ». Décomposons.
D'où vient réellement la perf
| Source | Mécanisme | Gain typique vs Express |
|---|---|---|
| Sérialisation | fast-json-stringify compile une fonction String(obj) spécialisée par schéma (pas de typeof, pas d'introspection runtime) | 2-12× sur le JSON.stringify |
| Routing | router radix-tree (find-my-way) en O(longueur du path), pas de scan linéaire des routes | constant vs O(n routes) |
| Pas de middleware chain | les hooks sont pré-compilés en une fonction unique par route au boot, pas une Array.reduce à chaque requête | élimine l'overhead par-requête |
| Logger | Pino sérialise en JSON dans un worker/asynchrone, n'inline pas de util.format coûteux | 5× sur le logging |
decorateRequest | slot pré-déclaré → V8 garde une hidden class stable, pas de transition de shape par requête | micro mais cumulatif sous charge |
Le point clé que beaucoup ratent : le gain est concentré dans le framework, pas dans ton handler. Si ton endpoint fait un appel DB de 20 ms, le delta Express→Fastify de ~0.3 ms est dans le bruit. Fastify ne te rend rapide que si tu es déjà I/O-light (cache hits, payloads sérialisés massivement, fan-out de petits appels). Sur un endpoint dominé par une requête SQL lourde, choisis Fastify pour la rigueur des schémas, pas pour les req/s.
Le piège mental « 80k req/s »
Les benchmarks officiels mesurent un handler return { hello: 'world' } sans I/O. C'est un plafond théorique, pas ta prod. En prod réelle :
req/s réel ≈ (cores × 1000ms) / latence_handler_msAvec un handler à 20 ms (1 appel DB), 1 core fait ~50 req/s par I/O concurrent bloqué — mais Node étant non-bloquant, le throughput dépend du nombre de requêtes en vol et de la limite du pool DB, pas de Fastify. Le goulot d'étranglement n'est presque jamais Fastify : c'est la DB, le pool de connexions, ou le GC. Mesure-le avec clinic.js / 0x avant d'optimiser le mauvais étage.
Failure modes en production
- Event-loop lag : un handler qui fait du CPU synchrone (parse d'un gros CSV,
JSON.parsede 10 MB, crypto sync) bloque toutes les requêtes en vol. Symptôme : p50 OK mais p99 catastrophique. Diagnostic :perf_hooks.monitorEventLoopDelay()exposé en métrique. Remède :worker_threadsou offload. - Schéma de réponse trop permissif :
additionalProperties: true(ou pas deresponsedu tout) → retour àJSON.stringifygénérique, perte du gain de sérialisation et risque de fuite de champ. Audit : toute route critique DOIT avoir unschema.response. - ajv recompilation : schémas inline dupliqués → boot lent + RAM. Vu sur une app à 400 routes : 4s de boot ramenés à 0.6s en passant tout en
$ref. - Backpressure absente : un client lent + un gros payload streamé sans gérer
reply.raw→ mémoire qui gonfle.@fastify/staticet les streams gèrent ça ; le code manuel non. - Pino sous charge extrême : en
level: 'debug'avec un transport synchrone, le logging devient le goulot. En prod :level: 'info', transport asynchrone, et surtout jamais depino-pretty(c'est un fork de process).
Le diagramme de décision du sizing
Endpoint lent ?
├─ CPU-bound (parse/crypto/compute) ──► worker_threads / offload, PAS plus d'instances
├─ I/O-bound (DB/HTTP) ──────────────► augmenter pool DB + cache, puis scale horizontal
└─ GC pressure (allocs/req élevées) ─► réduire les allocations, schémas $ref, --max-old-spaceRègle staff : ne scale jamais horizontalement avant d'avoir profilé un seul handler. Ajouter des pods masque un event-loop lag, ça ne le corrige pas.
🧪 Testing
Test avec app.inject() (sans port)
import { test } from 'node:test'
import assert from 'node:assert'
import { buildApp } from './app'
test('POST /users creates a user', async () => {
const app = buildApp()
const res = await app.inject({
method: 'POST',
url: '/api/v1/users',
payload: { email: '[email protected]', name: 'Alice' },
})
assert.strictEqual(res.statusCode, 201)
const body = res.json()
assert.strictEqual(body.email, '[email protected]')
})app.inject() est la killer feature de Fastify pour les tests : aucun port ouvert, exécution in-process, 10× plus rapide que supertest. La fonction reproduit tout le cycle réel (hooks, validation, serialization), donc les tests sont fidèles à la prod.
Test d'erreur de validation
test('rejects bad email', async () => {
const app = buildApp()
const res = await app.inject({
method: 'POST',
url: '/api/v1/users',
payload: { email: 'nope', name: 'A' },
})
assert.strictEqual(res.statusCode, 400)
assert.match(res.json().error.code, /VALIDATION/)
})Tester un plugin isolé
test('auth plugin rejects missing token', async () => {
const app = Fastify()
await app.register(authPlugin)
app.get('/test', () => 'ok')
const res = await app.inject({ method: 'GET', url: '/test' })
assert.strictEqual(res.statusCode, 401)
})Pas besoin de monter toute l'app, on teste le plugin avec un Fastify minimal.
🎬 Cas d'usage concrets
Scénario 1 — API banque "NeoCrédit" haute perf, schemas JSON Schema partout
NeoCrédit est une néobanque française B2C avec 1.2M utilisateurs, qui traite 80k req/s en peak (consultation de solde, transferts SEPA instantanés, notifications push). Le backend monolithe Fastify 5 sert l'app mobile (iOS + Android) et l'app web. Contraintes : (1) latence p99 < 50 ms sur les endpoints critiques (/balance, /transactions) parce qu'un délai dégrade l'UX et augmente le churn ; (2) tous les payloads doivent être validés strictement (input et output) parce qu'une fuite de champ accidentelle (numéro de carte, IBAN complet) déclenche un incident RGPD ; (3) la spec OpenAPI doit être maintenue à jour pour les audits ACPR.
Le choix Fastify est venu de trois propriétés : fast-json-stringify (sérialisation 12× plus rapide que JSON.stringify sur les payloads de 100+ champs), schémas JSON Schema centralisés via app.addSchema({ $id: '...' }) (un seul fichier schemas/banking.ts référencé par 80 routes), et @fastify/swagger qui génère automatiquement la spec OpenAPI 3.1 depuis les schémas. Bénéfice mesuré : latence p99 à 28 ms sur /balance, doc OpenAPI toujours à jour sans effort, et zéro fuite de champ confirmée par audit (les password_hash, internal_score n'apparaissent jamais dans les réponses parce qu'ils ne sont pas listés dans schema.response).
Scénario 2 — E-commerce "MarketHub" avec validation stricte des schémas
MarketHub est un agrégateur e-commerce qui connecte 200 marketplaces (Amazon, Cdiscount, Fnac, Leroy Merlin) à des revendeurs B2B. L'API Fastify reçoit des milliers de webhooks de stock, prix et commandes par seconde, et republie via WebSocket aux clients connectés. Le défi : chaque marketplace envoie un format différent, et un payload mal formé qui passe en aval peut planter le calcul de stock global.
La stratégie : un schéma JSON Schema par marketplace + un schéma canonique interne. Chaque webhook entrant est validé par Fastify (schema.body + ajv strict), transformé via un mapper pur, et republié en interne. Le schema.response filtre les champs sensibles (prix d'achat fournisseur, marges). Le hook onError global push les payloads rejetés vers une queue Redis "dead letter" pour analyse manuelle. L'équipe utilise aussi @fastify/rate-limit avec une clé par marketplace (keyGenerator: req => req.headers['x-marketplace-id']) pour qu'Amazon ne sature pas la file quand il fait un re-sync massif. Résultat : 95k webhooks/s tenus sans loss, et débogage rapide via les schémas qui pointent précisément le champ en cause dans le payload rejeté.
Scénario 3 — ATS RH "TalentForge" et JSON Schema partagés front/back
TalentForge est un ATS (Applicant Tracking System) français qui gère le recrutement pour 400 entreprises. Le backend Fastify expose une API REST consommée par une SPA React (Vite + TanStack Query) et une app mobile iOS native. L'équipe (8 personnes) voulait du type-safe sur le front sans aller jusqu'à tRPC (parce que l'app iOS est en Swift et a besoin de l'API REST classique).
Solution senior : les schémas TypeBox sont définis dans un package partagé du monorepo (@talentforge/schemas). Le backend Fastify les utilise via withTypeProvider<TypeBoxTypeProvider>() (validation + types req.body automatiques), et le front React les consomme via Static<typeof CandidateSchema> (type-safe sans codegen). Pour l'iOS, @fastify/swagger génère l'OpenAPI qui alimente un codegen Swift via swagger-codegen-cli. Bénéfice : un seul fichier de vérité (schemas/candidate.ts), trois consommateurs (Fastify validation, React types, Swift codegen), et les renames se propagent automatiquement. L'équipe a tué 70 % des bugs "champ manquant" qu'ils avaient en Express+Joi.
🛠️ Exemple end-to-end
Cas d'usage : "endpoint POST /api/v1/transfers dans NeoCrédit qui crée un virement SEPA instantané, vérifie le solde, débite via le core banking interne, écrit dans le ledger, et publie un event Kafka — schemas TypeBox, plugin auth scopé, hook de transaction, et observabilité".
// src/plugins/transfers.ts
import type { FastifyPluginAsync } from 'fastify'
import { Type, type Static } from '@sinclair/typebox'
import { authPlugin } from './auth'
const TransferRequest = Type.Object({
fromAccountId: Type.String({ format: 'uuid' }),
toIban: Type.String({ pattern: '^FR\\d{25}$' }),
amount: Type.Number({ minimum: 0.01, maximum: 100000, multipleOf: 0.01 }),
currency: Type.Literal('EUR'),
reference: Type.String({ minLength: 1, maxLength: 140 }),
idempotencyKey: Type.String({ format: 'uuid' }),
})
const TransferResponse = Type.Object({
transferId: Type.String({ format: 'uuid' }),
status: Type.Union([Type.Literal('settled'), Type.Literal('pending')]),
executedAt: Type.String({ format: 'date-time' }),
newBalance: Type.Number(),
})
const TransferError = Type.Object({
error: Type.Object({
code: Type.Union([
Type.Literal('INSUFFICIENT_FUNDS'),
Type.Literal('IDEMPOTENT_REPLAY'),
Type.Literal('CORE_BANKING_UNAVAILABLE'),
]),
message: Type.String(),
}),
})
export const transfersPlugin: FastifyPluginAsync = async (scope) => {
await scope.register(authPlugin)
scope.addHook('preHandler', async (req) => {
req.tx = await scope.db.transaction()
})
scope.addHook('onSend', async (req) => {
if (req.tx && !req.tx.completed) await req.tx.commit()
})
scope.addHook('onError', async (req) => {
if (req.tx && !req.tx.completed) await req.tx.rollback()
})
scope.post('/transfers', {
schema: {
body: TransferRequest,
response: { 201: TransferResponse, 400: TransferError, 402: TransferError, 503: TransferError },
},
handler: async (req, reply) => {
const log = req.log.child({ userId: req.user.id, idem: req.body.idempotencyKey })
const cached = await scope.redis.get(`transfer:idem:${req.user.id}:${req.body.idempotencyKey}`)
if (cached) {
log.info('idempotent replay')
return reply.code(201).send(JSON.parse(cached))
}
const account = await scope.db.accounts.findOne(
{ id: req.body.fromAccountId, userId: req.user.id },
{ tx: req.tx, forUpdate: true }
)
if (!account) return reply.code(404).send({ error: { code: 'INSUFFICIENT_FUNDS', message: 'account not found' } })
if (account.balance < req.body.amount) {
return reply.code(402).send({ error: { code: 'INSUFFICIENT_FUNDS', message: 'balance too low' } })
}
const coreResult = await scope.coreBanking.executeSepaInstant({
fromIban: account.iban,
toIban: req.body.toIban,
amount: req.body.amount,
reference: req.body.reference,
idempotencyKey: req.body.idempotencyKey,
})
if (coreResult.status === 'rejected') {
return reply.code(503).send({ error: { code: 'CORE_BANKING_UNAVAILABLE', message: coreResult.reason } })
}
const transfer = await scope.db.transfers.insert({
userId: req.user.id,
fromAccountId: account.id,
toIban: req.body.toIban,
amount: req.body.amount,
coreBankingRef: coreResult.transactionId,
status: coreResult.settled ? 'settled' : 'pending',
}, { tx: req.tx })
const newBalance = account.balance - req.body.amount
await scope.db.accounts.update({ id: account.id }, { balance: newBalance }, { tx: req.tx })
await scope.kafka.produce('banking.transfers.created', {
transferId: transfer.id,
userId: req.user.id,
amount: req.body.amount,
})
const payload = {
transferId: transfer.id,
status: transfer.status,
executedAt: transfer.createdAt.toISOString(),
newBalance,
}
await scope.redis.setex(`transfer:idem:${req.user.id}:${req.body.idempotencyKey}`, 86400, JSON.stringify(payload))
log.info({ transferId: transfer.id, amount: req.body.amount }, 'transfer settled')
return reply.code(201).send(payload)
},
})
}Cet endpoint combine huit patterns Fastify seniors : (1) schémas TypeBox typés (req.body est inféré sans cast), (2) response avec union de status codes pour OpenAPI complet, (3) plugin auth scopé via encapsulation (l'auth ne s'applique qu'aux routes de ce scope), (4) hook de transaction preHandler + onSend + onError (commit automatique en succès, rollback en erreur), (5) idempotency Redis 24h (un retry réseau ne double pas le virement), (6) forUpdate: true sur la lecture du compte (verrou pessimiste pour éviter les race conditions de solde concurrent), (7) appel core banking idempotent (clé propagée), (8) event Kafka publié dans la même transaction logique. Latence p99 mesurée à 35 ms en prod NeoCrédit, en pic à 80k req/s tenu sans dégradation.
🏋️ Exercices
Progression : implémenter → durcir pour la prod → casser puis réparer. Chaque exercice se teste avec app.inject() (aucun port à ouvrir).
Exercice 1 — Encapsulation : prouver l'isolation (implémenter)
Objectif : démontrer qu'un hook déclaré dans un plugin enfant ne fuite pas vers le parent ni vers un plugin frère.
Crée une app avec deux plugins frères a et b enregistrés via app.register(). Dans a, ajoute un preHandler qui set req.headers['x-touched'] = 'a'. Chaque plugin expose GET /ping. Vérifie par injection que la route de a voit le hook et que celle de b ne le voit pas. Puis déclare le même hook au niveau racine et montre qu'il touche les deux.
Indice / Solution
L'ordre d'enregistrement compte : un hook ne s'applique qu'aux routes enregistrées après lui dans le même scope ou un scope enfant. Wrappe chaque domaine dans app.register(async (scope) => { scope.addHook(...); scope.get('/ping', ...) }). Le test de b doit asserter que res.json().touched === undefined. Pour faire fuiter volontairement, enveloppe a avec fastify-plugin (fp) et observe que le hook remonte au parent : c'est exactement le mécanisme qui rend @fastify/jwt ou la connexion DB disponibles globalement.
Exercice 2 — Idempotency middleware réutilisable (production-grade)
Objectif : transformer le pattern idempotency de l'exemple NeoCrédit en plugin générique réutilisable, correct sous concurrence.
Écris un plugin idempotencyPlugin qui : lit le header Idempotency-Key, et si une réponse a déjà été produite pour cette clé (clé = userId + key), renvoie la réponse cachée sans exécuter le handler. La V1 naïve a une race condition : deux requêtes simultanées avec la même clé exécutent toutes les deux le handler. Corrige avec un verrou (Redis SET NX avec TTL, ou SETNX + poll). Le cache doit stocker status + body, pas juste le body.
Indice / Solution
Hook preHandler pour le check + le lock (SET key "in-flight" NX EX 30). Si le SET NX échoue, soit poll jusqu'à voir la réponse finale, soit renvoyer 409 Conflict (« requête en cours »). Hook onSend pour persister { statusCode, payload } avec un TTL de 24h, puis libérer/écraser le lock. Piège senior : ne jamais mettre en cache les réponses 5xx (sinon un échec transitoire devient permanent). Sérialise le tuple (status, body) ; au replay, fais reply.code(cached.status).send(cached.body). Décore la requête avec decorateRequest('idempotencyKey', null) pour la hidden class.
Exercice 3 — Rate limiter par tenant avec fenêtre glissante (production-grade)
Objectif : implémenter un rate limit sliding-window par clé de tenant, sans dépendre de @fastify/rate-limit, en O(1) Redis.
Limite à N requêtes par fenêtre de 60s, clé = req.headers['x-tenant-id']. Renvoie 429 avec les headers standard Retry-After, RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset. Le fixed-window naïf laisse passer 2N requêtes au tournant de la fenêtre (burst) : implémente un sliding-window log ou un sliding-window counter.
Indice / Solution
Sliding-window log : un ZSET Redis par tenant, score = timestamp ms. À chaque requête : ZREMRANGEBYSCORE key 0 (now-60000) (purge), ZCARD (compte), si < N alors ZADD now now + EXPIRE 60, sinon 429. Tout ça dans un script Lua pour l'atomicité (sinon race entre ZCARD et ZADD). Mets-le en onRequest (rejet le plus tôt, avant le parsing du body — économise du CPU sous attaque). RateLimit-Reset = ceil((plus_vieux_score + 60000 - now)/1000). Variante counter (moins de mémoire) : deux buckets pondérés par le chevauchement de fenêtre.
Exercice 4 — Casser puis réparer : la fuite de champ silencieuse (break-then-fix)
Objectif : reproduire un incident RGPD-like puis le rendre impossible structurellement.
Pars d'une route GET /me qui retourne { id, email, passwordHash, internalRiskScore } sans schema.response. Écris un test qui prouve que passwordHash fuite dans la réponse. Puis ajoute un schema.response[200] qui ne liste que id et email, et montre que la fuite disparaît. Bonus staff : écris un test de garde-fou qui parcourt toutes les routes de l'app (app.routes / hook onRoute) et échoue le build si une route 2xx n'a pas de schema.response.
Indice / Solution
fast-json-stringify n'émet que les properties déclarées : tout champ non listé est omis, sans erreur. C'est une feature de sécurité — mais elle ne protège que si le schéma existe. Le garde-fou : hook global app.addHook('onRoute', (route) => { if (!route.schema?.response?.[200] && route.method.includes('GET')) throw new Error(...) }), exécuté au boot, qui transforme « un dev a oublié un schéma » en échec CI. C'est la différence entre « on fait attention » et « le système empêche l'erreur ». Attention : additionalProperties: true dans le schéma de réponse ré-ouvre la fuite — interdis-le aussi.
Exercice 5 — Event-loop lag sous charge CPU (break-then-fix)
Objectif : observer puis éliminer un blocage de l'event loop causé par un handler CPU-bound.
Crée une route POST /hash qui fait un crypto.pbkdf2Sync(body.password, salt, 600000, 64, 'sha512') (synchrone, ~80 ms). Lance 50 requêtes concurrentes vers cette route et vers un /health trivial. Mesure la p99 de /health : elle explose parce que pbkdf2Sync bloque le thread. Répare en passant à crypto.pbkdf2 (async, callback → libuv threadpool) ou un worker_threads pool. Re-mesure.
Indice / Solution
*Sync = thread principal bloqué = toutes les requêtes en vol gelées. Symptôme classique : p50 stable, p99 catastrophique, event-loop lag (perf_hooks.monitorEventLoopDelay()) qui grimpe. La version async de pbkdf2 délègue au threadpool libuv (taille via UV_THREADPOOL_SIZE, défaut 4) — augmente-le si le hash est le pattern dominant. Vérifie aussi que le pool libuv n'est pas saturé par d'autres opérations (DNS, fs). Pour du compute pur arbitraire (parsing, image), worker_threads + un pool (piscina) est le bon outil ; libuv ne sert que pour les primitives Node qui l'utilisent déjà.
Exercice 6 — TypeProvider strict + schémas partagés $ref (architecte)
Objectif : un seul schéma source de vérité, typé end-to-end, référencé sans recompilation ajv.
Définis User une seule fois en TypeBox, enregistre-le via app.addSchema({ $id: 'User', ...Type... }), et utilise-le par $ref dans 3 routes (POST, GET liste, GET par id) tout en gardant l'inférence de types sur req.body/reply. Prouve par un benchmark de boot que la version $ref compile le schéma une fois, contre 3 fois pour la version inline.
Indice / Solution
Le frottement réel : $ref est dynamique côté ajv, donc l'inférence TS via withTypeProvider<TypeBoxTypeProvider>() ne « voit » pas toujours le type derrière la $id string. Pattern senior : garde la constante TypeBox en mémoire pour le typage (Static<typeof User> partout) et enregistre la même valeur via addSchema avec un $id pour le partage runtime — tu typés depuis la constante, tu références depuis le $id. Pour le benchmark, instrumente le hook onReady ou compte les appels à ajv.compile (monkey-patch en test). Le gain mémoire/boot devient mesurable au-delà de ~50 routes partageant le même schéma.
🎤 En entretien
Q : Pourquoi Fastify est-il « plus rapide » qu'Express, concrètement ? Cite deux mécanismes. R : (1)
fast-json-stringifycompile au boot une fonction de sérialisation spécialisée par schéma de réponse, qui évite l'introspection runtime deJSON.stringify(2-12× sur les gros payloads). (2) Les hooks sont pré-assemblés en une fonction unique par route, pas une chaîne de middlewares parcourue à chaque requête, et le routing est un radix-tree O(longueur du path) au lieu d'un scan linéaire. Le piège à mentionner : ce gain est dans le framework, pas dans ton handler — sur un endpoint dominé par une requête DB, le delta est dans le bruit.
Q : Quelle est la différence entre
schema.bodyetschema.response? Quelle conséquence de sécurité ? R :bodyvalide l'entrée via ajv et throw une 400 si invalide.responsen'est pas une validation : il compile un sérialiseur qui n'émet que les champs déclarés — tout champ non listé (password, score interne) est silencieusement omis. Conséquence : une whitelist de sortie gratuite contre les fuites de données — mais seulement si la route a effectivement unschema.response. Un staff engineer ajoute un garde-fouonRoutequi échoue le build si une route 2xx n'en a pas.
Q : C'est quoi l'encapsulation Fastify, et quand la « casse-t-on » volontairement ? R : Chaque
register()crée un scope enfant isolé : les hooks, decorators et schémas déclarés dedans ne s'appliquent qu'à ce scope et ses enfants, jamais au parent ni aux frères. C'est unique dans l'écosystème Node (ni Express ni Koa ne l'ont nativement). On la casse volontairement avecfastify-plugin(fp) pour les concerns transverses qui doivent fuiter globalement : connexion DB, logger, plugin JWT. Règle : encapsulation par défaut,fpseulement pour ce qui est légitimement partagé.
Q : Un endpoint a une p50 stable mais une p99 qui explose sous charge. Hypothèse no 1 ? R : Blocage de l'event loop par du CPU synchrone dans un handler (
*Synccrypto,JSON.parsed'un gros payload, regex catastrophique, boucle lourde). Comme Node est mono-thread, un handler qui bloque gèle toutes les requêtes en vol → la queue d'attente fait exploser la p99 pendant que la p50 reste bonne tant que la charge est faible. Diagnostic :perf_hooks.monitorEventLoopDelay()en métrique, profilingclinic flame/0x. Remède : async natif (threadpool libuv) ouworker_threads. Surtout pas « ajouter des pods » — ça masque sans corriger.
🔁 Quand utiliser / éviter
Utiliser Fastify
- Greenfield à haute perf : 30-80k req/s par instance hors I/O lourde.
- API REST/JSON avec contrats stricts : la combo schéma JSON + validation + serialization compilée + génération OpenAPI gratuite est imbattable.
- Equipe TypeScript : les TypeProviders TypeBox/json-schema-to-ts donnent une expérience proche de tRPC sans changer de paradigme.
- App modulaire : l'encapsulation des plugins remplace souvent un système de modules custom.
- Migration depuis Express : la sémantique est proche, la courbe d'apprentissage faible, le gain de perf 2-3×.
Éviter Fastify
- Edge/Workers : Fastify dépend de l'API HTTP Node, pas portable sur Cloudflare Workers ou Deno Deploy. Va sur Hono.
- App avec énormément de hooks DI complexes : NestJS apporte un vrai conteneur IoC + decorators. Si ton domaine fait 50+ services interconnectés, Nest passe mieux à l'échelle équipe.
- Stack legacy Express : ne migre pas pour migrer. Coût > gain si l'app est stable.
Fastify vs NestJS
Nest peut utiliser Fastify comme adapter HTTP (@nestjs/platform-fastify). Tu récupères 80 % du gain de perf de Fastify tout en gardant l'architecture Nest (modules, providers, decorators). Le tradeoff :
- Nest + Express adapter : par défaut, écosystème Express, ~25k req/s.
- Nest + Fastify adapter : ~50k req/s, mais certains middlewares Express sont incompatibles.
- Fastify pur : ~80k req/s, structure de plugins manuelle.
Recommandation senior : équipes qui aiment les decorators et la DI → Nest+Fastify ; équipes qui veulent moins de magie → Fastify pur.
Match-up rapide
| Critère | Fastify | Express | Hono | Nest |
|---|---|---|---|---|
| Perf | 80k req/s | 25k | 100k (edge) | 25-50k |
| Schéma natif | oui (JSON Schema + ajv) | non | non (Zod possible) | partiel (class-validator) |
| Encapsulation | oui (plugins) | partiel (routers) | partiel (sub-app) | total (modules) |
| Edge | non | non | oui | non |
| Type-safety | très bon (TypeProvider) | moyen | bon | excellent |
| Courbe | faible | très faible | très faible | élevée |
Pattern de déploiement prod
Fastify se déploie comme n'importe quel serveur Node : derrière un load balancer (NGINX, ALB, Cloudflare) qui termine TLS et fait du round-robin sur N instances. Sous PM2, Kubernetes, ou Fly.io. Pour un sizing typique :
- 1 instance Fastify sur 1 vCPU + 512 MB RAM → 30-50k req/s sur des handlers I/O-light.
- Horizontal scaling : ajouter des instances jusqu'à ce que la DB devienne le goulot.
- Couche cache (Redis, KV) devant Fastify pour les lectures fréquentes.
Important : Node est single-threaded. Pour utiliser plusieurs cores sur une seule machine, soit cluster (Node natif), soit lancer N processus indépendants derrière un LB local. PM2 fait ça en mode cluster.
Roadmap probable
Fastify 5 est sorti fin 2024 et reste la version stable en 2026. La v6 n'est pas annoncée. Les directions probables : meilleure intégration des Web Standards (vers une portabilité partielle edge), support encore meilleur de TypeBox v0.34, OpenAPI 3.1 par défaut au lieu de 3.0.
🔗 Liens
- Site officiel : https://fastify.dev
- Plugins officiels : https://fastify.dev/ecosystem/
- Benchmarks : https://fastify.dev/benchmarks/
- TypeBox : https://github.com/sinclairzx81/typebox
@fastify/type-provider-typebox: https://github.com/fastify/fastify-type-provider-typeboxjson-schema-to-ts: https://github.com/ThomasAribart/json-schema-to-ts- NestJS + Fastify adapter : https://docs.nestjs.com/techniques/performance
- Migration v4 → v5 : https://fastify.dev/docs/latest/Guides/Migration-Guide-V5/
- Conférence "Fastify under the hood" Tomas Della Vedova : https://www.youtube.com/results?search_query=fastify+under+the+hood
Récap final. Fastify 5 est le framework Node senior par excellence en 2026 : perf 3-4× supérieure à Express grâce à fast-json-stringify et au router optimisé ; schémas JSON partout pour validation, serialization, et génération OpenAPI gratuite ; encapsulation des plugins qui scope hooks et decorators (unique dans l'écosystème Node) ; types TypeScript inférés via TypeProvider. Le cycle de vie à 10 hooks permet d'instrumenter chaque phase de la requête. L'écosystème (cors, jwt, cookie, multipart, static, swagger, websocket) est complet et maintenu par la core team. À éviter pour les déploiements edge (Hono) et les apps à très lourde DI (NestJS). Pour tout le reste — API REST/JSON modernes, microservices, BFF, backends de SaaS — Fastify est aujourd'hui le choix par défaut quand on commence un projet à zéro et qu'on prend la perf et la rigueur au sérieux.