The 800-Line server.js Anti-Pattern
Open GitHub and search for junior Node.js developer projects. You will see thousands of repositories where the entire backend lives in a single, massive server.js file. Database connections, raw SQL queries, authentication middleware, payment logic, and response handlers are all tangled inside nested callback chains.
When an unhandled promise rejection triggers or a database error occurs, the server crashes completely. There is no input sanitization, no environment configuration validation, and no structured logging. Senior backend engineers reject these repositories on sight because writing code that works on localhost is easy. Writing code that survives production traffic requires architectural discipline.
In this guide, we will build an enterprise-ready Modular Node.js Backend using Node 22 LTS, TypeScript 5.7, Fastify, and Zod. We will implement strict layered architecture (Routes -> Controllers -> Services -> Repositories), centralized error handling, and graceful process shutdown.
The Production Layered Architecture
Every maintainable Node.js service isolates responsibilities across four distinct layers:
Incoming HTTP Request
│
▼
[ 1. Transport Layer (Routes & Middleware) ]
- Rate limiting, CORS, Authentication
- Schema validation with Zod (Returns 400 Bad Request immediately)
│
▼
[ 2. Controller Layer ]
- Parses HTTP request parameters
- Delegates execution to the Service layer
- Formats standard JSON HTTP responses
│
▼
[ 3. Service Layer (Pure Business Logic) ]
- Zero HTTP or Fastify/Express dependencies
- Coordinates transactions, discount rules, external API calls
- Throws strongly-typed Domain Exceptions
│
▼
[ 4. Data Access Layer (Repository / Database) ]
- Executes SQL queries or ORM commands
- Returns raw database records to the Service layer
Step 1: Clean Project Setup and Environment Validation
Never read process.env.PORT blindly throughout your codebase. If someone forgets to set a required database password in production, the application should fail immediately at boot time with a clear error message.
mkdir production-node-backend && cd production-node-backend
npm init -y
# Install runtime dependencies
npm install fastify @fastify/cors @fastify/sensible zod dotenv pino pino-pretty
# Install development tooling
npm install -D typescript @types/node tsx vitest
Now, validate your environment variables using Zod:
// src/config/env.ts
import { z } from 'zod';
import * as dotenv from 'dotenv';
dotenv.config();
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.coerce.number().default(3000),
HOST: z.string().default('0.0.0.0'),
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32, 'JWT_SECRET must be at least 32 characters long'),
});
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
console.error('Invalid environment configuration:');
console.error(JSON.stringify(parsed.error.format(), null, 2));
process.exit(1);
}
export const env = parsed.data;
Step 2: Strongly-Typed Domain Error Hierarchy
Never send raw SQL errors or unformatted exception messages back to client devices. We build custom error classes and a centralized Fastify error hook.
// src/errors/AppError.ts
export class AppError extends Error {
constructor(
public readonly statusCode: number,
public readonly message: string,
public readonly code: string,
public readonly details: unknown = null
) {
super(message);
Object.setPrototypeOf(this, new.target.prototype);
}
}
export class NotFoundError extends AppError {
constructor(resource: string, id: string) {
super(404, `${resource} with id ${id} not found.`, 'NOT_FOUND');
}
}
export class ValidationError extends AppError {
constructor(details: unknown) {
super(400, 'Invalid request payload.', 'VALIDATION_ERROR', details);
}
}
export class ConflictError extends AppError {
constructor(message: string) {
super(409, message, 'CONFLICT');
}
}
Step 3: Implementing an Order Processing Domain
Let us implement an e-commerce order creation flow showing how routes, controllers, and services interact cleanly.
The Validation Schema (Zod):
// src/modules/orders/order.schema.ts
import { z } from 'zod';
export const createOrderSchema = z.object({
customerId: z.string().uuid(),
items: z.array(
z.object({
productId: z.string().uuid(),
quantity: z.number().int().positive(),
unitPrice: z.number().positive(),
})
).min(1, 'Order must contain at least one item'),
discountCode: z.string().optional(),
});
export type CreateOrderInput = z.infer<typeof createOrderSchema>;
The Service Layer (Pure Business Logic):
// src/modules/orders/order.service.ts
import { CreateOrderInput } from './order.schema.js';
import { ConflictError } from '../../errors/AppError.js';
export interface Order {
id: string;
customerId: string;
totalAmount: number;
status: 'PENDING' | 'PAID';
createdAt: Date;
}
export class OrderService {
// In a real application, inject your Database Repository via constructor
constructor() {}
async createOrder(input: CreateOrderInput): Promise<Order> {
// Check business invariant: calculate total
const subtotal = input.items.reduce(
(sum, item) => sum + item.quantity * item.unitPrice,
0
);
let discountMultiplier = 1.0;
if (input.discountCode) {
if (input.discountCode === 'DROPOUT50') {
discountMultiplier = 0.5;
} else {
throw new ConflictError(`Invalid or expired discount code: ${input.discountCode}`);
}
}
const totalAmount = subtotal * discountMultiplier;
const newOrder: Order = {
id: crypto.randomUUID(),
customerId: input.customerId,
totalAmount,
status: 'PENDING',
createdAt: new Date(),
};
// Persist order to database in a transaction
return newOrder;
}
}
The Controller and Fastify Routes:
// src/modules/orders/order.routes.ts
import { FastifyPluginAsync } from 'fastify';
import { OrderService } from './order.service.js';
import { createOrderSchema } from './order.schema.js';
import { ValidationError } from '../../errors/AppError.js';
export const orderRoutes: FastifyPluginAsync = async (fastify) => {
const orderService = new OrderService();
fastify.post('/', async (request, reply) => {
// Schema validation hook
const parseResult = createOrderSchema.safeParse(request.body);
if (!parseResult.success) {
throw new ValidationError(parseResult.error.flatten());
}
const order = await orderService.createOrder(parseResult.data);
return reply.status(201).send({ success: true, data: order });
});
};
Step 4: Graceful Shutdown and Server Bootstrap
In containerized environments like Kubernetes, Docker, or AWS ECS, servers receive a SIGTERM signal when deploying new versions. If you terminate immediately, active database queries are severed and in-flight payments fail.
A production server must intercept the signal, stop accepting new connections, finish ongoing requests, and close database connection pools cleanly before exiting.
// src/server.ts
import Fastify from 'fastify';
import cors from '@fastify/cors';
import { env } from './config/env.js';
import { orderRoutes } from './modules/orders/order.routes.js';
import { AppError } from './errors/AppError.js';
const server = Fastify({
logger: {
level: env.NODE_ENV === 'development' ? 'info' : 'warn',
},
});
await server.register(cors, { origin: true });
// Centralized Error Handler Hook
server.setErrorHandler((error, request, reply) => {
if (error instanceof AppError) {
return reply.status(error.statusCode).send({
success: false,
code: error.code,
message: error.message,
details: error.details,
});
}
// Log unexpected internal errors securely
request.log.error(error);
return reply.status(500).send({
success: false,
code: 'INTERNAL_SERVER_ERROR',
message: 'An unexpected error occurred. Please try again later.',
});
});
// Health Checks for Cloud Readiness
server.get('/health/live', async () => ({ status: 'alive' }));
server.get('/health/ready', async () => ({ status: 'ready', database: 'connected' }));
// Register Feature Modules
await server.register(orderRoutes, { prefix: '/api/v1/orders' });
// Start Server
try {
await server.listen({ port: env.PORT, host: env.HOST });
console.log(`Server listening on http://${env.HOST}:${env.PORT}`);
} catch (err) {
server.log.error(err);
process.exit(1);
}
// Graceful Shutdown Handler
const signals: NodeJS.Signals[] = ['SIGINT', 'SIGTERM'];
for (const signal of signals) {
process.on(signal, async () => {
console.log(`Received ${signal}. Draining active connections...`);
try {
await server.close();
console.log('Server shut down cleanly. Exiting.');
process.exit(0);
} catch (err) {
console.error('Error during shutdown:', err);
process.exit(1);
}
});
}
What to Put on Your Resume
Compare how typical junior candidates describe their Node.js projects vs how you should phrase it:
| Weak Resume Bullet | Engineered Resume Bullet |
|---|---|
| Built a REST API with Node.js, Express, and MongoDB for ordering products. | Architected a modular e-commerce backend in Node 22 and Fastify with TypeScript, implementing Zod schema enforcement, domain-driven service layers, centralized error handling, and zero-downtime SIGTERM graceful shutdown hooks. |
Next Steps
Take this project further by adding integration tests using Vitest and Supertest, containerizing with a multi-stage Dockerfile, and generating OpenAPI documentation directly from your Zod schemas.
To dive deeper into backend systems and career preparation, explore our guides on resume-worthy coding projects, building real-world projects for your portfolio, strengthening your JavaScript project fundamentals, and our realistic playbook for becoming a developer without a degree.
