ool exhaustion. The registry pattern ensures a single instance across modules and handles graceful teardown.
import { PrismaClient } from "@prisma/client";
import { runtimeConfig } from "./config";
const globalRegistry = globalThis as unknown as { db: PrismaClient };
export const databaseRegistry =
globalRegistry.db ??
new PrismaClient({
log: runtimeConfig.NODE_ENV === "development"
? ["query", "warn", "error"]
: ["error"],
});
if (runtimeConfig.NODE_ENV !== "production") {
globalRegistry.db = databaseRegistry;
}
process.on("SIGINT", async () => {
await databaseRegistry.$disconnect();
process.exit(0);
});
process.on("SIGTERM", async () => {
await databaseRegistry.$disconnect();
process.exit(0);
});
Rationale: globalThis survives hot-reloads in development. Logging is gated by environment to avoid noise in production. Signal handlers ensure pending queries complete before shutdown, preventing data corruption.
3. Request Pipeline with Schema Validation
Replace untyped async wrappers with a validation middleware that parses, validates, and attaches typed payloads to the request object.
import { Request, Response, NextFunction } from "express";
import { ZodSchema, ZodError } from "zod";
export type ValidationTarget = "body" | "query" | "params";
export const validateRequest = <T extends ZodSchema>(
target: ValidationTarget,
schema: T
) => {
return (req: Request, _res: Response, next: NextFunction) => {
try {
const parsed = schema.parse(req[target]);
req.validated = { ...req.validated, [target]: parsed };
next();
} catch (error) {
if (error instanceof ZodError) {
next(Object.assign(new Error("Validation failed"), { statusCode: 400, details: error.errors }));
} else {
next(error);
}
}
};
};
Rationale: Express 5 handles promise rejections natively, but explicit validation at the boundary prevents malformed data from reaching business logic. Attaching req.validated maintains type safety downstream while keeping req.body untouched for debugging.
4. Stateless Authentication Strategy
Passport's JWT strategy should verify tokens, resolve the user, and attach a strongly-typed principal to the request.
import passport from "passport";
import { Strategy, ExtractJwt, StrategyOptions, VerifiedCallback } from "passport-jwt";
import { databaseRegistry } from "./database";
import { runtimeConfig } from "./config";
interface TokenPayload {
sub: string;
email: string;
iat: number;
exp: number;
}
const strategyOptions: StrategyOptions = {
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: runtimeConfig.JWT_SECRET,
};
passport.use(
"bearer",
new Strategy(strategyOptions, async (payload: TokenPayload, done: VerifiedCallback) => {
try {
const principal = await databaseRegistry.user.findUnique({
where: { id: payload.sub },
select: { id: true, email: true, profileImage: true, createdAt: true, updatedAt: true },
});
if (!principal) return done(null, false);
return done(null, principal);
} catch (error) {
return done(error as Error, false);
}
})
);
export const authenticateBearer = passport.authenticate("bearer", { session: false });
Rationale: Using sub aligns with OIDC standards. Selecting only required fields reduces payload size and prevents accidental credential leakage. session: false enforces stateless operation, critical for horizontal scaling.
5. Secure File Storage Engine
Multer must enforce MIME types, sanitize filenames, and prevent path traversal. A factory pattern allows reusable configurations.
import multer from "multer";
import path from "path";
import fs from "fs";
import { Request } from "express";
const createStorage = (targetDir: string) => {
fs.mkdirSync(targetDir, { recursive: true });
return multer.diskStorage({
destination: (_req, _file, callback) => callback(null, targetDir),
filename: (_req, file, callback) => {
const safeName = `${file.fieldname}-${Date.now()}-${Math.random().toString(36).slice(2)}${path.extname(file.originalname).toLowerCase()}`;
callback(null, safeName);
},
});
};
export const createUploadMiddleware = (field: string, dir: string, maxFiles = 1, maxSizeMB = 5) => {
return multer({
storage: createStorage(dir),
limits: { fileSize: maxSizeMB * 1024 * 1024, files: maxFiles },
fileFilter: (_req, file, callback) => {
const allowed = ["image/jpeg", "image/png", "image/webp", "application/pdf"];
if (allowed.includes(file.mimetype)) {
callback(null, true);
} else {
callback(new Error("Unsupported file type"));
}
},
}).array(field, maxFiles);
};
Rationale: Randomized suffixes prevent overwrites. MIME filtering blocks executable uploads. Size limits protect against denial-of-service via large payloads. The factory pattern keeps configuration DRY.
6. Centralized Error Boundary
A unified error handler must distinguish operational failures from system crashes, gate stack traces by environment, and map database constraints to HTTP status codes.
import { Request, Response, NextFunction } from "express";
import { runtimeConfig } from "./config";
export class OperationalError extends Error {
public readonly statusCode: number;
public readonly isOperational = true;
constructor(message: string, statusCode = 500) {
super(message);
this.statusCode = statusCode;
Error.captureStackTrace(this, this.constructor);
}
}
export const errorBoundary = (
error: any,
_req: Request,
res: Response,
_next: NextFunction
) => {
const status = error.statusCode || 500;
const message = error.message || "Internal server error";
if (error.code === "P2002") {
return res.status(409).json({ success: false, message: "Unique constraint violation" });
}
if (runtimeConfig.NODE_ENV === "development") {
return res.status(status).json({
success: false,
message,
stack: error.stack,
details: error.details || null,
});
}
return res.status(status).json({ success: false, message });
};
Rationale: OperationalError separates expected failures (validation, auth) from unexpected crashes. Prisma's P2002 maps to 409 Conflict. Environment gating prevents stack trace leakage in production while preserving debugging context in development.
Pitfall Guide
1. Unvalidated Request Boundaries
Explanation: Relying on req.body without schema validation allows malformed or malicious data to reach business logic. TypeScript interfaces only exist at compile time and provide zero runtime protection.
Fix: Implement Zod middleware that parses and attaches req.validated before route handlers execute. Always validate body, query, and params independently.
2. Prisma Connection Pool Exhaustion
Explanation: Instantiating new PrismaClient() per request or per module creates orphaned connections. Under load or during hot-reloads, this exhausts the database connection limit, causing ETIMEDOUT errors.
Fix: Use a global singleton pattern with globalThis. Attach SIGINT/SIGTERM handlers to call $disconnect() before process termination.
3. Insecure File Upload Handling
Explanation: Accepting arbitrary filenames and MIME types enables path traversal (../../../etc/passwd) and executable uploads. Multer's default configuration lacks security constraints.
Fix: Sanitize filenames with timestamps and random suffixes. Enforce a strict MIME allowlist. Store uploads outside the web root or behind authenticated routes.
4. Untyped Async Wrappers
Explanation: Using any in async error catchers breaks TypeScript's type inference downstream. It also masks missing error properties, making debugging difficult.
Fix: Replace any with explicit Error types. Attach statusCode and details properties to custom error classes. Leverage Express 5's native promise rejection handling where possible.
5. JWT Secret Fallbacks
Explanation: Defaulting to a weak or hardcoded JWT secret when process.env.JWT_SECRET is undefined creates a critical security vulnerability. Attackers can forge tokens if the secret is predictable.
Fix: Validate the secret on boot using a schema with a minimum length constraint. Fail fast if missing. Rotate secrets periodically and store them in a vault, not .env files in production.
6. Development Stack Trace Leakage
Explanation: Returning full error stacks and internal objects in production responses exposes implementation details, database schemas, and dependency versions to attackers.
Fix: Gate error responses by NODE_ENV. Return generic messages in production. Log full details to a structured logging service (e.g., Winston, Pino) instead of the HTTP response.
7. Missing Graceful Shutdown Hooks
Explanation: Forcing process termination without disconnecting database clients or closing HTTP servers leaves transactions incomplete and connections hanging. Load balancers may mark the instance as unhealthy.
Fix: Register SIGINT and SIGTERM handlers. Close the HTTP server, drain active connections, disconnect the database client, then exit with code 0.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Request Validation | Zod + Express Middleware | Runtime safety, TypeScript inference, minimal bundle size | Low (dev time) |
| Authentication | Passport JWT + Stateless Sessions | Standardized strategy, easy rotation, scales horizontally | Low (infrastructure) |
| File Storage | Local Disk + Multer (MVP) | Zero external dependencies, fast iteration | Low (storage) |
| File Storage | S3/Cloud Storage (Scale) | CDN integration, automatic backups, global distribution | Medium (cloud costs) |
| Error Handling | Custom OperationalError + Boundary Middleware | Predictable responses, environment gating, easy logging | Low (maintenance) |
| Database Client | Prisma Singleton + Global Registry | Prevents connection leaks, hot-reload safe | Low (architecture) |
Configuration Template
// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"rootDir": "./src",
"outDir": "./dist",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"isolatedModules": true,
"verbatimModuleSyntax": false,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": false,
"sourceMap": false
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}
# .env.example
PORT=3000
NODE_ENV=development
DATABASE_URL=postgresql://user:password@localhost:5432/appdb?schema=public
JWT_SECRET=replace-with-32+ character-cryptographic-secret
UPLOAD_DIR=./storage/uploads
Quick Start Guide
- Initialize Project: Run
npm init -y, install dependencies (express, prisma, passport, passport-jwt, multer, zod, typescript, @types/*), and generate the tsconfig.json from the template.
- Configure Database: Run
npx prisma init, update schema.prisma with your models, and execute npx prisma migrate dev --name init.
- Bootstrap Server: Create
src/server.ts, import the configuration, database registry, and Express app. Attach validation, auth, and error boundary middleware. Start listening on runtimeConfig.PORT.
- Validate & Run: Execute
npm run build && node dist/server.js. Test the /health endpoint, verify JWT authentication flow, and confirm file uploads respect MIME and size constraints. Monitor Prisma logs in development to ensure connection stability.