Welcome to Chapter 3 of our comprehensive Node.js project guide! In this chapter, we’re laying the critical groundwork for our backend application by integrating the Fastify web framework. We will move beyond basic Node.js scripts to establish a robust, performant, and maintainable API server.

This chapter focuses on setting up Fastify, understanding its core concepts like routing and the plugin system (Fastify’s equivalent of middleware), and implementing a foundational structure for our API. By the end of this chapter, you will have a running Fastify server capable of handling basic HTTP requests, organized into modular routes, and equipped with centralized error handling and request logging. This step is crucial for building scalable and production-ready services, as it defines how our application receives and responds to external requests.

To follow along, you should have completed Chapter 2, which covered initial project setup, TypeScript configuration, and basic scripting. We’ll leverage the project structure and development scripts established there. The expected outcome is a functional Fastify API server with several testable endpoints, demonstrating best practices for modularity and maintainability.


1. Planning & Design

Before diving into code, let’s visualize the components we’re building and how they interact. This chapter primarily focuses on the “API Server” component and its internal structure.

1.1. Component Architecture

Our immediate focus is on the API Server. Later chapters will expand this diagram to include databases, caching, and other services.

flowchart LR Client[Client Application] -->|HTTP Requests| APIServer[Fastify API Server] APIServer -->|Logs| Console/LogFile[Console/Log File] APIServer -->|Handles Errors| ErrorHandler[Centralized Error Handler] APIServer -->|Routes Requests| RouteHandlers[Modular Route Handlers]

1.2. API Endpoints Design

We’ll start with a few simple, yet illustrative, API endpoints to demonstrate Fastify’s routing capabilities:

  • GET /health: A simple health check endpoint. Returns 200 OK with a status message. Essential for load balancers and container orchestration platforms.
  • GET /api/v1/status: Provides basic application status. Returns 200 OK with information like API version and uptime.
  • GET /api/v1/greet/:name: A parameterized route that greets the provided name. Demonstrates capturing dynamic segments from the URL.

1.3. File Structure

We’ll continue building within our src/ directory. Here’s how our file structure will evolve for this chapter:

.
├── src/
│   ├── app.ts                  # Main Fastify application instance
│   ├── utils/                  # Utility functions (e.g., constants, helpers)
│   │   └── constants.ts
│   ├── plugins/                # Fastify plugins (middleware, error handlers, etc.)
│   │   ├── error-handler.plugin.ts
│   │   └── request-logger.plugin.ts
│   └── routes/                 # API route definitions, organized by version
│       ├── index.ts            # Registers all versioned routes
│       └── v1/                 # Version 1 API routes
│           ├── index.ts        # Registers all v1 routes
│           ├── status.route.ts # /api/v1/status endpoint
│           └── greet.route.ts  # /api/v1/greet/:name endpoint
├── package.json
├── tsconfig.json
└── ...

2. Step-by-Step Implementation

Let’s begin building our Fastify application incrementally.

2.1. Initial Fastify Setup

We’ll start by installing Fastify and creating our main application file.

2.1.1. Setup/Configuration

First, install Fastify and its TypeScript types:

npm install fastify @fastify/sensible
npm install --save-dev @types/fastify
  • fastify: The core web framework.
  • @fastify/sensible: A plugin that adds common HTTP error responses and utility functions, making error handling more convenient. We’ll utilize it later for consistent error responses.
  • @types/fastify: TypeScript definitions for Fastify.

Next, we’ll establish our main Fastify application file.

2.1.2. Core Implementation

Create src/app.ts as the entry point for our Fastify server. This file will initialize Fastify, register plugins, and start the server.

File: src/app.ts

import Fastify, { FastifyInstance, FastifyServerOptions } from 'fastify';
import { APP_CONSTANTS } from './utils/constants';

/**
 * Builds and configures the Fastify application instance.
 * @param opts Fastify server options.
 * @returns A Fastify instance.
 */
function buildApp(opts: FastifyServerOptions = {}): FastifyInstance {
  const app: FastifyInstance = Fastify(opts);

  // Health check route
  app.get('/health', async (request, reply) => {
    app.log.info('Health check endpoint hit');
    return reply.status(200).send({ status: 'ok', uptime: process.uptime() });
  });

  // Graceful shutdown
  const signals = ['SIGINT', 'SIGTERM'];
  signals.forEach((signal) => {
    process.on(signal, async () => {
      app.log.info(`Received ${signal}. Shutting down gracefully...`);
      await app.close();
      app.log.info('Fastify server closed.');
      process.exit(0);
    });
  });

  return app;
}

export { buildApp };

// This block ensures the server only runs when `app.ts` is executed directly
// and not when imported as a module for testing.
if (require.main === module) {
  const app = buildApp({
    logger: {
      level: APP_CONSTANTS.LOG_LEVEL, // Use log level from constants
      transport: {
        target: 'pino-pretty', // Pretty print logs in development
        options: {
          colorize: true,
          translateTime: 'SYS:HH:MM:ss Z',
          ignore: 'pid,hostname',
        },
      },
    },
  });

  app.listen({ port: APP_CONSTANTS.PORT, host: APP_CONSTANTS.HOST }, (err) => {
    if (err) {
      app.log.error(err);
      process.exit(1);
    }
    app.log.info(`Server listening on ${APP_CONSTANTS.HOST}:${APP_CONSTANTS.PORT}`);
    app.log.info(`Environment: ${APP_CONSTANTS.NODE_ENV}`);
  });
}

File: src/utils/constants.ts

// src/utils/constants.ts
export const APP_CONSTANTS = {
  PORT: parseInt(process.env.PORT || '3000', 10),
  HOST: process.env.HOST || '0.0.0.0',
  NODE_ENV: process.env.NODE_ENV || 'development',
  LOG_LEVEL: process.env.LOG_LEVEL || 'info', // Default log level
};

Explanation:

  • buildApp Function: We wrap our Fastify initialization in a buildApp function. This is a common best practice for Fastify applications, as it makes the server instance easily testable and allows for different configurations (e.g., for testing vs. production).
  • Fastify Instance: Fastify(opts) creates a new Fastify server instance. We pass opts to configure aspects like logging.
  • Health Check: A GET /health endpoint is crucial for production. It allows load balancers and container orchestrators (like Kubernetes or AWS ECS) to check if our application is alive and responsive.
  • Graceful Shutdown: The process.on('SIGINT', ...) and process.on('SIGTERM', ...) listeners ensure that when the server receives termination signals (e.g., from Ctrl+C or a deployment system), it gracefully closes open connections before exiting. This prevents abrupt disconnections and data loss.
  • Logger Configuration: Fastify uses pino by default, a highly performant JSON logger. In development, we use pino-pretty for human-readable logs. In production, pino’s JSON output is ideal for log aggregation systems. The log level is configurable via LOG_LEVEL environment variable.
  • require.main === module: This conditional block ensures that the app.listen() call only happens when app.ts is executed directly (e.g., via npm run dev). This is vital for testing frameworks, which will import buildApp and manage the server lifecycle themselves without starting an actual HTTP server.
  • APP_CONSTANTS: Centralizes environment-dependent configurations, making them easy to manage and access throughout the application.

2.1.3. Testing This Component

First, ensure your package.json has the dev script from Chapter 2:

File: package.json (ensure these scripts are present)

{
  "name": "node-backend-project",
  "version": "1.0.0",
  "description": "A progressive Node.js backend project.",
  "main": "dist/app.js",
  "scripts": {
    "build": "tsc",
    "start": "node dist/app.js",
    "dev": "ts-node-dev --respawn --transpile-only src/app.ts",
    "lint": "eslint \"{src,test}/**/*.ts\" --fix",
    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage"
  },
  "dependencies": {
    "@fastify/sensible": "^5.2.0",
    "fastify": "^4.26.0",
    "pino-pretty": "^10.3.1"
  },
  "devDependencies": {
    "@types/node": "^20.11.16",
    "@typescript-eslint/eslint-plugin": "^6.20.0",
    "@typescript-eslint/parser": "^6.20.0",
    "eslint": "^8.56.0",
    "jest": "^29.7.0",
    "ts-jest": "^29.1.2",
    "ts-node-dev": "^2.0.0",
    "typescript": "^5.3.3",
    "@types/jest": "^29.5.12"
  }
}

Now, run the server:

npm run dev

You should see output similar to this:

[INFO] 12:34:56 SYS:HH:MM:ss Z - Server listening on 0.0.0.0:3000
[INFO] 12:34:56 SYS:HH:MM:ss Z - Environment: development

Open your browser or use curl to test the health endpoint:

curl http://localhost:3000/health

Expected output:

{"status":"ok","uptime":...}

You should also see a log message in your terminal: Health check endpoint hit.

2.2. Introducing Routing Modularity

As our application grows, putting all routes in app.ts becomes unmanageable. Fastify’s plugin system allows us to organize routes into separate modules.

2.2.1. Setup/Configuration

We’ll create a routes directory with versioning.

Create these files:

  • src/routes/v1/status.route.ts
  • src/routes/v1/greet.route.ts
  • src/routes/v1/index.ts
  • src/routes/index.ts

2.2.2. Core Implementation

Let’s implement the new routes and register them.

File: src/routes/v1/status.route.ts

import { FastifyInstance, FastifyPluginOptions } from 'fastify';
import { APP_CONSTANTS } from '../../utils/constants';

/**
 * Registers the status API routes.
 * @param fastify The Fastify instance.
 * @param opts Plugin options.
 */
async function statusRoutes(fastify: FastifyInstance, opts: FastifyPluginOptions) {
  fastify.get('/status', async (request, reply) => {
    request.log.info('Status endpoint hit');
    return reply.status(200).send({
      message: 'API is running',
      version: 'v1',
      environment: APP_CONSTANTS.NODE_ENV,
      uptime: process.uptime(),
    });
  });
}

export default statusRoutes;

File: src/routes/v1/greet.route.ts

import { FastifyInstance, FastifyPluginOptions } from 'fastify';

/**
 * Registers the greet API routes.
 * @param fastify The Fastify instance.
 * @param opts Plugin options.
 */
async function greetRoutes(fastify: FastifyInstance, opts: FastifyPluginOptions) {
  fastify.get('/greet/:name', async (request, reply) => {
    const { name } = request.params as { name: string }; // Type assertion for params
    request.log.info(`Greet endpoint hit for name: ${name}`);

    if (!name) {
      return reply.status(400).send({ message: 'Name parameter is required' });
    }

    return reply.status(200).send({ message: `Hello, ${name}!` });
  });
}

export default greetRoutes;

File: src/routes/v1/index.ts

import { FastifyInstance, FastifyPluginOptions } from 'fastify';
import statusRoutes from './status.route';
import greetRoutes from './greet.route';

/**
 * Registers all v1 API routes.
 * @param fastify The Fastify instance.
 * @param opts Plugin options.
 */
async function v1Routes(fastify: FastifyInstance, opts: FastifyPluginOptions) {
  fastify.register(statusRoutes);
  fastify.register(greetRoutes);
}

export default v1Routes;

File: src/routes/index.ts

import { FastifyInstance, FastifyPluginOptions } from 'fastify';
import v1Routes from './v1';

/**
 * Registers all API routes, organized by version.
 * @param fastify The Fastify instance.
 * @param opts Plugin options.
 */
async function apiRoutes(fastify: FastifyInstance, opts: FastifyPluginOptions) {
  // Register v1 routes under the /api/v1 prefix
  fastify.register(v1Routes, { prefix: '/api/v1' });
  // Future versions would be registered similarly:
  // fastify.register(v2Routes, { prefix: '/api/v2' });
}

export default apiRoutes;

Finally, integrate these routes into our main app.ts.

File: src/app.ts (update buildApp function)

import Fastify, { FastifyInstance, FastifyServerOptions } from 'fastify';
import { APP_CONSTANTS } from './utils/constants';
import apiRoutes from './routes'; // Import our aggregated routes

/**
 * Builds and configures the Fastify application instance.
 * @param opts Fastify server options.
 * @returns A Fastify instance.
 */
function buildApp(opts: FastifyServerOptions = {}): FastifyInstance {
  const app: FastifyInstance = Fastify(opts);

  // Register all API routes
  app.register(apiRoutes);

  // Health check route (can remain here or be moved to a plugin)
  app.get('/health', async (request, reply) => {
    app.log.info('Health check endpoint hit');
    return reply.status(200).send({ status: 'ok', uptime: process.uptime() });
  });

  // Graceful shutdown (same as before)
  const signals = ['SIGINT', 'SIGTERM'];
  signals.forEach((signal) => {
    process.on(signal, async () => {
      app.log.info(`Received ${signal}. Shutting down gracefully...`);
      await app.close();
      app.log.info('Fastify server closed.');
      process.exit(0);
    });
  });

  return app;
}

export { buildApp };

// ... (remaining part of app.ts for direct execution remains the same)
if (require.main === module) {
  const app = buildApp({
    logger: {
      level: APP_CONSTANTS.LOG_LEVEL,
      transport: {
        target: 'pino-pretty',
        options: {
          colorize: true,
          translateTime: 'SYS:HH:MM:ss Z',
          ignore: 'pid,hostname',
        },
      },
    },
  });

  app.listen({ port: APP_CONSTANTS.PORT, host: APP_CONSTANTS.HOST }, (err) => {
    if (err) {
      app.log.error(err);
      process.exit(1);
    }
    app.log.info(`Server listening on ${APP_CONSTANTS.HOST}:${APP_CONSTANTS.PORT}`);
    app.log.info(`Environment: ${APP_CONSTANTS.NODE_ENV}`);
  });
}

Explanation:

  • Modular Routes: Each route file (status.route.ts, greet.route.ts) exports an async function that takes the fastify instance and opts as arguments. This is the standard Fastify plugin signature.
  • fastify.register(): This is the core mechanism for Fastify’s plugin system.
    • In src/routes/v1/index.ts, we register individual routes.
    • In src/routes/index.ts, we register the v1Routes plugin. The crucial part here is { prefix: '/api/v1' }. This option automatically prefixes all routes registered within v1Routes with /api/v1, keeping our URLs clean and organized by API version.
  • request.params: For the greet route, request.params object contains the dynamic segments of the URL. We use TypeScript’s type assertion as { name: string } to safely access the name property.
  • Logging: We use request.log.info() for request-specific logging. Fastify’s logger is context-aware, meaning logs within a request handler will automatically include request IDs (when configured with genReqId), aiding in traceability.
  • Error Handling in Route: The greet route includes a basic check for the name parameter, returning a 400 Bad Request if it’s missing. This demonstrates basic inline error handling.

2.2.3. Testing This Component

Restart your server:

npm run dev

Test the new endpoints:

# Test status endpoint
curl http://localhost:3000/api/v1/status

# Expected:
# {"message":"API is running","version":"v1","environment":"development","uptime":...}

# Test greet endpoint with a name
curl http://localhost:3000/api/v1/greet/Alice

# Expected:
# {"message":"Hello, Alice!"}

# Test greet endpoint without a name (though the route definition requires it,
# if we were to make the param optional, this would be a test case)
# For the current setup, `/greet/` would result in a 404.
# A better test for missing parameter would be if the route was `/greet` and `name` was a query param.
# For now, the existing route expects a name.

You should see corresponding log messages for each request in your terminal.

2.3. Fastify Hooks (Middleware Equivalent)

Fastify doesn’t use the traditional “middleware” concept like Express. Instead, it uses a powerful “hook” system and a plugin architecture. Hooks allow you to execute logic at specific points in the request lifecycle.

2.3.1. Setup/Configuration

We’ll create a simple plugin to log every incoming request before it reaches the route handler.

Create this file:

  • src/plugins/request-logger.plugin.ts

2.3.2. Core Implementation

File: src/plugins/request-logger.plugin.ts

import { FastifyInstance, FastifyPluginOptions } from 'fastify';
import fp from 'fastify-plugin'; // Recommended for utility plugins

/**
 * A Fastify plugin to log incoming requests.
 * Uses `fastify-plugin` to make it available to all encapsulated routes.
 * @param fastify The Fastify instance.
 * @param opts Plugin options.
 */
async function requestLoggerPlugin(fastify: FastifyInstance, opts: FastifyPluginOptions) {
  fastify.addHook('onRequest', async (request, reply) => {
    request.log.info({ url: request.url, method: request.method }, 'Incoming request');
  });
}

// Export as a Fastify plugin to ensure proper encapsulation and availability
export default fp(requestLoggerPlugin, {
  name: 'request-logger', // Unique name for the plugin
});

Now, register this plugin in our main app.ts.

File: src/app.ts (update buildApp function)

import Fastify, { FastifyInstance, FastifyServerOptions } from 'fastify';
import { APP_CONSTANTS } from './utils/constants';
import apiRoutes from './routes';
import requestLoggerPlugin from './plugins/request-logger.plugin'; // Import the logger plugin

/**
 * Builds and configures the Fastify application instance.
 * @param opts Fastify server options.
 * @returns A Fastify instance.
 */
function buildApp(opts: FastifyServerOptions = {}): FastifyInstance {
  const app: FastifyInstance = Fastify(opts);

  // Register utility plugins first
  app.register(requestLoggerPlugin); // Register our request logger

  // Register all API routes
  app.register(apiRoutes);

  // ... (remaining part of app.ts, health check and graceful shutdown)
  app.get('/health', async (request, reply) => {
    app.log.info('Health check endpoint hit');
    return reply.status(200).send({ status: 'ok', uptime: process.uptime() });
  });

  const signals = ['SIGINT', 'SIGTERM'];
  signals.forEach((signal) => {
    process.on(signal, async () => {
      app.log.info(`Received ${signal}. Shutting down gracefully...`);
      await app.close();
      app.log.info('Fastify server closed.');
      process.exit(0);
    });
  });

  return app;
}

export { buildApp };

// ... (remaining part of app.ts for direct execution)
if (require.main === module) {
  const app = buildApp({
    logger: {
      level: APP_CONSTANTS.LOG_LEVEL,
      transport: {
        target: 'pino-pretty',
        options: {
          colorize: true,
          translateTime: 'SYS:HH:MM:ss Z',
          ignore: 'pid,hostname',
        },
      },
    },
  });

  app.listen({ port: APP_CONSTANTS.PORT, host: APP_CONSTANTS.HOST }, (err) => {
    if (err) {
      app.log.error(err);
      process.exit(1);
    }
    app.log.info(`Server listening on ${APP_CONSTANTS.HOST}:${APP_CONSTANTS.PORT}`);
    app.log.info(`Environment: ${APP_CONSTANTS.NODE_ENV}`);
  });
}

Explanation:

  • fastify-plugin: This utility is crucial for plugins that you want to be available across your entire application, even within other registered plugins. Without fastify-plugin, a plugin registered at the root level might not be accessible to routes registered within a separate encapsulated plugin (like our apiRoutes).
  • fastify.addHook('onRequest', ...): This registers a function to be executed at the onRequest lifecycle hook. This hook runs at the very beginning of the request, before routing, parsing, or validation. Other important hooks include:
    • preParsing: Before request body parsing.
    • preValidation: Before request validation.
    • preHandler: Before the route handler.
    • onResponse: After the response has been sent.
    • onError: When an error occurs.
  • request.log.info(...): We use the request-specific logger, which will automatically include the request ID (if genReqId is configured in Fastify options) in production logs, making it easier to trace individual requests.

2.3.3. Testing This Component

Restart your server:

npm run dev

Make any request, e.g.:

curl http://localhost:3000/health

Now, in addition to the Health check endpoint hit log, you should see an Incoming request log before it, like this:

[INFO] 12:34:56 SYS:HH:MM:ss Z - Incoming request: { url: '/health', method: 'GET' }
[INFO] 12:34:56 SYS:HH:MM:ss Z - Health check endpoint hit

This confirms our onRequest hook is working correctly.

2.4. Centralized Error Handling

Robust applications need a centralized way to handle errors consistently. Fastify provides setErrorHandler for this purpose. We’ll also leverage @fastify/sensible to simplify common HTTP error responses.

2.4.1. Setup/Configuration

We already installed @fastify/sensible. Now, create a new plugin for error handling:

Create this file:

  • src/plugins/error-handler.plugin.ts

2.4.2. Core Implementation

File: src/plugins/error-handler.plugin.ts

import { FastifyInstance, FastifyPluginOptions, FastifyError } from 'fastify';
import fp from 'fastify-plugin';
import sensible from '@fastify/sensible'; // Import sensible

/**
 * A Fastify plugin for centralized error handling.
 * Registers sensible for common HTTP errors and sets a custom error handler.
 * @param fastify The Fastify instance.
 * @param opts Plugin options.
 */
async function errorHandlerPlugin(fastify: FastifyInstance, opts: FastifyPluginOptions) {
  // Register @fastify/sensible for common HTTP error utilities
  fastify.register(sensible);

  fastify.setErrorHandler((error: FastifyError, request, reply) => {
    request.log.error({ error, stack: error.stack }, 'Caught error in global error handler');

    // Default error message and status
    let statusCode = error.statusCode || 500;
    let message = 'An unexpected error occurred.';

    // Handle common Fastify errors or known application errors
    if (error.validation) {
      // Fastify validation error (e.g., from schema validation)
      statusCode = 400;
      message = 'Validation Error: ' + error.message;
      return reply.status(statusCode).send({
        statusCode,
        code: error.code,
        message,
        validation: error.validation,
      });
    }

    if (error.code === 'FST_ERR_BAD_URL') {
      // Example of handling a specific Fastify error code
      statusCode = 400;
      message = 'Bad URL format.';
    }

    // Use sensible's `toFastifyError` for consistent error objects
    const fastifyError = fastify.httpErrors.toFastifyError(error, statusCode, message);

    // Reply with a generic 500 error for unknown issues, or specific status/message for known ones
    reply.status(fastifyError.statusCode).send({
      statusCode: fastifyError.statusCode,
      code: fastifyError.code,
      message: fastifyError.message,
    });
  });
}

// Export as a Fastify plugin
export default fp(errorHandlerPlugin, {
  name: 'error-handler',
  dependencies: ['fastify-sensible'], // Ensure sensible is loaded before this plugin
});

Now, register this error handler plugin in app.ts.

File: src/app.ts (update buildApp function)

import Fastify, { FastifyInstance, FastifyServerOptions } from 'fastify';
import { APP_CONSTANTS } from './utils/constants';
import apiRoutes from './routes';
import requestLoggerPlugin from './plugins/request-logger.plugin';
import errorHandlerPlugin from './plugins/error-handler.plugin'; // Import error handler

/**
 * Builds and configures the Fastify application instance.
 * @param opts Fastify server options.
 * @returns A Fastify instance.
 */
function buildApp(opts: FastifyServerOptions = {}): FastifyInstance {
  const app: FastifyInstance = Fastify(opts);

  // Register utility plugins first
  app.register(requestLoggerPlugin);
  app.register(errorHandlerPlugin); // Register our centralized error handler

  // Register all API routes
  app.register(apiRoutes);

  // Add a test route to intentionally throw an error
  app.get('/error-test', async (request, reply) => {
    request.log.warn('Intentional error test endpoint hit');
    throw new Error('This is an intentional error from /error-test!');
  });

  // ... (remaining part of app.ts, health check and graceful shutdown)
  app.get('/health', async (request, reply) => {
    app.log.info('Health check endpoint hit');
    return reply.status(200).send({ status: 'ok', uptime: process.uptime() });
  });

  const signals = ['SIGINT', 'SIGTERM'];
  signals.forEach((signal) => {
    process.on(signal, async () => {
      app.log.info(`Received ${signal}. Shutting down gracefully...`);
      await app.close();
      app.log.info('Fastify server closed.');
      process.exit(0);
    });
  });

  return app;
}

export { buildApp };

// ... (remaining part of app.ts for direct execution)
if (require.main === module) {
  const app = buildApp({
    logger: {
      level: APP_CONSTANTS.LOG_LEVEL,
      transport: {
        target: 'pino-pretty',
        options: {
          colorize: true,
          translateTime: 'SYS:HH:MM:ss Z',
          ignore: 'pid,hostname',
        },
      },
    },
  });

  app.listen({ port: APP_CONSTANTS.PORT, host: APP_CONSTANTS.HOST }, (err) => {
    if (err) {
      app.log.error(err);
      process.exit(1);
    }
    app.log.info(`Server listening on ${APP_CONSTANTS.HOST}:${APP_CONSTANTS.PORT}`);
    app.log.info(`Environment: ${APP_CONSTANTS.NODE_ENV}`);
  });
}

Explanation:

  • @fastify/sensible: This plugin extends the Fastify reply object with useful methods for sending common HTTP errors (e.g., reply.notFound(), reply.badRequest()). We use fastify.httpErrors.toFastifyError to transform any Error object into a standardized Fastify error object.
  • setErrorHandler: This method registers a global error handler for the application. Any throw new Error() or unhandled promise rejection within a route handler or hook will be caught here.
  • Error Logging: Crucially, we log the full error object and stack trace using request.log.error(). In production, this allows us to diagnose issues effectively.
  • Production vs. Development Error Messages: For production, it’s a security best practice not to expose detailed error messages or stack traces to the client. Our handler sends a generic “An unexpected error occurred.” for unknown errors. For known errors (like validation errors), we can provide more specific, but still safe, messages.
  • Dependencies: The dependencies: ['fastify-sensible'] option in fp() ensures that @fastify/sensible is loaded and ready before our errorHandlerPlugin attempts to use its features.
  • Test Route: The /error-test route is added temporarily to easily trigger an error and verify our handler.

2.4.3. Testing This Component

Restart your server:

npm run dev

Test the new error route:

curl http://localhost:3000/error-test

Expected output:

{"statusCode":500,"code":"FST_ERR_GENERIC","message":"An unexpected error occurred."}

In your terminal, you should see the WARN log for the intentional error and an ERROR log from the global error handler, including the error details and stack trace. This demonstrates that our centralized error handling is catching and processing errors as expected.


3. Production Considerations

Building a production-ready application involves more than just writing functional code. Here’s what we need to keep in mind for Fastify, routing, and middleware/hooks:

3.1. Error Handling

  • Operational vs. Programmer Errors: Distinguish between operational errors (e.g., invalid input, network issues, database connection failures) and programmer errors (e.g., typos, logic bugs, unhandled exceptions). Our current handler is good for catching both, but future enhancements will involve more granular handling.
  • Sensitive Information: Never expose sensitive information (like internal file paths, database connection strings, or full stack traces) in error responses to clients, especially in production. Our current handler sends a generic 500 message for unhandled errors.
  • Error Tracking: Integrate with an error tracking service (e.g., Sentry, Bugsnag) to automatically report and monitor errors in production. This will be covered in a later chapter.

3.2. Performance Optimization

  • Fastify’s Strengths: Fastify is inherently fast. Leverage its performance by avoiding heavy, synchronous operations in hooks or route handlers. Use async/await for I/O operations to keep the event loop unblocked.
  • Minimal Hooks: Only add hooks when necessary. Each hook adds a small overhead to every request.
  • Payload Validation: While we didn’t implement it in this chapter, Fastify’s built-in JSON schema validation (using ajv) is highly optimized and should be preferred over custom, potentially slower, validation logic.

3.3. Security Considerations

  • Input Validation: Always validate all incoming data (params, query strings, body). We touched upon this in the greet route, but comprehensive validation will be covered in a dedicated chapter. This prevents common vulnerabilities like SQL injection, XSS, and buffer overflows.
  • Security Headers: Implement security headers (e.g., X-Content-Type-Options, Strict-Transport-Security, Content-Security-Policy). Fastify has plugins like @fastify/helmet that simplify this.
  • Rate Limiting: Protect your API from abuse and DoS attacks by implementing rate limiting. Fastify offers @fastify/rate-limit. This will also be covered in a later chapter.
  • CORS: Properly configure Cross-Origin Resource Sharing (@fastify/cors) to allow only trusted clients to access your API.

3.4. Logging and Monitoring

  • Structured Logging: Fastify’s pino logger provides structured (JSON) logs, which are ideal for production. They are easily parsed by log aggregation tools (e.g., ELK stack, Datadog, CloudWatch Logs).
  • Log Levels: Use appropriate log levels (e.g., info for general operations, warn for non-critical issues, error for failures, debug for detailed troubleshooting) to control verbosity and filter logs effectively.
  • Request IDs: Ensure your logger is configured to generate and include a unique request ID for each incoming request. This allows you to trace a single request’s journey through multiple services and logs. Fastify does this automatically if genReqId is configured (e.g., genReqId: () => randomUUID()).

4. Code Review Checkpoint

At this point, you have successfully set up a foundational Fastify application with modular routing, request logging, and centralized error handling.

Summary of what was built:

  • Initialized a Fastify server using the buildApp pattern for testability.
  • Implemented a /health endpoint for readiness checks.
  • Organized API routes into modular, versioned files (src/routes/v1/).
  • Used fastify.register() with the prefix option for clean URL management.
  • Implemented an onRequest hook using fastify-plugin to log all incoming requests.
  • Set up a global error handler using fastify.setErrorHandler and @fastify/sensible for consistent error responses and robust error logging.
  • Added a test endpoint (/error-test) to verify error handling.

Files Created/Modified:

  • package.json: Added fastify, @fastify/sensible, @types/fastify.
  • src/app.ts: Main Fastify instance, registered plugins and routes, health check, graceful shutdown.
  • src/utils/constants.ts: Defined APP_CONSTANTS for port, host, environment, and log level.
  • src/plugins/request-logger.plugin.ts: onRequest hook for logging.
  • src/plugins/error-handler.plugin.ts: Global error handler and @fastify/sensible integration.
  • src/routes/index.ts: Aggregates versioned API routes.
  • src/routes/v1/index.ts: Aggregates v1 specific routes.
  • src/routes/v1/status.route.ts: /api/v1/status endpoint.
  • src/routes/v1/greet.route.ts: /api/v1/greet/:name endpoint.

How it integrates with existing code: The new Fastify server now forms the core of our backend application, replacing the simple index.ts from Chapter 2 (which we’ve effectively renamed/refactored into app.ts and its related modules). The project structure is growing, adhering to best practices for modularity and maintainability.


5. Common Issues & Solutions

5.1. “Address already in use” Error

  • Issue: When starting the server, you might see an error like EADDRINUSE: address already in use :::3000.
  • Cause: This means another process is already listening on the port your Fastify application is trying to use (default 3000). This often happens if you didn’t gracefully shut down a previous server instance, or if another application is running on that port.
  • Solution:
    1. Find and Kill: On Linux/macOS, use lsof -i :3000 to find the process ID (PID) and then kill -9 <PID>. On Windows, use netstat -ano | findstr :3000 to find the PID and then taskkill /PID <PID> /F.
    2. Change Port: Modify the PORT in src/utils/constants.ts or set the PORT environment variable (e.g., PORT=4000 npm run dev).
    3. Graceful Shutdown: Ensure your graceful shutdown logic in app.ts is working correctly, so the server releases the port when stopped.

5.2. Route Not Found (404)

  • Issue: You’re trying to access a route, but the server returns a 404 Not Found error.
  • Cause:
    • Typo in URL: Check the URL you’re requesting against the defined route paths (e.g., /api/v1/status vs. /status).
    • Incorrect Prefix: If you used fastify.register(plugin, { prefix: '/my-prefix' }), ensure you’re including the prefix in your request.
    • Plugin/Route Not Registered: Double-check that all your route files are correctly imported and registered via fastify.register() in src/routes/v1/index.ts, src/routes/index.ts, and ultimately src/app.ts.
    • Wrong HTTP Method: Ensure you’re using the correct HTTP method (e.g., GET for a fastify.get() route).
  • Solution: Carefully review your route definitions, fastify.register() calls, and the URLs you’re testing. Fastify logs a warning if a route handler is registered with the same path and method multiple times.

5.3. TypeScript Compilation Issues

  • Issue: TypeScript errors related to types (e.g., “Property ‘params’ does not exist on type ‘FastifyRequest’”).
  • Cause:
    • Missing Type Definitions: You might have forgotten to install @types/fastify or other @types/ packages for your dependencies.
    • Incorrect Type Assertions: While request.params as { name: string } works, it bypasses type checking. For robust type safety, Fastify routes can be defined with generic types for Request, Reply, Params, Querystring, and Body.
  • Solution:
    1. Install Types: Always install type definitions for any library (npm install --save-dev @types/library-name).
    2. Explicit Route Schemas: For better type safety, define JSON schemas for your request parameters, querystring, and body. Fastify uses these schemas for validation and automatically infers types. We will cover this in detail in the next chapter. For now, type assertions (as Type) are acceptable for simple cases.

6. Testing & Verification

Let’s ensure everything we’ve built in this chapter is working as expected.

  1. Start the server:

    npm run dev
    

    Verify that the server starts without errors and logs Server listening on 0.0.0.0:3000.

  2. Test the health check endpoint:

    curl http://localhost:3000/health
    
    • Expected Response: {"status":"ok","uptime":...}
    • Expected Logs: Incoming request and Health check endpoint hit.
  3. Test the v1 status endpoint:

    curl http://localhost:3000/api/v1/status
    
    • Expected Response: {"message":"API is running","version":"v1","environment":"development","uptime":...}
    • Expected Logs: Incoming request and Status endpoint hit.
  4. Test the v1 greet endpoint with a name:

    curl http://localhost:3000/api/v1/greet/Sarah
    
    • Expected Response: {"message":"Hello, Sarah!"}
    • Expected Logs: Incoming request and Greet endpoint hit for name: Sarah.
  5. Test the error test endpoint:

    curl http://localhost:3000/error-test
    
    • Expected Response: {"statusCode":500,"code":"FST_ERR_GENERIC","message":"An unexpected error occurred."} (or similar, if you modified the error message)
    • Expected Logs: Incoming request, Intentional error test endpoint hit, and Caught error in global error handler (with error details including stack trace).

If all these tests pass and the logs appear as expected, your Fastify application’s foundation is solid!


7. Summary & Next Steps

In this chapter, we successfully migrated our basic Node.js setup to a full-fledged Fastify web server. We established a production-ready project structure, implemented modular routing, introduced Fastify’s powerful plugin and hook system for request logging, and set up a robust, centralized error handler. These are fundamental building blocks for any serious backend application.

You now have a running API server that:

  • Uses Fastify for high performance and developer experience.
  • Organizes routes logically with versioning.
  • Logs incoming requests for better observability.
  • Handles errors gracefully and consistently.
  • Is prepared for future extensions and deployments.

In the next chapter, Chapter 4: Data Validation & Environment Configuration, we will dive deeper into securing our API by implementing robust input validation using Fastify’s schema capabilities. We’ll also refine our environment configuration to handle different deployment environments (development, staging, production) securely and efficiently, preparing our application for real-world scenarios.