Node.js and Express API Architecture: A Complete Guide
Express is minimal by design — which means the architecture is entirely up to you. Here's the structure I use to keep Express APIs maintainable as they grow past a handful of routes.
Node.js and Express remain one of the most productive stacks for building APIs — but that minimalism is a double-edged sword. Without deliberate structure, an Express app turns into a pile of routes with logic scattered across files. Here's the architecture that scales.
The short answer: organize an Express API in layers (routes, controllers, services, repositories), validate every input at the boundary, throw typed errors and handle them in one place, and treat configuration, logging, health checks, and graceful shutdown as part of the architecture rather than afterthoughts.
Key takeaways
- Controllers translate HTTP; services hold business rules; repositories own the database.
- Validate requests with a schema (Zod or Joi) before they reach a service.
- One error handler, typed error classes, and no stack traces in responses.
- Validate environment variables at startup so misconfiguration fails fast.
- Test services as plain functions and routes with an HTTP-level integration test.
1. Project Structure
src/
routes/ -> route definitions, thin, no business logic
controllers/ -> request/response handling
services/ -> business logic, framework-agnostic
repositories/ -> database access
middleware/ -> auth, validation, error handling
config/ -> env, database, third-party clients
utils/
The key rule: controllers stay thin, services hold logic, repositories own data access. This keeps business logic testable without spinning up an HTTP server.
Here is what one feature looks like across the layers. Note that the service knows nothing about
req or res:
// routes/orders.routes.js
router.post("/orders", requireAuth, validate(createOrderSchema), ordersController.create);
// controllers/orders.controller.js
export async function create(req, res) {
const order = await ordersService.createOrder(req.user.id, req.body);
res.status(201).json(order);
}
// services/orders.service.js
export async function createOrder(userId, input) {
const product = await productsRepo.findById(input.productId);
if (!product) throw new NotFoundError("Product not found");
if (product.stock < input.quantity) throw new ConflictError("Insufficient stock");
return ordersRepo.insert({ userId, productId: product.id, quantity: input.quantity });
}
// repositories/orders.repo.js
export function insert(order) {
return db("orders").insert(order).returning("*").then(rows => rows[0]);
}
For larger codebases, group by feature instead of by layer (src/modules/orders/ containing its own
routes, controller, service, and repository). The layering rules stay the same; only the folder layout changes,
which keeps related code together as the number of features grows.
2. Middleware Chain Design
- Request logging and correlation IDs first
- Body parsing and input sanitization
- Authentication, then authorization
- Route-specific validation (Zod or Joi schemas)
- Centralized error handler last
3. Centralized Error Handling
app.use((err, req, res, next) => {
const status = err.status || 500;
res.status(status).json({
error: { message: err.expose ? err.message : "Internal server error", code: err.code }
});
});
Never leak stack traces or raw database errors to clients. Throw typed errors (ValidationError,
NotFoundError) from services and translate them centrally.
export class AppError extends Error {
constructor(message, status = 500, code = "INTERNAL", expose = false) {
super(message);
this.status = status;
this.code = code;
this.expose = expose;
}
}
export class NotFoundError extends AppError {
constructor(msg = "Not found") { super(msg, 404, "NOT_FOUND", true); }
}
export class ConflictError extends AppError {
constructor(msg = "Conflict") { super(msg, 409, "CONFLICT", true); }
}
Async errors need one more consideration. Express 5 forwards rejected promises from async handlers to the error
middleware automatically. On Express 4, an unhandled rejection in a handler never reaches the error handler, so
wrap handlers (or use a small helper like asyncHandler) or upgrade. Log the full error with the
request ID on the server; send the client only the safe message and code.
If your controllers have try/catch blocks scattered everywhere, your error handling architecture is missing, not your try/catch discipline.
4. Validation at the Boundary
Validate every request body, query param, and route param before it reaches business logic — schema validation libraries (Zod, Joi) turn this into a declarative, type-safe step rather than manual if-checks.
import { z } from "zod";
export const createOrderSchema = z.object({
productId: z.string().uuid(),
quantity: z.number().int().positive().max(100),
});
export const validate = (schema) => (req, res, next) => {
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ error: { code: "VALIDATION", details: result.error.flatten() } });
}
req.body = result.data; // only validated, typed fields continue
next();
};
Replacing req.body with the parsed result also strips unknown fields, which closes off mass-assignment
bugs where a client sends role: "admin" and it ends up in an update query. Response shapes and status
codes are covered in more depth in
how to design REST APIs properly.
5. Authentication & Security
- JWT for stateless APIs, secure httpOnly cookies for browser sessions
- Rate limiting on auth and public endpoints
- Helmet for security headers, CORS configured explicitly (never
*with credentials) - Parameterized queries always — no string-concatenated SQL
Choosing between tokens and server-side sessions has more trade-offs than it first appears; see JWT vs session authentication. Whichever you choose, keep authorization checks (can this user act on this resource?) in the service layer, not only in route middleware, so they apply no matter which route calls the service.
Configuration That Fails Fast
Read environment variables once, validate them at startup, and export a typed config object. If
DATABASE_URL is missing, the process should refuse to start instead of failing on the first request.
const Env = z.object({
NODE_ENV: z.enum(["development", "test", "production"]),
PORT: z.coerce.number().default(3000),
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
});
export const config = Env.parse(process.env);
6. Production Readiness
- Health check endpoint for load balancers and orchestrators
- Structured JSON logging with request context
- Graceful shutdown on SIGTERM — drain in-flight requests before exiting
- Environment-based configuration, never hardcoded secrets
const server = app.listen(config.PORT);
process.on("SIGTERM", () => {
server.close(async () => { // stop accepting new connections, finish in-flight ones
await db.destroy(); // then release the database pool
process.exit(0);
});
setTimeout(() => process.exit(1), 10_000).unref(); // hard stop if draining hangs
});
Testing Strategy
- Services: unit tests with repositories stubbed; fast and focused on business rules.
- Routes: integration tests with Supertest against the real app and a disposable test database, covering auth, validation, and error responses.
- Contracts: keep an OpenAPI/Swagger spec and check responses against it so frontend and API stay in sync.
Because the app is created in one module (createApp()) and started in another (server.js),
tests can import the app without opening a port.
When to Go Beyond Express
| Framework | Strength | Consider it when |
|---|---|---|
| Express | Huge ecosystem, minimal, well understood | Your team will apply the structure in this guide |
| Fastify | Schema-based validation and serialization, lower overhead | Throughput matters and you want schemas built in |
| NestJS | Opinionated modules, dependency injection, TypeScript-first | Large teams that want enforced conventions |
If you are deciding between Node.js and a JVM stack altogether, see Spring Boot vs Node.js for backends, and for scaling concerns beyond a single service, scalable backend architecture with Node.js.
Bringing It Together
Express doesn't enforce architecture — that's the job of the engineer building on it. A clean layered structure, centralized error handling, and boundary validation are what separate a prototype from a production API.
Frequently Asked Questions
What is the best folder structure for an Express API?
A layered structure with routes, controllers, services, repositories, middleware, and config works well. Controllers handle HTTP, services hold business logic, and repositories own database access. For larger apps, group the same layers by feature module.
Should I use Zod or Joi for validation in Express?
Both work. Zod is a good default in TypeScript projects because it infers types from schemas. Joi is mature and common in JavaScript codebases. What matters is validating body, query, and params before they reach business logic.
How do I handle async errors in Express?
Express 5 passes rejected promises from async handlers to the error middleware automatically. On Express 4, wrap async handlers with a helper that catches rejections and calls next(err), or upgrade to Express 5.
Is Express still a good choice for new APIs?
Yes, for teams willing to apply their own structure. Fastify offers built-in schemas and lower overhead, and NestJS provides enforced conventions for large teams, but Express remains stable, well documented, and widely understood.
How should an Express app shut down gracefully?
On SIGTERM, stop accepting new connections with server.close, let in-flight requests finish, close the database pool, then exit. Add a timeout that forces exit if draining hangs, so orchestrators can replace the container on schedule.
I'm Sandaruwan Jayasundara — Senior Software Engineer | Backend Specialist. Explore my projects or get in touch about your backend.