How I Structure Production APIs So They Don't Collapse Under Growth
I've inherited enough Node.js backends to know the smell of a codebase that started clean and rotted within weeks. Route handlers full of business logic, validation scattered across files, auth checks duplicated in every endpoint. This is how I structure APIs to stay maintainable past the first feature request.
Layer Separation That Works
The four-layer model I use has held up across projects ranging from small APIs to systems handling millions of requests:
1. Routes (Thin)
Routes define the HTTP contract — method, path, middleware — and nothing else. They delegate to controllers immediately.
router.get('/projects/:id', requireAuth, projectController.getById);No logic here. If a route file has more than 30 lines, something is wrong.
2. Controllers (Request/Response Handling)
Controllers parse input, call services, format responses. They know about HTTP but not about business rules.
async function getById(req, res) {
const { id } = req.params;
const project = await projectService.findById(id, req.user.tenantId);
if (!project) return res.status(404).json({ error: 'Not found' });
res.json({ data: project });
}The controller shouldn't know how the project is fetched. It just orchestrates the flow.
3. Services (Business Logic)
Services contain the actual logic — validation, authorization checks, orchestration of multiple data sources. They know nothing about HTTP or the request/response cycle.
async function findById(projectId, tenantId) {
const project = await db.project.findUnique({
where: { id: projectId, tenantId }
});
if (!project) return null;
if (project.status === 'archived' && !isAdmin(tenantId)) {
throw new ForbiddenError('Archived projects require admin access');
}
return project;
}4. Data Access (Queries)
Raw database access lives here. Services call data access functions, not ORM methods directly. This makes it possible to swap storage without changing business logic.
Validation at the Boundary
Every request gets validated at the edge, before it reaches any controller or service. I use Zod schemas at the route level:
const createProjectSchema = z.object({
body: z.object({
title: z.string().min(3).max(100),
description: z.string().max(1000).optional(),
tenantId: z.string().uuid()
})
});
router.post('/projects',
validate(createProjectSchema),
requireAuth,
projectController.create
);A validation middleware catches malformed requests early, returning consistent error shapes. Services never need to check for missing fields — they can assume the data is structurally valid.
Auth Placement
Auth middleware at the route level handles authentication (who is this?). Authorization belongs in services (can this person do this thing?). Mixing them creates routes that either check too little or check too much.
// Route level: authentication
router.delete('/projects/:id', requireAuth, ...)
// Service level: authorization
async function deleteProject(projectId, userId) {
const project = await db.project.findUnique({ where: { id: projectId } });
if (project.ownerId !== userId) {
throw new ForbiddenError('Only the owner can delete projects');
}
// proceed with deletion
}Cache Placement
Caching belongs in the data access layer, not the service layer. Services should not know whether data comes from Redis or PostgreSQL. A cache-aside wrapper around the data access function keeps caching concerns isolated:
async function findById(id, tenantId) {
const cacheKey = `project:${tenantId}:${id}`;
const cached = await cache.get(cacheKey);
if (cached) return JSON.parse(cached);
const project = await db.project.findUnique({ where: { id, tenantId } });
if (project) await cache.set(cacheKey, JSON.stringify(project), 'EX', 300);
return project;
}When caching strategy needs to change, you change one data access function, not every endpoint handler.
What I Avoid
- Fat controllers: If a controller calls more than two services or has branching logic, extract a new service.
- Service-layer request objects: Services shouldn't import
Requestfrom Express. Pass primitives or domain-specific DTOs. - Circular dependencies: Services importing other services is fine. Services importing controllers is a design smell.
This structure isn't revolutionary, but it's proven. Every project where I've deviated from these boundaries has been harder to test, harder to debug, and harder to onboard new developers into.
Designing Multi-Tenant Systems Without Creating a Data Leak Nightmare
Schema-per-tenant vs shared-table tenancy, tradeoffs that actually matter, and why convenience-first architecture usually turns into future damage.
Redis Caching That Doesn't Rot Your System From the Inside
Caching is easy until invalidation turns your app into a liar. This breaks down practical TTL strategy, namespaced keys, and targeted invalidation.
Building RAG Pipelines That Actually Work in Production
Chunking strategies, embedding selection, retrieval re-ranking, and why naive RAG falls apart at scale without careful pipeline design.