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.
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. Returns200 OKwith a status message. Essential for load balancers and container orchestration platforms.GET /api/v1/status: Provides basic application status. Returns200 OKwith 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:
buildAppFunction: We wrap our Fastify initialization in abuildAppfunction. 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 passoptsto configure aspects like logging. - Health Check: A
GET /healthendpoint 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', ...)andprocess.on('SIGTERM', ...)listeners ensure that when the server receives termination signals (e.g., fromCtrl+Cor a deployment system), it gracefully closes open connections before exiting. This prevents abrupt disconnections and data loss. - Logger Configuration: Fastify uses
pinoby default, a highly performant JSON logger. In development, we usepino-prettyfor human-readable logs. In production,pino’s JSON output is ideal for log aggregation systems. The log level is configurable viaLOG_LEVELenvironment variable. require.main === module: This conditional block ensures that theapp.listen()call only happens whenapp.tsis executed directly (e.g., vianpm run dev). This is vital for testing frameworks, which will importbuildAppand 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.tssrc/routes/v1/greet.route.tssrc/routes/v1/index.tssrc/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 anasync functionthat takes thefastifyinstance andoptsas 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 thev1Routesplugin. The crucial part here is{ prefix: '/api/v1' }. This option automatically prefixes all routes registered withinv1Routeswith/api/v1, keeping our URLs clean and organized by API version.
- In
request.params: For thegreetroute,request.paramsobject contains the dynamic segments of the URL. We use TypeScript’s type assertionas { name: string }to safely access thenameproperty.- 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 withgenReqId), aiding in traceability. - Error Handling in Route: The
greetroute includes a basic check for thenameparameter, returning a400 Bad Requestif 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. Withoutfastify-plugin, a plugin registered at the root level might not be accessible to routes registered within a separate encapsulated plugin (like ourapiRoutes).fastify.addHook('onRequest', ...): This registers a function to be executed at theonRequestlifecycle 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 (ifgenReqIdis 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 usefastify.httpErrors.toFastifyErrorto transform anyErrorobject into a standardized Fastify error object.setErrorHandler: This method registers a global error handler for the application. Anythrow 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 infp()ensures that@fastify/sensibleis loaded and ready before ourerrorHandlerPluginattempts to use its features. - Test Route: The
/error-testroute 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/awaitfor 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
greetroute, 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/helmetthat 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
pinologger 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.,
infofor general operations,warnfor non-critical issues,errorfor failures,debugfor 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
genReqIdis 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
buildApppattern for testability. - Implemented a
/healthendpoint for readiness checks. - Organized API routes into modular, versioned files (
src/routes/v1/). - Used
fastify.register()with theprefixoption for clean URL management. - Implemented an
onRequesthook usingfastify-pluginto log all incoming requests. - Set up a global error handler using
fastify.setErrorHandlerand@fastify/sensiblefor consistent error responses and robust error logging. - Added a test endpoint (
/error-test) to verify error handling.
Files Created/Modified:
package.json: Addedfastify,@fastify/sensible,@types/fastify.src/app.ts: Main Fastify instance, registered plugins and routes, health check, graceful shutdown.src/utils/constants.ts: DefinedAPP_CONSTANTSfor port, host, environment, and log level.src/plugins/request-logger.plugin.ts:onRequesthook for logging.src/plugins/error-handler.plugin.ts: Global error handler and@fastify/sensibleintegration.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/statusendpoint.src/routes/v1/greet.route.ts:/api/v1/greet/:nameendpoint.
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:
- Find and Kill: On Linux/macOS, use
lsof -i :3000to find the process ID (PID) and thenkill -9 <PID>. On Windows, usenetstat -ano | findstr :3000to find the PID and thentaskkill /PID <PID> /F. - Change Port: Modify the
PORTinsrc/utils/constants.tsor set thePORTenvironment variable (e.g.,PORT=4000 npm run dev). - Graceful Shutdown: Ensure your graceful shutdown logic in
app.tsis working correctly, so the server releases the port when stopped.
- Find and Kill: On Linux/macOS, use
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/statusvs./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()insrc/routes/v1/index.ts,src/routes/index.ts, and ultimatelysrc/app.ts. - Wrong HTTP Method: Ensure you’re using the correct HTTP method (e.g.,
GETfor afastify.get()route).
- Typo in URL: Check the URL you’re requesting against the defined route paths (e.g.,
- 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/fastifyor 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 forRequest,Reply,Params,Querystring, andBody.
- Missing Type Definitions: You might have forgotten to install
- Solution:
- Install Types: Always install type definitions for any library (
npm install --save-dev @types/library-name). - 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.
- Install Types: Always install type definitions for any library (
6. Testing & Verification
Let’s ensure everything we’ve built in this chapter is working as expected.
Start the server:
npm run devVerify that the server starts without errors and logs
Server listening on 0.0.0.0:3000.Test the health check endpoint:
curl http://localhost:3000/health- Expected Response:
{"status":"ok","uptime":...} - Expected Logs:
Incoming requestandHealth check endpoint hit.
- Expected Response:
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 requestandStatus endpoint hit.
- Expected Response:
Test the v1 greet endpoint with a name:
curl http://localhost:3000/api/v1/greet/Sarah- Expected Response:
{"message":"Hello, Sarah!"} - Expected Logs:
Incoming requestandGreet endpoint hit for name: Sarah.
- Expected Response:
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, andCaught error in global error handler(with error details including stack trace).
- Expected Response:
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.