Welcome to Chapter 9 of our Node.js backend journey! In this chapter, we’re going to significantly enhance the robustness and maintainability of our API by implementing three critical pillars of production-ready applications: advanced data validation, centralized error handling, and structured logging. These components are often overlooked in initial development but are absolutely essential for building resilient, observable, and debuggable systems.

We’ve already laid the groundwork with basic routing, authentication, and database integration. Now, we’ll elevate our application’s quality by preventing invalid data from reaching our business logic, gracefully handling all types of errors, and providing clear, actionable insights into our application’s behavior through logs. By the end of this chapter, our API will be far more secure against malformed requests, provide consistent and helpful error responses to clients, and offer developers a powerful tool for monitoring and debugging.

This chapter builds directly upon the project structure and authentication mechanisms established in previous chapters. We will integrate these new features into our existing Fastify application, ensuring that every incoming request is validated, every error is caught and processed, and every significant event is logged. The expected outcome is a more stable, secure, and easier-to-manage API, ready for the challenges of a production environment.

Planning & Design

Before diving into the code, let’s outline the architectural changes and additions we’ll be making. This will help us understand where each new component fits into our existing Fastify application.

Component Architecture

Our goal is to create a clear flow for requests, ensuring validation occurs early and errors are handled uniformly. Logging will be pervasive, capturing events at various stages.

flowchart TD Client[Client Request] --> Fastify[Fastify Server] subgraph Request_Processing["Request Processing"] Fastify --->|Pre handler Hook| LoggerInit[Initialize Logger] Fastify --->|Pre handler Hook| RequestLogger[Log Incoming Request] Fastify --->|Pre handler Hook| ValidationMiddleware[Zod Validation] ValidationMiddleware --->|Invalid Data| ErrorHandler[Centralized Error Handler] ValidationMiddleware --->|Valid Data| RouteHandler[Route Handler] RouteHandler --> ServiceLayer[Service Layer] ServiceLayer --> Database[Database] RouteHandler --->|Throws Error| ErrorHandler ServiceLayer --->|Throws Error| ErrorHandler ErrorHandler --->|Log Error| ErrorLogger[Error Logger] ErrorHandler --> ClientResponse[Client Error Response] RouteHandler --->|Successful Response| ResponseLogger[Log Outgoing Response] ResponseLogger --> Client[Client Response] style ErrorHandler fill:#f9f,stroke:#333,stroke-width:2px style ErrorLogger fill:#f9f,stroke:#333,stroke-width:2px end

**Explanation:**
1.  **Client Request:** An incoming HTTP request.
2.  **Fastify Server:** Our core application framework.
3.  **Logger Initialization & Request Logging:** Early hooks to attach a logger to the request and log initial request details.
4.  **Validation Middleware:** A pre-handler hook that uses Zod schemas to validate incoming request bodies, query parameters, or headers. If validation fails, it immediately triggers the error handler.
5.  **Route Handler & Service Layer:** Our existing business logic. These layers will now be designed to throw specific custom errors when expected conditions are not met.
6.  **Database:** Our data persistence layer.
7.  **Centralized Error Handler:** A global Fastify plugin that catches all errors thrown anywhere in the application. It differentiates between known, custom errors and unexpected system errors.
8.  **Error Logger:** Logs detailed error information, especially for unexpected errors, to aid debugging.
9.  **Client Error Response:** The standardized error response sent back to the client.
10. **Response Logger:** Logs successful outgoing responses.
11. **Client Response:** The final response to the client.

#### File Structure Enhancements

We'll be adding several new files and modifying existing ones to accommodate these features:

src/ ├── app.ts ├── config/ │ └── index.ts ├── errors/ │ └── httpErrors.ts # Custom HTTP-specific error classes ├── plugins/ │ ├── auth.ts │ ├── errorHandler.ts # Centralized error handler plugin │ └── logger.ts # Pino logger configuration and plugin ├── routes/ │ ├── authRoutes.ts │ └── userRoutes.ts # Will modify to use validation and error handling ├── services/ │ ├── authService.ts │ └── userService.ts # Will modify to throw custom errors ├── utils/ │ ├── validation/ │ │ └── userSchemas.ts # Zod schemas for user-related data │ └── … └── server.ts


### Step-by-Step Implementation

Let's start by installing the necessary dependencies and then implement each component incrementally.

#### 3.1. Setup Dependencies

We'll need `zod` for validation and `pino` along with `pino-pretty` for logging.

```bash
npm install zod pino
npm install --save-dev pino-pretty

Explanation:

  • zod: A TypeScript-first schema declaration and validation library. It’s highly performant, type-safe, and provides excellent developer experience.
  • pino: An extremely fast Node.js logger. It’s designed for low overhead and structured logging, which is crucial for production environments.
  • pino-pretty: A development-time tool that formats Pino’s JSON output into a human-readable format. We’ll only use this in development.

3.2. Structured Logging with Pino

First, we’ll set up our logger as a Fastify plugin.

a) Setup/Configuration

Create a new file src/plugins/logger.ts.

b) Core Implementation

// src/plugins/logger.ts
import { FastifyInstance, FastifyPluginAsync } from 'fastify';
import fp from 'fastify-plugin';
import pino from 'pino';
import { isProduction } from '../config'; // Assuming you have an `isProduction` flag in your config

declare module 'fastify' {
  interface FastifyInstance {
    log: pino.Logger; // Extend FastifyInstance with a pino logger
  }
}

const loggerPlugin: FastifyPluginAsync = async (fastify: FastifyInstance) => {
  const logger = pino({
    level: process.env.LOG_LEVEL || (isProduction ? 'info' : 'debug'), // Log level from env or default
    formatters: {
      level(label) {
        return { level: label }; // Format level as an object
      },
    },
    timestamp: pino.stdTimeFunctions.isoTime, // ISO 8601 timestamp
    // Conditionally use pino-pretty for development
    transport: isProduction
      ? undefined
      : {
          target: 'pino-pretty',
          options: {
            colorize: true,
            translateTime: 'SYS:HH:MM:ss Z',
            ignore: 'pid,hostname',
          },
        },
  });

  fastify.decorate('log', logger); // Decorate Fastify instance with our logger

  // Hook to log incoming requests
  fastify.addHook('onRequest', (request, reply, done) => {
    request.log.info({
      method: request.method,
      url: request.url,
      ip: request.ip,
    }, 'Incoming request');
    done();
  });

  // Hook to log outgoing responses
  fastify.addHook('onResponse', (request, reply, done) => {
    request.log.info({
      method: request.method,
      url: request.url,
      statusCode: reply.statusCode,
      responseTime: reply.getResponseTime(),
    }, 'Outgoing response');
    done();
  });

  // Log server startup
  fastify.log.info(`Logger initialized. Environment: ${isProduction ? 'Production' : 'Development'}`);
};

export default fp(loggerPlugin, {
  name: 'logger-plugin',
  dependencies: [], // No direct dependencies on other plugins for logger init
});

Explanation:

  • declare module 'fastify': This extends the FastifyInstance interface so TypeScript knows that fastify.log will be available and its type is pino.Logger.
  • pino({...}): We configure Pino.
    • level: Sets the minimum log level. info for production, debug for development, configurable via LOG_LEVEL environment variable.
    • formatters: Customizes how log levels appear.
    • timestamp: Uses ISO 8601 format for timestamps, which is standard and easy to parse by log aggregators.
    • transport: Crucially, pino-pretty is only enabled in development (isProduction ? undefined : {...}). In production, Pino logs raw JSON, which is ideal for ingestion by log aggregation services (e.g., ELK stack, Datadog, CloudWatch Logs).
  • fastify.decorate('log', logger): This makes our configured Pino logger instance available directly on the fastify object as fastify.log.
  • fastify.addHook('onRequest', ...): This hook runs at the beginning of each request. We log details like method, URL, and IP.
  • fastify.addHook('onResponse', ...): This hook runs when a response is sent. We log status code and response time.
  • fp(loggerPlugin, { name: 'logger-plugin' }): Wraps our plugin with fastify-plugin to ensure it’s loaded once and its decorators/hooks are available to other plugins.

Integrating isProduction: Make sure your src/config/index.ts has an isProduction flag:

// src/config/index.ts
export const NODE_ENV = process.env.NODE_ENV || 'development';
export const isProduction = NODE_ENV === 'production';
export const PORT = parseInt(process.env.PORT || '3000', 10);
// ... other configurations

Register the Logger Plugin: Now, register this plugin in your main application file, typically src/app.ts or src/server.ts.

// src/app.ts (or src/server.ts, depending on your setup)
import fastify from 'fastify';
import loggerPlugin from './plugins/logger'; // Import our logger plugin
// ... other imports

const app = fastify({
  // Optionally disable Fastify's default logger if you want full control with Pino
  // logger: false,
});

// Register the logger plugin FIRST, so it's available for subsequent plugins and routes
app.register(loggerPlugin);

// ... register other plugins and routes
// Example:
// app.register(authPlugin);
// app.register(userRoutes, { prefix: '/users' });

export default app;

c) Testing This Component

  1. Start your application: npm run dev (or node src/server.ts).
  2. Observe console output: You should see a log message indicating the logger was initialized.
  3. Make a request: Use curl or Postman to hit any existing endpoint (e.g., GET /health if you have one, or GET /users if authenticated).
    curl http://localhost:3000/health
    
  4. Check logs: You should see Incoming request and Outgoing response logs in your console, formatted nicely by pino-pretty.

Debugging Tips:

  • If no logs appear, ensure loggerPlugin is registered correctly and before any other route or plugin that might be hit.
  • Check your LOG_LEVEL environment variable. If set too high (e.g., error), you won’t see info or debug messages.
  • Verify pino-pretty is installed and working in development. If you see raw JSON output, it might not be configured correctly or isProduction is true unexpectedly.

3.3. Advanced Validation with Zod

We’ll use Zod to define schemas for our request bodies and then create a utility to apply these schemas.

a) Setup/Configuration

Create a new directory src/utils/validation/ and inside it, a file userSchemas.ts.

b) Core Implementation

First, define a Zod schema for creating a user.

// src/utils/validation/userSchemas.ts
import { z } from 'zod';

// Schema for creating a new user
export const createUserSchema = z.object({
  username: z.string().min(3, 'Username must be at least 3 characters long').max(20, 'Username cannot exceed 20 characters'),
  email: z.string().email('Invalid email address'),
  password: z.string().min(8, 'Password must be at least 8 characters long')
    .regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
    .regex(/[a-z]/, 'Password must contain at least one lowercase letter')
    .regex(/[0-9]/, 'Password must contain at least one number')
    .regex(/[^a-zA-Z0-9]/, 'Password must contain at least one special character'),
  role: z.enum(['user', 'admin']).default('user'), // Assuming roles like 'user' or 'admin'
});

// Schema for updating an existing user (all fields optional)
export const updateUserSchema = z.object({
  username: z.string().min(3, 'Username must be at least 3 characters long').max(20, 'Username cannot exceed 20 characters').optional(),
  email: z.string().email('Invalid email address').optional(),
  role: z.enum(['user', 'admin']).optional(),
}).partial(); // .partial() makes all fields optional

Explanation:

  • z.object({...}): Defines the shape of our object.
  • z.string(): Expects a string.
  • .min(), .max(), .email(), .regex(): Provide powerful validation rules. Custom error messages are given for better client feedback.
  • z.enum(): Restricts a string to a predefined set of values.
  • .default('user'): Provides a default value if not supplied.
  • .optional(): Marks a field as optional.
  • .partial(): A Zod utility that makes all fields in an object schema optional, useful for update operations.

Next, let’s create a Fastify pre-handler function to apply these schemas.

// src/utils/validation/index.ts (create this new file)
import { FastifyReply, FastifyRequest, HookHandlerDoneFunction } from 'fastify';
import { ZodSchema, ZodError } from 'zod';
import { BadRequestError } from '../../errors/httpErrors'; // Will create this next

export const validate = (schema: ZodSchema) =>
  async (request: FastifyRequest, reply: FastifyReply, done: HookHandlerDoneFunction) => {
    try {
      request.body = await schema.parseAsync(request.body); // Validate and parse
      done();
    } catch (error) {
      if (error instanceof ZodError) {
        // Transform ZodError into a more client-friendly format
        const errors = error.errors.map(err => ({
          path: err.path.join('.'),
          message: err.message,
        }));
        request.log.warn({ validationErrors: errors, body: request.body }, 'Validation failed for request body');
        // Throw a BadRequestError, which our centralized error handler will catch
        done(new BadRequestError('Validation Failed', errors));
      } else {
        request.log.error({ error }, 'Unknown error during validation');
        done(error); // Re-throw other unexpected errors
      }
    }
  };

Explanation:

  • validate(schema: ZodSchema): This is a higher-order function that takes a Zod schema and returns a Fastify pre-handler hook.
  • schema.parseAsync(request.body): This is the core Zod validation. It attempts to parse and validate request.body against the provided schema. parseAsync is used because Zod schemas can include asynchronous refinements, though ours are synchronous here.
  • Error Handling:
    • If schema.parseAsync fails, it throws a ZodError.
    • We catch ZodError, transform it into a cleaner array of path and message, and then log it with request.log.warn.
    • Crucially, we then done(new BadRequestError(...)) which will be caught by our centralized error handler. This ensures consistent error responses.
    • Any other unexpected errors during validation are simply re-thrown.

Applying Validation to a Route: Now, let’s modify an existing route, for example, src/routes/userRoutes.ts, to use this validation.

// src/routes/userRoutes.ts (modified)
import { FastifyInstance, FastifyPluginAsync, FastifyRequest, FastifyReply } from 'fastify';
import { createUserSchema, updateUserSchema } from '../utils/validation/userSchemas';
import { validate } from '../utils/validation';
import * as userService from '../services/userService'; // Assuming you have a userService
import { authenticate, authorize } from '../plugins/auth'; // From previous chapters

interface CreateUserRequestBody {
  username: string;
  email: string;
  password?: string; // Password is required by schema, but type might allow optional for partial updates
  role?: 'user' | 'admin';
}

interface UpdateUserRequestBody {
  username?: string;
  email?: string;
  role?: 'user' | 'admin';
}

const userRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => {

  // Route for creating a new user (e.g., registration)
  fastify.post<{ Body: CreateUserRequestBody }>(
    '/',
    {
      preHandler: [validate(createUserSchema), authenticate, authorize(['admin'])], // Admins create users
    },
    async (request, reply) => {
      const { username, email, password, role } = request.body;
      try {
        const newUser = await userService.createUser(username, email, password!, role); // Password is guaranteed by validation
        request.log.info({ userId: newUser.id, username: newUser.username }, 'User created successfully');
        reply.status(201).send({ message: 'User created successfully', user: newUser });
      } catch (error) {
        request.log.error({ error }, 'Error creating user');
        throw error; // Let centralized error handler catch this
      }
    }
  );

  // Route for updating an existing user
  fastify.put<{ Params: { id: string }, Body: UpdateUserRequestBody }>(
    '/:id',
    {
      preHandler: [authenticate, authorize(['admin']), validate(updateUserSchema)], // Admins update users
    },
    async (request, reply) => {
      const { id } = request.params;
      const updates = request.body; // Updates are already validated
      try {
        const updatedUser = await userService.updateUser(id, updates);
        if (!updatedUser) {
          // This should ideally be a NotFoundError, which we'll define next
          request.log.warn({ userId: id }, 'Attempted to update non-existent user');
          reply.status(404).send({ message: 'User not found' });
          return;
        }
        request.log.info({ userId: id }, 'User updated successfully');
        reply.send({ message: 'User updated successfully', user: updatedUser });
      } catch (error) {
        request.log.error({ error, userId: id }, 'Error updating user');
        throw error;
      }
    }
  );

  // ... other user routes (GET, DELETE)
};

export default userRoutes;

c) Testing This Component

Prerequisites: Ensure your authPlugin and authenticate, authorize functions from previous chapters are working and registered. You’ll need an admin token to test the user creation/update routes.

  1. Start your application.
  2. Test successful user creation:
    # Replace <YOUR_ADMIN_TOKEN> with a valid admin JWT
    curl -X POST -H "Content-Type: application/json" \
         -H "Authorization: Bearer <YOUR_ADMIN_TOKEN>" \
         -d '{"username": "testuser", "email": "test@example.com", "password": "Password123!"}' \
         http://localhost:3000/users
    
    Expected: 201 Created response with user data.
  3. Test validation failure (missing field):
    curl -X POST -H "Content-Type: application/json" \
         -H "Authorization: Bearer <YOUR_ADMIN_TOKEN>" \
         -d '{"username": "short", "email": "invalid-email"}' \
         http://localhost:3000/users
    
    Expected: 400 Bad Request with an error message indicating validation failures (e.g., username too short, invalid email, missing password). The exact error response format will be refined by our centralized error handler next.
  4. Test validation failure (invalid password):
    curl -X POST -H "Content-Type: application/json" \
         -H "Authorization: Bearer <YOUR_ADMIN_TOKEN>" \
         -d '{"username": "newuser", "email": "new@example.com", "password": "short"}' \
         http://localhost:3000/users
    
    Expected: 400 Bad Request with an error message about password complexity.

Debugging Tips:

  • If you get a 500 Internal Server Error instead of 400 Bad Request for validation failures, it means your BadRequestError or centralized error handler isn’t set up yet or isn’t catching the ZodError properly. We’ll address this in the next section.
  • Check server logs for Validation failed for request body warnings.

3.4. Centralized Error Handling

This is a crucial step for providing consistent API responses and robust error management.

a) Setup/Configuration

Create a new directory src/errors/ and inside it, a file httpErrors.ts.

b) Core Implementation

First, define custom HTTP-specific error classes.

// src/errors/httpErrors.ts
interface CustomErrorDetails {
  [key: string]: any;
}

export class HttpError extends Error {
  public statusCode: number;
  public isOperational: boolean; // Indicates if this is a known, expected error
  public details?: CustomErrorDetails | any[];

  constructor(message: string, statusCode: number, isOperational: boolean = true, details?: CustomErrorDetails | any[]) {
    super(message);
    this.name = this.constructor.name; // Set the error name to the class name
    this.statusCode = statusCode;
    this.isOperational = isOperational;
    this.details = details; // Optional additional details (e.g., validation errors)

    // Capture stack trace, excluding constructor call from it
    Error.captureStackTrace(this, this.constructor);
  }
}

export class BadRequestError extends HttpError {
  constructor(message: string = 'Bad Request', details?: CustomErrorDetails | any[]) {
    super(message, 400, true, details);
  }
}

export class UnauthorizedError extends HttpError {
  constructor(message: string = 'Unauthorized') {
    super(message, 401, true);
  }
}

export class ForbiddenError extends HttpError {
  constructor(message: string = 'Forbidden') {
    super(message, 403, true);
  }
}

export class NotFoundError extends HttpError {
  constructor(message: string = 'Not Found') {
    super(message, 404, true);
  }
}

export class ConflictError extends HttpError {
  constructor(message: string = 'Conflict', details?: CustomErrorDetails | any[]) {
    super(message, 409, true, details);
  }
}

// Add other common HTTP errors as needed

Explanation:

  • HttpError: A base class for all our custom HTTP errors.
    • statusCode: The HTTP status code to send to the client.
    • isOperational: A crucial flag. true means this is an expected, handled error (e.g., validation fail, resource not found). false means it’s an unexpected, catastrophic error (e.g., database connection lost). This helps us decide whether to expose details to the client and how urgently to alert developers.
    • details: An optional field to include more specific information, like the array of validation errors from Zod.
    • Error.captureStackTrace: Improves stack trace readability by removing the constructor call itself.
  • Specific Error Classes: We extend HttpError for common HTTP status codes like BadRequestError, UnauthorizedError, NotFoundError, etc., pre-setting their statusCode and isOperational flags.

Next, create the centralized error handler plugin src/plugins/errorHandler.ts.

// src/plugins/errorHandler.ts
import { FastifyInstance, FastifyPluginAsync, FastifyError } from 'fastify';
import fp from 'fastify-plugin';
import { HttpError } from '../errors/httpErrors';
import { isProduction } from '../config';

const errorHandlerPlugin: FastifyPluginAsync = async (fastify: FastifyInstance) => {
  fastify.setErrorHandler((error: FastifyError, request, reply) => {
    let statusCode = 500;
    let message = 'Internal Server Error';
    let details: any | undefined = undefined; // Optional details for the client

    if (error instanceof HttpError) {
      // Handle known, operational errors
      statusCode = error.statusCode;
      message = error.message;
      details = error.details;
      request.log.warn({ error: error.name, statusCode, message, details, stack: error.stack }, 'Handled HTTP Error');
    } else if (error.validation) {
      // Fastify's built-in validation errors (e.g., schema validation on routes)
      statusCode = 400;
      message = 'Validation Failed';
      details = error.validation.map(err => ({
        path: err.dataPath || err.instancePath,
        message: err.message,
        keyword: err.keyword,
      }));
      request.log.warn({ error: 'FastifyValidation', statusCode, message, details }, 'Fastify validation error');
    } else {
      // Handle unexpected, non-operational errors
      request.log.error({ error: error.name, message: error.message, stack: error.stack, url: request.url, method: request.method }, 'Unhandled Error');
      // In production, mask generic errors
      if (isProduction) {
        message = 'Something unexpected happened. Please try again later.';
        details = undefined; // Do not expose internal error details in production
      }
    }

    // Always send back a JSON error response
    reply.status(statusCode).send({
      statusCode,
      error: error.name || 'Error',
      message,
      ...(details && { details }), // Only include details if present
      timestamp: new Date().toISOString(),
      path: request.url,
    });
  });

  fastify.log.info('Centralized error handler registered.');
};

export default fp(errorHandlerPlugin, {
  name: 'error-handler-plugin',
  dependencies: ['logger-plugin'], // Ensure logger is available
});

Explanation:

  • fastify.setErrorHandler((error, request, reply) => { ... }): This is Fastify’s global error handler. Any error thrown (or passed to done(error)) in a route or hook will be caught here.
  • if (error instanceof HttpError): This checks if the error is one of our custom, operational HTTP errors. If so, we use its predefined statusCode, message, and details. We log these as warn because they are expected.
  • else if (error.validation): Fastify has its own schema validation for route options (schema: { body: ... }). If you use that, Fastify attaches a validation property to the error. We handle this specifically to provide good error messages.
  • else: This block catches all other errors, which are typically unexpected system errors (e.g., database connection issues, bugs in code).
    • We log these as error level, including the stack trace, for immediate attention.
    • Production Safety: In production (isProduction), we mask the error message to a generic, user-friendly message to prevent sensitive internal details from leaking.
  • Standardized Response: Regardless of the error type, we always send a consistent JSON response containing statusCode, error name, message, optional details, timestamp, and path.

Register the Error Handler Plugin: Register this plugin in src/app.ts after the logger, as it depends on the logger.

// src/app.ts (or src/server.ts)
import fastify from 'fastify';
import loggerPlugin from './plugins/logger';
import errorHandlerPlugin from './plugins/errorHandler'; // Import our error handler plugin
import userRoutes from './routes/userRoutes'; // Our modified user routes
// ... other imports

const app = fastify({
  // logger: false, // Keep Fastify's default logger false if using Pino
});

app.register(loggerPlugin);
app.register(errorHandlerPlugin); // Register the error handler AFTER logger

// Register routes
app.register(userRoutes, { prefix: '/users' });

export default app;

c) Testing This Component

  1. Start your application.

  2. Test Zod Validation Error (as before):

    curl -X POST -H "Content-Type: application/json" \
         -H "Authorization: Bearer <YOUR_ADMIN_TOKEN>" \
         -d '{"username": "sh", "email": "invalid"}' \
         http://localhost:3000/users
    

    Expected: 400 Bad Request with a JSON body like:

    {
      "statusCode": 400,
      "error": "BadRequestError",
      "message": "Validation Failed",
      "details": [
        { "path": "username", "message": "Username must be at least 3 characters long" },
        { "path": "email", "message": "Invalid email address" },
        // ... missing password errors
      ],
      "timestamp": "...",
      "path": "/users"
    }
    

    Also, check your server logs for a Handled HTTP Error warning.

  3. Test a NotFoundError (modify userService): Let’s simulate a NotFoundError in our userService.ts (if you have one).

    // src/services/userService.ts (modified example)
    import { NotFoundError, ConflictError } from '../errors/httpErrors'; // Import custom errors
    import { db } from '../db'; // Assuming you have a database connection
    
    export const createUser = async (username: string, email: string, passwordHash: string, role: string) => {
      // Simulate checking for existing user
      const existingUser = await db.user.findUnique({ where: { email } });
      if (existingUser) {
        throw new ConflictError('User with this email already exists');
      }
      // ... actual creation logic
      return { id: 'new-user-id', username, email, role }; // Dummy return
    };
    
    export const updateUser = async (id: string, updates: any) => {
      // Simulate finding a user
      const user = await db.user.findUnique({ where: { id } });
      if (!user) {
        throw new NotFoundError(`User with ID ${id} not found`); // Throw NotFoundError
      }
      // ... actual update logic
      return { ...user, ...updates }; // Dummy return
    };
    
    export const getUserById = async (id: string) => {
      const user = await db.user.findUnique({ where: { id } });
      if (!user) {
        throw new NotFoundError(`User with ID ${id} not found`);
      }
      return user;
    };
    // ... other service methods
    

    Now, test GET /users/:id with a non-existent ID (assuming you have such a route):

    curl -H "Authorization: Bearer <YOUR_ADMIN_TOKEN>" http://localhost:3000/users/non-existent-id-123
    

    Expected: 404 Not Found with a JSON body like:

    {
      "statusCode": 404,
      "error": "NotFoundError",
      "message": "User with ID non-existent-id-123 not found",
      "timestamp": "...",
      "path": "/users/non-existent-id-123"
    }
    

    Check server logs for a Handled HTTP Error warning.

  4. Test an unhandled exception: Temporarily modify a route handler to throw a generic error:

    // src/routes/userRoutes.ts (TEMPORARY MODIFICATION FOR TESTING)
    fastify.get<{ Params: { id: string } }>(
      '/:id',
      { preHandler: [authenticate, authorize(['admin', 'user'])] },
      async (request, reply) => {
        // ... existing code
        throw new Error('Something truly unexpected happened!'); // Simulate unhandled error
        // ...
      }
    );
    

    Make a request to that route:

    curl -H "Authorization: Bearer <YOUR_ADMIN_TOKEN>" http://localhost:3000/users/some-valid-id
    

    Expected: 500 Internal Server Error. In development: You might see the original error message and stack trace. In production (NODE_ENV=production): You should see the generic message: "Something unexpected happened. Please try again later." Crucially, check server logs for an Unhandled Error message at error level, including the stack trace.

Debugging Tips:

  • Ensure errorHandlerPlugin is registered after loggerPlugin.
  • Verify your HttpError classes are correctly imported and used when throwing errors from services or routes.
  • If generic 500 errors appear instead of specific 4xx errors for known conditions, double-check if your HttpError hierarchy and instanceof checks in the error handler are correct.

3.5. Integrating All Components

At this point, you should have:

  • src/plugins/logger.ts registered first in src/app.ts.
  • src/plugins/errorHandler.ts registered second in src/app.ts.
  • src/errors/httpErrors.ts defining your custom error classes.
  • src/utils/validation/userSchemas.ts defining Zod schemas.
  • src/utils/validation/index.ts with the validate pre-handler.
  • src/routes/userRoutes.ts (and potentially src/services/userService.ts) updated to use validate and throw HttpError instances.

Your src/app.ts should look similar to this:

// src/app.ts
import fastify from 'fastify';
import fastifyHelmet from '@fastify/helmet'; // From previous security chapter
import fastifyCors from '@fastify/cors';     // From previous security chapter
import loggerPlugin from './plugins/logger';
import errorHandlerPlugin from './plugins/errorHandler';
import authPlugin from './plugins/auth'; // Authentication plugin
import userRoutes from './routes/userRoutes';
import authRoutes from './routes/authRoutes';
import { PORT, NODE_ENV, isProduction } from './config';

const app = fastify({
  // Disable Fastify's default logger if you want full control with Pino
  logger: false,
  // Add request ID generation for better tracing
  genReqId: (req) => req.headers['x-request-id'] as string || Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15),
});

// Register plugins
app.register(fastifyHelmet);
app.register(fastifyCors, {
  origin: isProduction ? /your-frontend-domain\.com$/ : '*', // Adjust for your frontend
  credentials: true,
});

// IMPORTANT: Register logger and error handler early
app.register(loggerPlugin);
app.register(errorHandlerPlugin);

// Register authentication plugin (depends on logger)
app.register(authPlugin);

// Register routes
app.register(authRoutes, { prefix: '/auth' });
app.register(userRoutes, { prefix: '/users' });

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

// Catch-all for undefined routes
app.all('*', async (request, reply) => {
  request.log.warn({ url: request.url, method: request.method }, 'Route not found');
  reply.status(404).send({
    statusCode: 404,
    error: 'Not Found',
    message: `Route ${request.method}:${request.url} not found`,
    timestamp: new Date().toISOString(),
    path: request.url,
  });
});

const start = async () => {
  try {
    await app.listen({ port: PORT, host: '0.0.0.0' });
    app.log.info(`Server listening on ${PORT} in ${NODE_ENV} mode`);
  } catch (err) {
    app.log.fatal({ err }, 'Server failed to start');
    process.exit(1);
  }
};

if (require.main === module) {
  start();
}

export default app;

Production Considerations

  • Error Reporting Services: For production, integrate with external error tracking tools like Sentry, Bugsnag, or AWS CloudWatch Alarms. These tools can automatically capture unhandled exceptions, group them, and notify developers. Our isOperational flag in HttpError is excellent for deciding which errors to report (only non-operational ones).
  • Log Aggregation: In production, Pino’s JSON output should be shipped to a centralized log aggregation system (e.g., ELK Stack, Datadog, Splunk, AWS CloudWatch Logs, Google Cloud Logging). This allows for searching, filtering, monitoring, and alerting on logs across all instances of your application.
  • Log Levels: Carefully manage log levels. In production, info or warn is often sufficient. debug and trace should be used sparingly and only when actively debugging an issue, as they generate a lot of data.
  • Sensitive Data in Logs: NEVER log sensitive information like passwords, API keys, or personally identifiable information (PII). Review your logging statements to ensure no such data is inadvertently captured. Pino offers redaction capabilities if needed.
  • Performance of Validation: Zod is highly performant, but complex schemas or very large request bodies can add overhead. For extremely high-throughput APIs, consider pre-compiling schemas or optimizing validation pathways, though this is rarely necessary for typical web APIs.

Code Review Checkpoint

At this point, we’ve significantly upgraded our application’s error handling, logging, and data integrity.

Files Created/Modified:

  • src/plugins/logger.ts: New plugin for structured logging with Pino.
  • src/plugins/errorHandler.ts: New plugin for centralized error handling.
  • src/errors/httpErrors.ts: New file defining custom HTTP error classes.
  • src/utils/validation/userSchemas.ts: New file with Zod schemas for user data.
  • src/utils/validation/index.ts: New file with the validate pre-handler utility.
  • src/config/index.ts: Modified to include isProduction flag.
  • src/app.ts: Modified to register loggerPlugin and errorHandlerPlugin.
  • src/routes/userRoutes.ts: Modified to use validate middleware and potentially throw custom HttpError instances.
  • src/services/userService.ts (example): Modified to throw custom HttpError instances (e.g., NotFoundError, ConflictError).
  • package.json: Updated with zod, pino, pino-pretty dependencies.

Integration Summary: The loggerPlugin provides a Fastify instance-wide logger, logging requests and responses. The errorHandlerPlugin catches all errors, providing a consistent JSON response and logging unhandled errors. The validate utility, powered by Zod schemas, ensures incoming data meets our requirements, throwing BadRequestError on failure, which is then caught by our centralized error handler. Our service layer now throws specific HttpError types for known business logic failures, further streamlining error responses.

Common Issues & Solutions

  1. Issue: Validation errors are not returning 400 Bad Request but 500 Internal Server Error.

    • Reason: The ZodError is not being correctly transformed into an HttpError (specifically BadRequestError) or the errorHandlerPlugin is not catching HttpError instances.
    • Solution:
      • Verify that src/utils/validation/index.ts correctly done(new BadRequestError(...)) when a ZodError is caught.
      • Ensure src/plugins/errorHandler.ts correctly checks if (error instanceof HttpError) and processes it.
      • Check that errorHandlerPlugin is registered after loggerPlugin in src/app.ts.
  2. Issue: Logs are not appearing in the console, or are appearing as raw JSON in development.

    • Reason: The loggerPlugin is not registered, or pino-pretty is not installed/configured correctly for development.
    • Solution:
      • Confirm app.register(loggerPlugin); is present and at the very beginning of your plugin registrations in src/app.ts.
      • Check pino-pretty is in devDependencies and installed (npm install).
      • Verify the isProduction flag logic in src/config/index.ts and src/plugins/logger.ts to ensure pino-pretty is conditionally enabled.
  3. Issue: My custom HttpError messages are not being returned to the client, or generic 500 errors are shown even for known 4xx conditions.

    • Reason: The errorHandlerPlugin might be masking error messages due to isProduction being true, or the HttpError is not being properly recognized.
    • Solution:
      • Double-check the isProduction flag. If you are in development, ensure NODE_ENV is not set to production.
      • Verify that error instanceof HttpError is correctly evaluated in src/plugins/errorHandler.ts. Ensure your custom error classes (BadRequestError, NotFoundError, etc.) correctly extend HttpError.

Testing & Verification

To verify that all the components from this chapter are working correctly, perform the following tests:

  1. Start your application in development mode:

    NODE_ENV=development npm run dev
    
  2. Verify Logging:

    • Make a GET request to your health endpoint: curl http://localhost:3000/health
    • Expected: You should see Incoming request and Outgoing response logs in your console, formatted nicely by pino-pretty. You should also see Health check received from the route handler.
  3. Verify Zod Validation with BadRequestError:

    • Attempt to create a user with invalid data (e.g., short username, invalid email, weak password, or missing fields). You will need an admin token.
      curl -X POST -H "Content-Type: application/json" \
           -H "Authorization: Bearer <YOUR_ADMIN_TOKEN>" \
           -d '{"username": "a", "email": "bad", "password": "123"}' \
           http://localhost:3000/users
      
    • Expected Client Response (400 Bad Request):
      {
        "statusCode": 400,
        "error": "BadRequestError",
        "message": "Validation Failed",
        "details": [ /* array of specific validation errors */ ],
        "timestamp": "...",
        "path": "/users"
      }
      
    • Expected Server Logs: A warn level log for Handled HTTP Error indicating the validation failure.
  4. Verify NotFoundError:

    • Attempt to fetch a user with a non-existent ID (assuming you have a GET /users/:id route that throws NotFoundError if the user is not found).
      curl -H "Authorization: Bearer <YOUR_ADMIN_TOKEN>" http://localhost:3000/users/nonexistent-uuid
      
    • Expected Client Response (404 Not Found):
      {
        "statusCode": 404,
        "error": "NotFoundError",
        "message": "User with ID nonexistent-uuid not found",
        "timestamp": "...",
        "path": "/users/nonexistent-uuid"
      }
      
    • Expected Server Logs: A warn level log for Handled HTTP Error.
  5. Verify Unhandled Error Handling:

    • Temporarily re-introduce throw new Error('Simulated unhandled error'); into one of your route handlers.
    • Make a request to that route.
    • Expected Client Response (500 Internal Server Error):
      {
        "statusCode": 500,
        "error": "Error",
        "message": "Internal Server Error",
        "timestamp": "...",
        "path": "/your-test-route"
      }
      
    • Expected Server Logs: An error level log for Unhandled Error, including the stack trace.
  6. Verify Production Behavior:

    • Stop your application.
    • Start it in production mode: NODE_ENV=production npm run dev (or npm start if configured).
    • Repeat step 5 (unhandled error).
    • Expected Client Response (500 Internal Server Error):
      {
        "statusCode": 500,
        "error": "Error",
        "message": "Something unexpected happened. Please try again later.", // Generic message
        "timestamp": "...",
        "path": "/your-test-route"
      }
      
    • Expected Server Logs: The error level log for Unhandled Error will still be present, but the client response will be generic. Logs will be in JSON format, not pretty-printed.

Summary & Next Steps

Congratulations! In this chapter, we’ve significantly matured our Node.js API by implementing essential production-grade features:

  • Structured Logging with Pino: We configured a high-performance logger, integrated it with Fastify, and set up request/response logging, ensuring our application’s behavior is observable and debuggable. We also learned how to use pino-pretty for development and raw JSON for production.
  • Advanced Validation with Zod: We leveraged the power of Zod to define robust, type-safe schemas for our incoming request bodies, preventing invalid data from corrupting our application logic. We created a reusable validation middleware for Fastify.
  • Centralized Error Handling: We designed and implemented custom HttpError classes and a global Fastify error handler. This ensures consistent, client-friendly error responses for known issues and robust, secure handling of unexpected system errors, with careful masking of details in production.

These additions make our API much more resilient, secure, and developer-friendly. We now have clear visibility into what’s happening and how to react when things go wrong.

In the next chapter, Chapter 10: Rate Limiting, CORS & Security Headers, we’ll further enhance the security and resilience of our API by implementing rate limiting to protect against abuse, fine-tuning CORS policies for secure cross-origin communication, and applying various security headers to mitigate common web vulnerabilities.