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.
**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 theFastifyInstanceinterface so TypeScript knows thatfastify.logwill be available and its type ispino.Logger.pino({...}): We configure Pino.level: Sets the minimum log level.infofor production,debugfor development, configurable viaLOG_LEVELenvironment 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-prettyis 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 thefastifyobject asfastify.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 withfastify-pluginto 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
- Start your application:
npm run dev(ornode src/server.ts). - Observe console output: You should see a log message indicating the logger was initialized.
- Make a request: Use
curlor Postman to hit any existing endpoint (e.g.,GET /healthif you have one, orGET /usersif authenticated).curl http://localhost:3000/health - Check logs: You should see
Incoming requestandOutgoing responselogs in your console, formatted nicely bypino-pretty.
Debugging Tips:
- If no logs appear, ensure
loggerPluginis registered correctly and before any other route or plugin that might be hit. - Check your
LOG_LEVELenvironment variable. If set too high (e.g.,error), you won’t seeinfoordebugmessages. - Verify
pino-prettyis installed and working in development. If you see raw JSON output, it might not be configured correctly orisProductionis 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 validaterequest.bodyagainst the providedschema.parseAsyncis used because Zod schemas can include asynchronous refinements, though ours are synchronous here.- Error Handling:
- If
schema.parseAsyncfails, it throws aZodError. - We catch
ZodError, transform it into a cleaner array ofpathandmessage, and then log it withrequest.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.
- If
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.
- Start your application.
- Test successful user creation:Expected:
# 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/users201 Createdresponse with user data. - Test validation failure (missing field):Expected:
curl -X POST -H "Content-Type: application/json" \ -H "Authorization: Bearer <YOUR_ADMIN_TOKEN>" \ -d '{"username": "short", "email": "invalid-email"}' \ http://localhost:3000/users400 Bad Requestwith 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. - Test validation failure (invalid password):Expected:
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/users400 Bad Requestwith an error message about password complexity.
Debugging Tips:
- If you get a
500 Internal Server Errorinstead of400 Bad Requestfor validation failures, it means yourBadRequestErroror centralized error handler isn’t set up yet or isn’t catching theZodErrorproperly. We’ll address this in the next section. - Check server logs for
Validation failed for request bodywarnings.
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.truemeans this is an expected, handled error (e.g., validation fail, resource not found).falsemeans 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
HttpErrorfor common HTTP status codes likeBadRequestError,UnauthorizedError,NotFoundError, etc., pre-setting theirstatusCodeandisOperationalflags.
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 todone(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 predefinedstatusCode,message, anddetails. We log these aswarnbecause they are expected.else if (error.validation): Fastify has its own schema validation for route options (schema: { body: ... }). If you use that, Fastify attaches avalidationproperty 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
errorlevel, 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.
- We log these as
- Standardized Response: Regardless of the error type, we always send a consistent JSON response containing
statusCode,errorname,message, optionaldetails,timestamp, andpath.
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
Start your application.
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/usersExpected:
400 Bad Requestwith 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 Errorwarning.Test a
NotFoundError(modifyuserService): Let’s simulate aNotFoundErrorin ouruserService.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 methodsNow, test
GET /users/:idwith a non-existent ID (assuming you have such a route):curl -H "Authorization: Bearer <YOUR_ADMIN_TOKEN>" http://localhost:3000/users/non-existent-id-123Expected:
404 Not Foundwith 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 Errorwarning.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-idExpected:
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 anUnhandled Errormessage aterrorlevel, including the stack trace.
Debugging Tips:
- Ensure
errorHandlerPluginis registered afterloggerPlugin. - Verify your
HttpErrorclasses are correctly imported and used when throwing errors from services or routes. - If generic
500errors appear instead of specific4xxerrors for known conditions, double-check if yourHttpErrorhierarchy andinstanceofchecks in the error handler are correct.
3.5. Integrating All Components
At this point, you should have:
src/plugins/logger.tsregistered first insrc/app.ts.src/plugins/errorHandler.tsregistered second insrc/app.ts.src/errors/httpErrors.tsdefining your custom error classes.src/utils/validation/userSchemas.tsdefining Zod schemas.src/utils/validation/index.tswith thevalidatepre-handler.src/routes/userRoutes.ts(and potentiallysrc/services/userService.ts) updated to usevalidateand throwHttpErrorinstances.
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
isOperationalflag inHttpErroris 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,
infoorwarnis often sufficient.debugandtraceshould 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 thevalidatepre-handler utility.src/config/index.ts: Modified to includeisProductionflag.src/app.ts: Modified to registerloggerPluginanderrorHandlerPlugin.src/routes/userRoutes.ts: Modified to usevalidatemiddleware and potentially throw customHttpErrorinstances.src/services/userService.ts(example): Modified to throw customHttpErrorinstances (e.g.,NotFoundError,ConflictError).package.json: Updated withzod,pino,pino-prettydependencies.
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
Issue: Validation errors are not returning
400 Bad Requestbut500 Internal Server Error.- Reason: The
ZodErroris not being correctly transformed into anHttpError(specificallyBadRequestError) or theerrorHandlerPluginis not catchingHttpErrorinstances. - Solution:
- Verify that
src/utils/validation/index.tscorrectlydone(new BadRequestError(...))when aZodErroris caught. - Ensure
src/plugins/errorHandler.tscorrectly checksif (error instanceof HttpError)and processes it. - Check that
errorHandlerPluginis registered afterloggerPlugininsrc/app.ts.
- Verify that
- Reason: The
Issue: Logs are not appearing in the console, or are appearing as raw JSON in development.
- Reason: The
loggerPluginis not registered, orpino-prettyis not installed/configured correctly for development. - Solution:
- Confirm
app.register(loggerPlugin);is present and at the very beginning of your plugin registrations insrc/app.ts. - Check
pino-prettyis indevDependenciesand installed (npm install). - Verify the
isProductionflag logic insrc/config/index.tsandsrc/plugins/logger.tsto ensurepino-prettyis conditionally enabled.
- Confirm
- Reason: The
Issue: My custom
HttpErrormessages are not being returned to the client, or generic500errors are shown even for known4xxconditions.- Reason: The
errorHandlerPluginmight be masking error messages due toisProductionbeingtrue, or theHttpErroris not being properly recognized. - Solution:
- Double-check the
isProductionflag. If you are in development, ensureNODE_ENVis not set toproduction. - Verify that
error instanceof HttpErroris correctly evaluated insrc/plugins/errorHandler.ts. Ensure your custom error classes (BadRequestError,NotFoundError, etc.) correctly extendHttpError.
- Double-check the
- Reason: The
Testing & Verification
To verify that all the components from this chapter are working correctly, perform the following tests:
Start your application in development mode:
NODE_ENV=development npm run devVerify Logging:
- Make a
GETrequest to your health endpoint:curl http://localhost:3000/health - Expected: You should see
Incoming requestandOutgoing responselogs in your console, formatted nicely bypino-pretty. You should also seeHealth check receivedfrom the route handler.
- Make a
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
warnlevel log forHandled HTTP Errorindicating the validation failure.
- 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.
Verify
NotFoundError:- Attempt to fetch a user with a non-existent ID (assuming you have a
GET /users/:idroute that throwsNotFoundErrorif 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
warnlevel log forHandled HTTP Error.
- Attempt to fetch a user with a non-existent ID (assuming you have a
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
errorlevel log forUnhandled Error, including the stack trace.
- Temporarily re-introduce
Verify Production Behavior:
- Stop your application.
- Start it in production mode:
NODE_ENV=production npm run dev(ornpm startif 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
errorlevel log forUnhandled Errorwill 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-prettyfor 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
HttpErrorclasses 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.