CtrlK
BlogDocsLog inGet started
Tessl Logo

mcollina/fastify-best-practices

Guides development of Fastify Node.js backend servers and REST APIs using TypeScript or JavaScript. Use when building, configuring, or debugging a Fastify application — including defining routes, implementing plugins, setting up JSON Schema validation, handling errors, optimising performance, managing authentication, configuring CORS and security headers, integrating databases, working with WebSockets, and deploying to production. Covers the full Fastify request lifecycle (hooks, serialization, logging with Pino) and TypeScript integration via strip types. Trigger terms: Fastify, Node.js server, REST API, API routes, backend framework, fastify.config, server.ts, app.ts.

95

Quality

95%

Does it follow best practices?

Impact

Pending

No eval scenarios have been run

SecuritybySnyk

Passed

No known issues

Overview
Quality
Evals
Security
Files

decorators.mdrules/

name:
decorators
description:
Decorators and request/reply extensions in Fastify
metadata:
{"tags":"decorators, extensions, customization, utilities"}

Decorators and Extensions

Understanding Decorators

Decorators add custom properties and methods to Fastify instances, requests, and replies:

import Fastify from 'fastify';

const app = Fastify();

// Decorate the Fastify instance
app.decorate('utility', {
  formatDate: (date: Date) => date.toISOString(),
  generateId: () => crypto.randomUUID(),
});

// Use in routes
app.get('/example', async function (request, reply) {
  const id = this.utility.generateId();
  return { id, timestamp: this.utility.formatDate(new Date()) };
});

Decorator Types

Three types of decorators for different contexts:

// Instance decorator - available on fastify instance
app.decorate('config', { apiVersion: '1.0.0' });
app.decorate('db', databaseConnection);
app.decorate('cache', cacheClient);

// Request decorator - available on each request
app.decorateRequest('user', null);           // Object property
app.decorateRequest('startTime', 0);         // Primitive
app.decorateRequest('getData', function() {  // Method
  return this.body;
});

// Reply decorator - available on each reply
app.decorateReply('sendError', function(code: number, message: string) {
  return this.code(code).send({ error: message });
});
app.decorateReply('success', function(data: unknown) {
  return this.send({ success: true, data });
});

TypeScript Declaration Merging

Extend Fastify types for type safety:

// Declare custom properties
declare module 'fastify' {
  interface FastifyInstance {
    config: {
      apiVersion: string;
      environment: string;
    };
    db: DatabaseClient;
    cache: CacheClient;
  }

  interface FastifyRequest {
    user: {
      id: string;
      email: string;
      roles: string[];
    } | null;
    startTime: number;
    requestId: string;
  }

  interface FastifyReply {
    sendError: (code: number, message: string) => void;
    success: (data: unknown) => void;
  }
}

// Register decorators
app.decorate('config', {
  apiVersion: '1.0.0',
  environment: process.env.NODE_ENV,
});

app.decorateRequest('user', null);
app.decorateRequest('startTime', 0);

app.decorateReply('sendError', function (code: number, message: string) {
  this.code(code).send({ error: message });
});

Decorator Initialization

Initialize request/reply decorators in hooks:

// Decorators with primitive defaults are copied
app.decorateRequest('startTime', 0);

// Initialize in hook
app.addHook('onRequest', async (request) => {
  request.startTime = Date.now();
});

// Object decorators need getter pattern for proper initialization
app.decorateRequest('context', null);

app.addHook('onRequest', async (request) => {
  request.context = {
    traceId: request.headers['x-trace-id'] || crypto.randomUUID(),
    clientIp: request.ip,
    userAgent: request.headers['user-agent'],
  };
});

Dependency Injection with Decorators

Use decorators for dependency injection:

import fp from 'fastify-plugin';

// Database plugin
export default fp(async function databasePlugin(fastify, options) {
  const db = await createDatabaseConnection(options.connectionString);

  fastify.decorate('db', db);

  fastify.addHook('onClose', async () => {
    await db.close();
  });
});

// User service plugin
export default fp(async function userServicePlugin(fastify) {
  // Depends on db decorator
  if (!fastify.hasDecorator('db')) {
    throw new Error('Database plugin must be registered first');
  }

  const userService = {
    findById: (id: string) => fastify.db.query('SELECT * FROM users WHERE id = $1', [id]),
    create: (data: CreateUserInput) => fastify.db.query(
      'INSERT INTO users (name, email) VALUES ($1, $2) RETURNING *',
      [data.name, data.email]
    ),
  };

  fastify.decorate('userService', userService);
}, {
  dependencies: ['database-plugin'],
});

// Use in routes
app.get('/users/:id', async function (request) {
  const user = await this.userService.findById(request.params.id);
  return user;
});

Request Context Pattern

Build rich request context:

interface RequestContext {
  traceId: string;
  user: User | null;
  permissions: Set<string>;
  startTime: number;
  metadata: Map<string, unknown>;
}

declare module 'fastify' {
  interface FastifyRequest {
    ctx: RequestContext;
  }
}

app.decorateRequest('ctx', null);

app.addHook('onRequest', async (request) => {
  request.ctx = {
    traceId: request.headers['x-trace-id']?.toString() || crypto.randomUUID(),
    user: null,
    permissions: new Set(),
    startTime: Date.now(),
    metadata: new Map(),
  };
});

// Auth hook populates user
app.addHook('preHandler', async (request) => {
  const token = request.headers.authorization;
  if (token) {
    const user = await verifyToken(token);
    request.ctx.user = user;
    request.ctx.permissions = new Set(user.permissions);
  }
});

// Use in handlers
app.get('/profile', async (request, reply) => {
  if (!request.ctx.user) {
    return reply.code(401).send({ error: 'Unauthorized' });
  }

  if (!request.ctx.permissions.has('read:profile')) {
    return reply.code(403).send({ error: 'Forbidden' });
  }

  return request.ctx.user;
});

Reply Helpers

Create consistent response methods:

declare module 'fastify' {
  interface FastifyReply {
    ok: (data?: unknown) => void;
    created: (data: unknown) => void;
    noContent: () => void;
    badRequest: (message: string, details?: unknown) => void;
    unauthorized: (message?: string) => void;
    forbidden: (message?: string) => void;
    notFound: (resource?: string) => void;
    conflict: (message: string) => void;
    serverError: (message?: string) => void;
  }
}

app.decorateReply('ok', function (data?: unknown) {
  this.code(200).send(data ?? { success: true });
});

app.decorateReply('created', function (data: unknown) {
  this.code(201).send(data);
});

app.decorateReply('noContent', function () {
  this.code(204).send();
});

app.decorateReply('badRequest', function (message: string, details?: unknown) {
  this.code(400).send({
    statusCode: 400,
    error: 'Bad Request',
    message,
    details,
  });
});

app.decorateReply('unauthorized', function (message = 'Authentication required') {
  this.code(401).send({
    statusCode: 401,
    error: 'Unauthorized',
    message,
  });
});

app.decorateReply('notFound', function (resource = 'Resource') {
  this.code(404).send({
    statusCode: 404,
    error: 'Not Found',
    message: `${resource} not found`,
  });
});

// Usage
app.get('/users/:id', async (request, reply) => {
  const user = await db.users.findById(request.params.id);
  if (!user) {
    return reply.notFound('User');
  }
  return reply.ok(user);
});

app.post('/users', async (request, reply) => {
  const user = await db.users.create(request.body);
  return reply.created(user);
});

Checking Decorators

Check if decorators exist before using:

// Check at registration time
app.register(async function (fastify) {
  if (!fastify.hasDecorator('db')) {
    throw new Error('Database decorator required');
  }

  if (!fastify.hasRequestDecorator('user')) {
    throw new Error('User request decorator required');
  }

  if (!fastify.hasReplyDecorator('sendError')) {
    throw new Error('sendError reply decorator required');
  }

  // Safe to use decorators
});

Decorator Encapsulation

Decorators respect encapsulation by default:

app.register(async function pluginA(fastify) {
  fastify.decorate('pluginAUtil', () => 'A');

  fastify.get('/a', async function () {
    return this.pluginAUtil(); // Works
  });
});

app.register(async function pluginB(fastify) {
  // this.pluginAUtil is NOT available here (encapsulated)

  fastify.get('/b', async function () {
    // this.pluginAUtil() would be undefined
  });
});

Use fastify-plugin to share decorators:

import fp from 'fastify-plugin';

export default fp(async function sharedDecorator(fastify) {
  fastify.decorate('sharedUtil', () => 'shared');
});

// Now available to parent and sibling plugins

Functional Decorators

Create decorators that return functions:

declare module 'fastify' {
  interface FastifyInstance {
    createValidator: <T>(schema: object) => (data: unknown) => T;
    createRateLimiter: (options: RateLimitOptions) => RateLimiter;
  }
}

app.decorate('createValidator', function <T>(schema: object) {
  const validate = ajv.compile(schema);
  return (data: unknown): T => {
    if (!validate(data)) {
      throw new ValidationError(validate.errors);
    }
    return data as T;
  };
});

// Usage
const validateUser = app.createValidator<User>(userSchema);

app.post('/users', async (request) => {
  const user = validateUser(request.body);
  return db.users.create(user);
});

Async Decorator Initialization

Handle async initialization properly:

import fp from 'fastify-plugin';

export default fp(async function asyncPlugin(fastify) {
  // Async initialization
  const connection = await createAsyncConnection();
  const cache = await initializeCache();

  fastify.decorate('asyncService', {
    connection,
    cache,
    query: async (sql: string) => connection.query(sql),
  });

  fastify.addHook('onClose', async () => {
    await connection.close();
    await cache.disconnect();
  });
});

// Plugin is fully initialized before routes execute
app.get('/data', async function () {
  return this.asyncService.query('SELECT * FROM data');
});

rules

authentication.md

configuration.md

content-type.md

cors-security.md

database.md

decorators.md

deployment.md

error-handling.md

hooks.md

http-proxy.md

logging.md

performance.md

plugins.md

routes.md

schemas.md

serialization.md

testing.md

typescript.md

websockets.md

SKILL.md

tile.json