Why Fastify Needs Rules That Prevent Express Patterns
Fastify is designed around three principles that Express doesn't have: schema-based validation and serialization (JSON Schema on every route), plugin-based encapsulation (isolated contexts for each feature), and hooks instead of middleware (lifecycle-aware, typed, ordered). AI assistants generate Express code in Fastify projects because Express dominates their training data — the result works but misses every performance and type-safety benefit Fastify provides.
The most common AI failures: no JSON Schema on routes (skipping Fastify's fast validation and serialization), using Express-style middleware instead of Fastify hooks, importing plugins without encapsulation (leaking decorators and services globally), using res.send() instead of returning values (bypassing serialization), and generating callback-style code instead of async/await.
Fastify routes with JSON Schema are 2-4x faster than equivalent Express routes because Fastify compiles schemas to serialization functions at startup. AI code without schemas gives up this performance advantage while adding no benefit.
Rule 1: JSON Schema on Every Route
The rule: 'Define JSON Schema for body, querystring, params, headers, and response on every route. Fastify validates input and serializes output using these schemas — providing both type safety and performance. Use the schema option in route definitions: { schema: { body: { type: "object", properties: { name: { type: "string" } }, required: ["name"] }, response: { 200: { ... } } } }.'
For TypeProvider: 'Use @fastify/type-provider-typebox or @fastify/type-provider-zod for typed schemas. TypeBox provides JSON Schema compatible types: Type.Object({ name: Type.String() }). Zod provider uses Zod schemas and converts to JSON Schema. Both give you runtime validation + TypeScript types from a single schema definition.'
AI generates routes without schemas — req.body is unvalidated and untyped. One rule (schema required on every route) gives you: input validation (automatic 400 on invalid data), response serialization (2-4x faster than JSON.stringify), and TypeScript types (inferred from schema). Three benefits from one pattern.
- JSON Schema on every route: body, querystring, params, response
- TypeBox or Zod TypeProvider for typed schemas — single source of truth
- Automatic validation: 400 on invalid input — no manual checking
- Fast serialization: compiled from schema at startup — 2-4x vs JSON.stringify
- TypeScript types inferred from schema — no separate interface definitions
JSON Schema on a route gives you: automatic input validation (400 on bad data), fast serialization (2-4x vs JSON.stringify), and TypeScript types (inferred from schema). One schema definition, three wins.
Rule 2: Plugin-Based Encapsulation
The rule: 'Organize code as Fastify plugins. Each feature is a plugin: export default async function usersPlugin(fastify: FastifyInstance) { ... }. Register with fastify.register(usersPlugin, { prefix: "/users" }). Plugins create isolated contexts — decorators and hooks in one plugin don't leak to siblings. Use fastify-plugin only when a plugin genuinely needs to share context with its parent.'
For dependency injection: 'Use fastify.decorate() to attach services to the Fastify instance: fastify.decorate("db", databaseClient). Access in route handlers: fastify.db. Decorators added in a plugin are available to that plugin's children but not to siblings. This is Fastify's DI mechanism — no IoC container needed.'
For registration order: 'Register plugins in dependency order: database plugin first, then auth plugin (needs db), then route plugins (need auth + db). Use fastify-plugin sparingly — every use breaks encapsulation. The default isolation is the correct pattern for 90% of plugins.'
Rule 3: Hooks, Not Middleware
The rule: 'Use Fastify hooks instead of Express-style middleware. onRequest for authentication. preValidation for pre-processing before schema validation. preHandler for business logic checks after validation. preSerialization for response transformation. onSend for response headers. onError for error logging. onResponse for request completion logging.'
For the hook lifecycle: 'Fastify hooks run in a defined order: onRequest → preParsing → preValidation → preHandler → [handler] → preSerialization → onSend → onResponse. Each hook has a specific purpose. Auth belongs in onRequest. Validation preprocessing belongs in preValidation. Response wrapping belongs in preSerialization. Never put auth in preHandler — it runs after validation, wasting resources on unauthenticated requests.'
AI generates Express middleware (app.use()) which Fastify supports for compatibility but isn't idiomatic. Fastify hooks are typed, lifecycle-aware, and run in a guaranteed order. Middleware runs 'somewhere in the middle' with no lifecycle guarantees.
- onRequest: auth checks — first hook, before any processing
- preValidation: transform request before schema validation
- preHandler: business logic checks after validation passes
- preSerialization: transform response before serialization
- onError: error logging with full request context
- Never Express middleware — use typed, lifecycle-aware hooks
Never put auth in preHandler — it runs after validation, wasting resources validating unauthenticated requests. Auth belongs in onRequest — the first hook. Reject unauthorized requests before any processing.
Rule 4: Return Values, Not res.send()
The rule: 'Return values from route handlers — Fastify serializes them automatically using the response schema. async function handler(request, reply) { return { name: "Alice" }; }. This triggers schema-based serialization (fast). Never use reply.send() directly unless you need to set custom headers or status codes first. For status codes: reply.code(201); return data; — set the code, then return.'
For streams and buffers: 'Return streams directly for file downloads: return fs.createReadStream(path). Fastify handles stream piping and cleanup. Return Buffers for binary data. Set Content-Type via reply.type("application/octet-stream") before returning.'
AI generates reply.send({ data }) in every handler — bypassing Fastify's compiled serialization. The return pattern is idiomatic and faster. reply.send() is only needed for non-standard responses (streams, redirects, manual error responses).
Return values from handlers — Fastify serializes using the compiled schema (fast). reply.send() bypasses schema serialization. The return pattern is idiomatic and measurably faster.
Rule 5: Error Handling
The rule: 'Use Fastify's built-in error handling. Throw errors with statusCode: throw { statusCode: 404, message: "User not found" }. Define custom error handlers with fastify.setErrorHandler for formatted error responses. Schema validation errors are automatically returned as 400 with field-level details. Never try/catch in route handlers — let errors propagate to the error handler.'
For custom errors: 'Define error classes with statusCode and code properties: class NotFoundError extends Error { statusCode = 404; code = "NOT_FOUND" }. The error handler maps these to responses. Use @fastify/sensible for standard HTTP errors: fastify.httpErrors.notFound("User not found").'
For logging: 'Fastify includes pino logger by default — the fastest Node.js logger. Use request.log for request-scoped logging (includes requestId). Use fastify.log for app-level logging. Never use console.log — pino is structured, fast, and includes context automatically.'
Complete Fastify Rules Template
Consolidated rules for Fastify projects.
- JSON Schema on every route: body, params, querystring, response — TypeBox or Zod TypeProvider
- Plugin-based encapsulation — fastify.register for each feature — fastify-plugin sparingly
- Hooks not middleware: onRequest for auth, preHandler for logic, preSerialization for transforms
- Return values from handlers — never reply.send() unless custom headers/status needed
- Fastify error handler: throw with statusCode — schema errors auto-400 — @fastify/sensible
- pino for logging: request.log (scoped) and fastify.log (app) — never console.log
- fastify.decorate for DI — services on the instance, accessible in handlers
- @fastify/swagger for API docs — tap for testing — fastify.inject for request simulation