Express 5 — API conventions
Architecture
backend/
├── src/
│ ├── controllers/ # Thin — parse request, call service, send response
│ ├── services/ # Business logic, Prisma calls, validations
│ ├── middleware/ # Auth, validation, rate limiting, error handler
│ ├── validators/ # Zod schemas per resource
│ ├── routes/ # Route declarations, applies middleware
│ └── utils/ # Helpers, logger, constants
Controllers
A controller does three things:
- Extract data from
req (params, body, query, user)
- Call the corresponding service
- Return the response with the right status code
// GOOD
const getItem = async (req, res, next) => {
const item = await itemService.getById(req.params.id, req.user?.id);
res.json(item);
};
// BAD — business logic in the controller
const getItem = async (req, res, next) => {
const item = await prisma.item.findUnique({ where: { id: req.params.id } });
if (!item) throw new NotFoundError('Item not found');
const ratings = await prisma.rating.aggregate({ /* ... */ });
// 50 lines of logic...
};
Validation
- Zod on EVERY endpoint. No exceptions.
- Schemas live in
validators/. One file per resource.
- A
validate(schema) middleware applied in the routes.
// validators/item.validator.js
const createItemSchema = z.object({
body: z.object({
name: z.string().min(1).max(200),
tags: z.array(z.string()).min(1),
description: z.string().min(10),
}),
});
Error handling
- Custom error classes:
NotFoundError, UnauthorizedError, ValidationError, ForbiddenError.
- All inherit from
AppError with a statusCode.
- A single global error handler as the last middleware.
- Never wrap controllers in
try/catch under Express 5 — async errors bubble up automatically.
Status codes
200 — successful GET / PUT / PATCH
201 — successful POST creating a resource
204 — successful DELETE (no body)
400 — validation failed
401 — not authenticated
403 — authenticated but not authorized
404 — resource not found
409 — conflict (duplicate)
429 — rate limited
500 — server error (never expose stack traces in production)
Auth middleware
authenticate — verifies the JWT, attaches req.user
optionalAuth — attempts to verify, doesn't block if absent
authorize(roles) — role check, runs after authenticate
Anti-patterns
- ❌
res.status(200).json({ error: true }) — use real status codes
- ❌ Business logic inside controllers
- ❌ Catch-all
try/catch in every controller (pointless under Express 5)
- ❌
req.body without Zod validation
1---2name: express-api3description: Express 5 conventions and REST API patterns. Activates when working on routes, controllers, middleware, validation, or error handling in a Node.js/Express backend.4---56# Express 5 — API conventions78## Architecture910```11backend/12├── src/13│ ├── controllers/ # Thin — parse request, call service, send response14│ ├── services/ # Business logic, Prisma calls, validations15│ ├── middleware/ # Auth, validation, rate limiting, error handler16│ ├── validators/ # Zod schemas per resource17│ ├── routes/ # Route declarations, applies middleware18│ └── utils/ # Helpers, logger, constants19```2021## Controllers2223A controller does three things:241. Extract data from `req` (params, body, query, user)252. Call the corresponding service263. Return the response with the right status code2728```js29// GOOD30const getItem = async (req, res, next) => {31 const item = await itemService.getById(req.params.id, req.user?.id);32 res.json(item);33};3435// BAD — business logic in the controller36const getItem = async (req, res, next) => {37 const item = await prisma.item.findUnique({ where: { id: req.params.id } });38 if (!item) throw new NotFoundError('Item not found');39 const ratings = await prisma.rating.aggregate({ /* ... */ });40 // 50 lines of logic...41};42```4344## Validation4546- Zod on EVERY endpoint. No exceptions.47- Schemas live in `validators/`. One file per resource.48- A `validate(schema)` middleware applied in the routes.4950```js51// validators/item.validator.js52const createItemSchema = z.object({53 body: z.object({54 name: z.string().min(1).max(200),55 tags: z.array(z.string()).min(1),56 description: z.string().min(10),57 }),58});59```6061## Error handling6263- Custom error classes: `NotFoundError`, `UnauthorizedError`, `ValidationError`, `ForbiddenError`.64- All inherit from `AppError` with a `statusCode`.65- A single global error handler as the last middleware.66- **Never** wrap controllers in `try/catch` under Express 5 — async errors bubble up automatically.6768## Status codes6970- `200` — successful GET / PUT / PATCH71- `201` — successful POST creating a resource72- `204` — successful DELETE (no body)73- `400` — validation failed74- `401` — not authenticated75- `403` — authenticated but not authorized76- `404` — resource not found77- `409` — conflict (duplicate)78- `429` — rate limited79- `500` — server error (never expose stack traces in production)8081## Auth middleware8283- `authenticate` — verifies the JWT, attaches `req.user`84- `optionalAuth` — attempts to verify, doesn't block if absent85- `authorize(roles)` — role check, runs after `authenticate`8687## Anti-patterns8889- ❌ `res.status(200).json({ error: true })` — use real status codes90- ❌ Business logic inside controllers91- ❌ Catch-all `try/catch` in every controller (pointless under Express 5)92- ❌ `req.body` without Zod validation