Structuring a REST API you can still maintain six months later
The three architectural decisions that separate an API that is pleasant to evolve from a project you dread opening.
- Architecture
- API
- Best practices
On my first projects I always started the same way: one routes.js file, a handful of functions, and a promise to "tidy this up later". Six months on, the smallest change took half a day. Here are the three decisions that changed that.
1. Separate transport from business logic
The highest-return habit: an HTTP route should know nothing about your domain. It reads the request, calls a service, and turns the result into a response.
// routes/orders.ts — transport only
router.post("/orders", async (req, res) => {
const input = createOrderSchema.parse(req.body);
const order = await orderService.create(input);
res.status(201).json(toOrderResponse(order));
});
The payoff is immediate: the day you add a CLI command, a batch job or a message queue, the logic is already reusable as-is.
2. Validate at the boundaries, not everywhere
Data entering the system is validated once, at the entry point. Past that boundary it is considered trustworthy and correctly typed.
This removes the defensive code that clutters every function:
// Pointless if validation already happened at the boundary
if (!order || !order.items || order.items.length === 0) { /* ... */ }
In practice I keep one schema per entry point and derive the TypeScript type from it. A single source of truth for both validation and typing.
3. Treat errors as part of the API
An error is a response like any other: it deserves a stable, documented shape.
| Case | Status | Body |
|---|---|---|
| Invalid input | 422 | { code, fields } |
| Missing resource | 404 | { code } |
| Business conflict | 409 | { code, message } |
The key part is the code field: a stable identifier clients can branch on, unlike a human-facing message that may be reworded or translated.
What I take away
These three rules cost almost nothing to put in place at the start, and they prevent a full rewrite six months later. If I had to keep one, it would be the first: separating transport from domain logic, because it is by far the most painful to retrofit.