Fastify

Fastify's Schemas and Plugin System

Fastify 314,116 's bet is that a route declares the shape of its input and output. Validation runs through Ajv, which compiles each JSON Schema to a function, and serialization through fast-json-stringify, which compiles the response schema into a string builder. Declaring the output is a performance feature.

A schema-first route (fastify-schema.js)JavaScript
const book = { type: 'object', properties: { id: { type: 'string' },
  title: { type: 'string' }, year: { type: 'integer' } } };
app.post('/books', {
  schema: {
    body: { type: 'object', required: ['title', 'year'], additionalProperties: false,
      properties: { title: { type: 'string', minLength: 1, maxLength: 120 },
        year: { type: 'integer', minimum: 1450, maximum: 2030 } } },
    response: { 201: { type: 'object', properties: { data: book } } },  // the serializer
  },
}, async (request, reply) => {
  const created = { id: 'bk_9', ...request.body, secret: 'internal note' };
  return reply.code(201).send({ data: created }); // "secret" is not in the schema
});
Output
POST {"title":"Solaris","year":1961}
  201 {"data":{"id":"bk_9","title":"Solaris","year":1961}}       # "secret" never left
POST {"title":"","year":1961}
  400 {"statusCode":400,"code":"FST_ERR_VALIDATION","error":"Bad Request","message":
       "body/title must NOT have fewer than 1 characters"}
POST {"title":"Solaris","year":"1961","rating":5}
  201 {"data":{"id":"bk_9","title":"Solaris","year":1961}}       # coerced and stripped

The third response is the trap. Fastify configures Ajv with coerceTypes and removeAdditional on, so "1961" becomes 1961 and the undeclared rating is deleted from request.body rather than refused — additionalProperties: false strips here, where zod's .strict() in The Bookshelf API answers 422. Pass ajv: { customOptions: { coerceTypes: false, removeAdditional: false } } to Fastify() for the strict reading, and add a setErrorHandler: that 400 is not your error format.

Plugins are the other half. app.register(fn) hands fn a child instance with its own scope, so hooks, decorators and routes added inside are invisible outside — the opposite of app.use.

Encapsulation is the default; fastify-plugin opts outJavaScript
app.register(async (scoped) => {
  scoped.addHook('onRequest', authenticate);      // applies only inside this scope
  scoped.get('/admin/stats', handler);
});
app.register(fp(async (root) => root.decorate('db', await connect())));  // visible app-wide

fastify-plugin (fp) opts out, so a handle registered once is reachable everywhere as request.server.db. That rule replaces most of what middleware ordering does in Express 24,430 . The cost is a mental model to learn, and an ecosystem of @fastify/* packages in place of the Express middleware you know.