Designing Scalable REST APIs with Node.js and Express: Architecture & Best Practices
Node.js and Express make building a REST API deceptively fast. But without strict architectural layers, projects quickly turn into spaghetti code. Here is how to structure scalable, production-ready Node.js APIs.
Building an API with Node.js and Express takes less than ten lines of code. However, building an API that handles concurrent traffic, validates untrusted input, logs errors gracefully, and remains easy to extend over years of production requires deliberate architectural discipline.
Without an established pattern, Express codebases typically devolve into massive controller files where route definitions, database queries, business logic, and error handling are all mixed together.
In this guide, I share the layered architecture pattern I use to design maintainable, high-throughput REST APIs in Node.js and TypeScript.
The 3-Layer Architectural Pattern
To keep an API maintainable, separate your application into three distinct responsibilities:
- Controller Layer (HTTP Transport): Parses incoming HTTP requests, validates parameters, and returns HTTP responses. No business logic or database queries should live here.
- Service Layer (Business Logic): Implements the core domain logic, calculates pricing, handles business validation, and orchestrates actions.
- Data Access Layer (Repository/DAO): Interacts directly with the database (PostgreSQL, Prisma, Kysely, or raw SQL queries).
Client Request
│
▼
┌───────────────┐
│ Routes & Auth │ (JWT verification, rate limiting)
└───────┬───────┘
▼
┌───────────────┐
│ Controllers │ (Schema validation with Zod, response formatting)
└───────┬───────┘
▼
┌───────────────┐
│ Services │ (Business rules, calculations, external APIs)
└───────┬───────┘
▼
┌───────────────┐
│ Repositories │ (SQL queries, transaction boundaries)
└───────────────┘
Strict Input Validation with Zod
Never trust client input. Unvalidated parameters lead to SQL injections, type confusion bugs, and unhandled server exceptions.
Using Zod, create explicit schemas for headers, query parameters, and request bodies:
import { z } from "zod"; export const CreateUserSchema = z.object({ body: z.object({ email: z.string().email("Invalid email format"), password: z.string().min(8, "Password must be at least 8 characters"), fullName: z.string().min(2, "Full name is required"), role: z.enum(["admin", "member", "viewer"]).default("member"), }), }); // Middleware to enforce schema export const validate = (schema: z.ZodSchema) => (req, res, next) => { try { schema.parse({ body: req.body, query: req.query, params: req.params }); next(); } catch (error) { if (error instanceof z.ZodError) { return res.status(400).json({ status: "error", message: "Validation failed", errors: error.errors.map((e) => ({ field: e.path.join("."), message: e.message, })), }); } next(error); } };
Centralized Error Handling
Rather than writing try/catch blocks inside every controller route, use an async error wrapper or Express 5's native promise handling combined with a centralized error middleware:
// AppError: operational error class export class AppError extends Error { constructor( public statusCode: number, public message: string, public isOperational = true ) { super(message); Error.captureStackTrace(this, this.constructor); } } // Global error handling middleware export function errorHandler(err, req, res, next) { const statusCode = err.statusCode || 500; const message = err.isOperational ? err.message : "Internal server error"; // Log unhandled non-operational errors if (!err.isOperational) { console.error("UNEXPECTED ERROR:", err); } res.status(statusCode).json({ status: statusCode >= 500 ? "error" : "fail", message, ...(process.env.NODE_ENV === "development" && { stack: err.stack }), }); }
Database Connection Pooling and Graceful Shutdown
One of the most common causes of Node.js crashes under load is database connection exhaustion. Always use a connection pool (e.g. pg.Pool) with configured maximum connection limits:
import { Pool } from "pg"; export const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 20, // Maximum pool connections idleTimeoutMillis: 30000, connectionTimeoutMillis: 2000, }); // Graceful shutdown on SIGTERM / SIGINT const shutdown = async () => { console.log("Gracefully closing database connections..."); await pool.end(); process.exit(0); }; process.on("SIGTERM", shutdown); process.on("SIGINT", shutdown);
REST vs GraphQL: Choosing the Right API Style
While REST remains the default for most web applications, GraphQL offers advantages when multiple client apps require different shapes of data. For a complete comparison, see REST vs GraphQL: Which API Style Fits Your Product?.
For how API contracts evolve over time, review Structuring REST APIs for Long-Term Maintainability and Role-Based Authentication Explained.
Need a senior backend engineer to design, optimize, or scale your Node.js infrastructure? Explore my backend and API development services or contact me to discuss your technical challenges.