Node.js Architecture
Purpose
Define and enforce Node.js backend architecture, framework selection, and middleware pipeline conventions.
Agent Protocol
Trigger
User request includes: node.js, nodejs, express, fastify, hono, node backend, node project structure, middleware, node.js routing.
Input Context
- Framework (Express, Fastify, Hono)
- Runtime (Node.js v18+, Bun, Deno)
- Language (JavaScript, TypeScript)
- Project type (REST API, GraphQL, BFF)
Output Artifact
A markdown document containing:
- Project structure
- Middleware pipeline ordering
- Routing conventions
- Error handling strategy
- Validation setup (Zod, Joi)
- Dependency injection pattern
- Testing setup
Response Format
Produce the artifact directly. No preamble, no postamble, no explanations. No filler, no hedging, no transitions. Strip articles a/an/the where unambiguous. Compress output — why use many token when few do trick.
Completion Criteria
- Project structure follows separation of concerns
- Middleware pipeline ordered by responsibility
- Error handler catches all exceptions
- Validation integrated with schema library
Max Response Length
4096 tokens
Workflow
Step 1: Select Framework
| Framework | Performance | Ecosystem | TypeScript | When |
|---|---|---|---|---|
| Express | Moderate | Largest | Manual setup | Large ecosystem, legacy, most devs know it |
| Fastify | High | Large | Native | Performance-critical, JSON schema validation |
| Hono | Very High | Growing | Native | Edge, Bun, minimal footprint |
Step 2: Set Up Project Structure
src/
+-- modules/
| +-- orders/
| | +-- order.controller.ts
| | +-- order.service.ts
| | +-- order.repository.ts
| | +-- order.schema.ts # Zod validation
| | +-- order.routes.ts
| | +-- order.test.ts
| | +-- order.mapper.ts
| +-- products/
| | +-- ...
| +-- users/
| +-- ...
+-- common/
| +-- middleware/
| | +-- auth.ts
| | +-- error-handler.ts
| | +-- request-logger.ts
| | +-- rate-limiter.ts
| | +-- request-id.ts
| | +-- validate.ts
| +-- errors/
| | +-- app-error.ts
| | +-- not-found.ts
| +-- types/
| | +-- index.ts
| | +-- express.d.ts
+-- config/
| +-- database.ts
| +-- env.ts
| +-- redis.ts
| +-- logger.ts
+-- app.ts # Express/Fastify app setup
+-- server.ts # Entry point
Step 3: Order Middleware Pipeline (Express)
import cors from 'cors';
import helmet from 'helmet';
import compression from 'compression';
import { requestLogger } from './common/middleware/request-logger';
import { rateLimiter } from './common/middleware/rate-limiter';
import { notFoundHandler } from './common/middleware/not-found';
import { errorHandler } from './common/middleware/error-handler';
import { requestId } from './common/middleware/request-id';
import { routes } from './routes';
const app = express();
app.use(cors({ origin: process.env.CORS_ORIGIN, credentials: true }));
app.use(helmet());
app.use(compression());
app.use(express.json({ limit: '1mb' }));
app.use(express.urlencoded({ extended: true }));
app.use(requestId);
app.use(requestLogger);
app.use(rateLimiter);
app.use('/api/v1', routes);
app.use(notFoundHandler);
app.use(errorHandler);
For Fastify:
import Fastify from 'fastify';
import cors from '@fastify/cors';
import helmet from '@fastify/helmet';
import compress from '@fastify/compress';
import rateLimit from '@fastify/rate-limit';
const app = Fastify({ logger: true });
await app.register(cors, { origin: process.env.CORS_ORIGIN });
await app.register(helmet);
await app.register(compress);
await app.register(rateLimit, { max: 100, timeWindow: '1 minute' });
await app.register(routes, { prefix: '/api/v1' });
app.setNotFoundHandler((req, reply) => {
reply.status(404).send({ success: false, error: { code: 'NOT_FOUND', message: 'Route not found' } });
});
app.setErrorHandler((err, req, reply) => {
const status = err.statusCode || 500;
reply.status(status).send({ success: false, error: { code: err.code || 'INTERNAL', message: err.message } });
});
Step 4: Implement Error Handling
// common/errors/app-error.ts
export interface ErrorDetails {
code: string;
message: string;
details?: unknown;
stack?: string;
}
export class AppError extends Error {
public readonly statusCode: number;
public readonly code: string;
public readonly details: unknown;
public readonly isOperational: boolean;
constructor(statusCode: number, code: string, message: string, details?: unknown) {
super(message);
this.statusCode = statusCode;
this.code = code;
this.details = details;
this.isOperational = true;
Object.setPrototypeOf(this, new.target.prototype);
Error.captureStackTrace(this, this.constructor);
}
public toJSON(): ErrorDetails {
return {
code: this.code,
message: this.message,
details: this.details,
stack: process.env.NODE_ENV === 'development' ? this.stack : undefined,
};
}
}
export class NotFoundError extends AppError {
constructor(entity: string, id: string) {
super(404, 'NOT_FOUND', `${entity} with id ${id} not found`);
}
}
export class ValidationError extends AppError {
constructor(errors: unknown) {
super(400, 'VALIDATION', 'Validation failed', errors);
}
}
export class UnauthorizedError extends AppError {
constructor(message = 'Unauthorized') {
super(401, 'UNAUTHORIZED', message);
}
}
export class ForbiddenError extends AppError {
constructor(message = 'Forbidden') {
super(403, 'FORBIDDEN', message);
}
}
// common/middleware/error-handler.ts
import type { Request, Response, NextFunction } from 'express';
import { AppError } from '../errors/app-error';
import { logger } from '../../config/logger';
export function errorHandler(err: Error, req: Request, res: Response, _next: NextFunction): void {
if (err instanceof AppError) {
logger.warn({ err, requestId: req.id, path: req.path }, 'Operational error');
res.status(err.statusCode).json({
success: false,
error: err.toJSON(),
});
return;
}
logger.error({ err, requestId: req.id, path: req.path }, 'Unhandled error');
res.status(500).json({
success: false,
error: {
code: 'INTERNAL_ERROR',
message: process.env.NODE_ENV === 'production' ? 'An unexpected error occurred' : err.message,
},
});
}
Step 5: Validation with Zod
// 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, 'At least one item required'),
shippingAddress: z.object({
street: z.string().min(1),
city: z.string().min(1),
zipCode: z.string().regex(/^\d{5}(-\d{4})?$/),
country: z.string().length(2),
}),
couponCode: z.string().optional(),
});
export type CreateOrderInput = z.infer<typeof createOrderSchema>;
// common/middleware/validate.ts
import type { Request, Response, NextFunction } from 'express';
import type { ZodSchema } from 'zod';
import { ValidationError } from '../errors/app-error';
export function validate(schema: ZodSchema, source: 'body' | 'query' | 'params' = 'body') {
return (req: Request, _res: Response, next: NextFunction): void => {
const result = schema.safeParse(req[source]);
if (result.success) {
req[source] = result.data;
next();
} else {
next(new ValidationError(result.error.issues));
}
};
}
// Usage in route
router.post('/', validate(createOrderSchema), orderController.create);
Step 6: Routing and Controller Pattern
// modules/orders/order.routes.ts
import { Router } from 'express';
import { OrderController } from './order.controller';
import { validate } from '../../common/middleware/validate';
import { createOrderSchema, updateOrderSchema } from './order.schema';
import { authenticate } from '../../common/middleware/auth';
const router = Router();
const controller = new OrderController();
router.use(authenticate);
router.get('/', controller.list);
router.get('/:id', controller.getById);
router.post('/', validate(createOrderSchema), controller.create);
router.put('/:id', validate(updateOrderSchema), controller.update);
router.delete('/:id', controller.delete);
export { router as orderRoutes };
// modules/orders/order.controller.ts
import type { Request, Response, NextFunction } from 'express';
import { OrderService } from './order.service';
export class OrderController {
constructor(private readonly orderService = new OrderService()) {}
list = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const result = await this.orderService.findAll(req.query);
res.json({ success: true, data: result });
} catch (err) {
next(err);
}
};
getById = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const result = await this.orderService.findById(req.params.id);
res.json({ success: true, data: result });
} catch (err) {
next(err);
}
};
create = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const result = await this.orderService.create(req.body);
res.status(201).json({ success: true, data: result });
} catch (err) {
next(err);
}
};
update = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const result = await this.orderService.update(req.params.id, req.body);
res.json({ success: true, data: result });
} catch (err) {
next(err);
}
};
delete = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
await this.orderService.delete(req.params.id);
res.status(204).send();
} catch (err) {
next(err);
}
};
}
Step 7: Configuration Management
// config/env.ts
import { z } from 'zod';
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
PORT: z.coerce.number().default(3000),
DATABASE_URL: z.string().url(),
REDIS_URL: z.string().url().optional(),
JWT_SECRET: z.string().min(32),
JWT_EXPIRES_IN: z.string().default('15m'),
CORS_ORIGIN: z.string().default('http://localhost:3000'),
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
});
export type Env = z.infer<typeof envSchema>;
let env: Env;
export function loadEnv(): Env {
const result = envSchema.safeParse(process.env);
if (!result.success) {
console.error('Invalid environment variables:', result.error.issues);
process.exit(1);
}
env = result.data;
return env;
}
export function getEnv(): Env {
if (!env) return loadEnv();
return env;
}
Step 8: Dependency Injection Container
// config/container.ts
import { OrderService } from '../modules/orders/order.service';
import { OrderRepository } from '../modules/orders/order.repository';
import { PaymentService } from '../modules/payments/payment.service';
import { NotificationService } from '../modules/notifications/notification.service';
import { Database } from './database';
export class Container {
private static instance: Container;
private readonly db: Database;
private readonly services = new Map<string, unknown>();
private constructor() {
this.db = new Database();
}
static getInstance(): Container {
if (!Container.instance) {
Container.instance = new Container();
}
return Container.instance;
}
getOrderRepository(): OrderRepository {
if (!this.services.has('orderRepository')) {
this.services.set('orderRepository', new OrderRepository(this.db));
}
return this.services.get('orderRepository') as OrderRepository;
}
getOrderService(): OrderService {
if (!this.services.has('orderService')) {
this.services.set('orderService', new OrderService(
this.getOrderRepository(),
this.getPaymentService(),
this.getNotificationService(),
));
}
return this.services.get('orderService') as OrderService;
}
getPaymentService(): PaymentService {
if (!this.services.has('paymentService')) {
this.services.set('paymentService', new PaymentService());
}
return this.services.get('paymentService') as PaymentService;
}
getNotificationService(): NotificationService {
if (!this.services.has('notificationService')) {
this.services.set('notificationService', new NotificationService());
}
return this.services.get('notificationService') as NotificationService;
}
}
Step 9: Testing Setup
// modules/orders/order.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import request from 'supertest';
import { createApp } from '../app';
import { OrderService } from './order.service';
vi.mock('./order.service');
describe('Orders API', () => {
let app: Express.Application;
beforeEach(() => {
vi.clearAllMocks();
app = createApp('test');
});
describe('POST /api/v1/orders', () => {
it('creates order successfully', async () => {
const mockOrder = { id: '123', customerId: 'cust-1', items: [] };
vi.mocked(OrderService.prototype.create).mockResolvedValue(mockOrder);
const res = await request(app)
.post('/api/v1/orders')
.send({
customerId: '550e8400-e29b-41d4-a716-446655440000',
items: [{ productId: '550e8400-e29b-41d4-a716-446655440001', quantity: 2, unitPrice: 19.99 }],
shippingAddress: { street: '123 Main', city: 'NYC', zipCode: '10001', country: 'US' },
})
.expect(201);
expect(res.body.success).toBe(true);
expect(res.body.data.id).toBe('123');
});
it('returns 400 for invalid input', async () => {
const res = await request(app)
.post('/api/v1/orders')
.send({ customerId: 'invalid' })
.expect(400);
expect(res.body.error.code).toBe('VALIDATION');
});
it('returns 401 without auth token', async () => {
const res = await request(app)
.post('/api/v1/orders')
.send({})
.expect(401);
});
});
describe('GET /api/v1/orders/:id', () => {
it('returns 404 for non-existent order', async () => {
vi.mocked(OrderService.prototype.findById).mockRejectedValue(
new NotFoundError('Order', '999')
);
await request(app)
.get('/api/v1/orders/999')
.expect(404);
});
});
});
Architecture Decision Trees
Framework Selection
Need high performance?
+-- Yes -> Need edge runtime?
| +-- Yes -> Hono (Bun, Deno, Cloudflare Workers)
| +-- No -> Fastify (JSON schema validation, high throughput)
+-- No -> Need largest ecosystem?
+-- Yes -> Express (most middleware, community packages)
+-- No -> Hono (modern, lightweight, good DX)
Request Validation Strategy
TypeScript project?
+-- Yes -> Zod (type inference, composable schemas)
+-- No -> Joi (mature, expressive API)
Project Structure Decision
Monorepo with shared types?
+-- Yes -> Use Nx or Turborepo, shared packages for types/schemas
+-- No -> Flat modules/ structure with domain grouping
Error Handling Approach
Single service deployment?
+-- Yes -> Centralized AppError hierarchy with status codes
+-- No -> Add correlation IDs, distributed tracing headers
Common Pitfalls
Middleware ordering wrong: Rate limiter before JSON parser causes body read issues. Security middleware (helmet, cors) must come first, before any body parsing.
Swallowing async errors: Express does not catch async promise rejections automatically. Always use
express-async-errorsor wrap async handlers. Fastify handles this natively.Blocking the event loop: CPU-heavy operations in request handlers (JSON.stringify on large objects, crypto operations, image processing). Offload to worker threads or queue tasks.
No request body size limits: Attackers send gigantic payloads to exhaust memory. Always set
express.json({ limit: '1mb' })or equivalent.Exposing stack traces in production: Never include
err.stackin production error responses. Use structured logging instead.Zod schema duplication: Defining validation both in Zod schemas and TypeScript interfaces. Use
z.infer<typeof schema>to derive types.Global state in request handlers: Mutating module-level variables across requests leads to race conditions. Use DI container or request-scoped services.
Circular dependencies: Common in barrel exports. Keep imports explicit and avoid
index.tsre-exports that create cycles.Missing HTTP header security: Not setting
X-Content-Type-Options,X-Frame-Options,Strict-Transport-Security. Helmet handles this.Database connection pool exhaustion: Creating connections per request instead of using a connection pool. Always use
pg.Poolor Prisma's built-in pooling.
Best Practices
Controller methods never call
next(err)directly with non-Error values. Always wrap in AppError or equivalent typed error class.Use repository pattern for data access abstracted behind interfaces. Enables unit testing without a database.
Keep route handlers thin. Route file defines URL structure and middleware. Controller handles request/response. Service contains business logic.
Use async event emitters or message queues for side effects. Sending emails, push notifications, webhooks should not block the response.
Structured logging with correlation IDs. Attach a unique request ID to every log line for debugging distributed traces.
Rate limit by user ID, not just IP. For authenticated endpoints, rate-limit by userId to prevent one user exhausting another's quota.
Graceful shutdown: Handle SIGTERM, stop accepting connections, drain existing requests, close database connections.
Compression before rate limiting. Compress responses first, then apply rate limits to reduce bandwidth usage for limited users.
Health check endpoints:
/health(liveness) and/ready(readiness) for Kubernetes probes. DB connection check in readiness.Use
.envfiles with validation at startup. Fail fast if required variables missing.
Compared With
| Feature | Express | Fastify | Hono |
|---|---|---|---|
| Request validation | Manual (Zod/Joi) | Built-in (JSON Schema) | Manual (Zod) |
| Performance | 15k req/s | 35k req/s | 50k req/s |
| Plugin ecosystem | 5000+ | 200+ | 50+ |
| TypeScript DX | Manual types | Auto-generated | Native |
| Edge compatibility | No | Limited | Yes |
| OpenAPI generation | Third-party | Built-in plugin | Third-party |
| WebSocket | Via library | Via plugin | Built-in |
| Testing supertest | Yes | inject() method | Built-in test client |
| Middleware model | Sequential | Encapsulated | Sequential |
| Error handling | Manual async wrap | Auto async catch | Auto async catch |
Performance
- Connection pooling: Reuse database connections across requests. Prisma: 10-15 connections per CPU core. Knex: use
pool.minandpool.max. - Response compression: Gzip/Brotli reduces payload by 60-80%. Use
compression(Express),@fastify/compress(Fastify). - Redis caching: Cache expensive query results with TTL. Use
node-cache-managerwith Redis store. - Cluster mode: Use Node.js
clustermodule or PM2 cluster mode to utilize all CPU cores. - HTTP/2: Enable for multiplexing. Fastify supports natively. Express needs
spdyor a reverse proxy. - Keep-alive connections: Disable
Connection: close. Reduces TCP handshake overhead. - Body parser limit: Set to minimum required. Default 100KB in Express, configurable.
- Memory monitoring: Use
--max-old-space-sizeflag. Monitor heap usage withprocess.memoryUsage().
Tooling
| Tool | Purpose |
|---|---|
| Zod | Runtime schema validation with TypeScript inference |
| Vitest | Unit and integration testing (fast, ESM-native) |
| Supertest | HTTP assertion testing |
| Pino | Structured JSON logging (used by Fastify) |
| Winston | Alternative logging with transports |
| tsx / ts-node | TypeScript execution in development |
| PM2 | Production process manager with clustering |
| Docker | Containerization for consistent environments |
| ESLint | Code quality with @typescript-eslint |
| Prettier | Consistent code formatting |
| Husky + lint-staged | Pre-commit hooks |
| Nodemon / tsx watch | Auto-restart on file changes |
| Dotenv | Environment variable loading |
Rules
- Framework selected by performance needs and ecosystem requirements.
- Modules grouped by domain feature, not by technical layer.
- Middleware ordered: security -> parsing -> logging -> rate-limit -> routes -> 404 -> error.
- All errors handled by central error handler — no try/catch in controllers.
- Validation via Zod/Fastify schema — never manual checks.
- TypeScript strict mode for all new projects.
- No business logic in route handlers or controllers.
- Repository interfaces in domain, implementations in infrastructure.
- Async handlers must be wrapped for Express (use express-async-errors).
- Error classes carry HTTP status code, error code, and optional details.
- Environment variables validated at startup with Zod schema.
- Database migrations for all schema changes — never raw DDL in production.
- Logging with structured JSON format for log aggregation.
- Health and readiness endpoints required for container orchestration.
- Rate limiting applied per-route or per-client, never global-only.
- Secrets managed via environment variables or secrets manager, never in code.
References
- references/nodejs-architecture-patterns.md — Node.js Architecture Patterns
- references/nodejs-module-system-di.md — Node.js Module System and DI
- references/express-setup.md — Express Setup Reference
- references/fastify-setup.md — Fastify Setup
- references/nodejs-clustering.md — Node.js Clustering
- references/nodejs-event-loop.md — Node.js Event Loop
- references/nodejs-middleware.md — Node.js Middleware Architecture Reference
- references/nodejs-security.md — Node.js Security Reference
Handoff
Hand off to backend/nodejs/patterns/SKILL.md for Node.js-specific patterns.