Vishal.dev
Back
Full-Stack

How I Structure Production APIs So They Don't Collapse Under Growth

March 24, 2026 3 min read
Node.jsTypeScriptAPIsScalability

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 Request from 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.