DEV Community

Gavin Cettolo
Gavin Cettolo

Posted on

Clean API Design in Node.js: A Practical Guide

Most Node.js APIs start the same way.

A server.js file. A few routes. Maybe an app.js if you've read a tutorial. Everything in one place because the app is small and there's no reason to complicate it yet.

Then the app grows.

Routes multiply. Business logic leaks into route handlers. Error handling is copy-pasted across files with slight variations. A new developer joins and spends their first week just trying to understand where things live.

The API still works. But it's become a place nobody wants to touch.

This guide is about building the structure that prevents that outcome — from the first route to a production-ready layer with validation, versioning, centralized error handling, rate limiting, and auto-generated documentation.

We'll build it incrementally, so every step is independently useful even if you stop halfway through.


TL;DR

  • Clean API design in Node.js is mostly about separation of concerns: routes, controllers, services, and validation each live in their own layer.
  • Zod handles validation at the boundary — before business logic ever runs.
  • Centralized error handling is the single change that improves the most codebases the fastest.

- Rate limiting, API versioning, and OpenAPI docs are not advanced topics — they're table stakes for any API that will be used by someone else.

Table of Contents

- Final Thoughts

What We're Building

We'll build a REST API for a simple product catalog — products with a name, price, and category. The domain is intentionally simple so the focus stays on structure, not business logic.

By the end, the API will have:

  • Clean separation between routing, controllers, and services
  • Request validation with Zod
  • Centralized error handling
  • Consistent response shaping
  • API versioning (/api/v1/)
  • Rate limiting
  • Auto-generated Swagger documentation Let's start from zero.

Step 1: Project Structure

Before writing a single line of code, let's define where things live.

src/
├── api/
│   └── v1/
│       ├── products/
│       │   ├── products.router.ts
│       │   ├── products.controller.ts
│       │   ├── products.service.ts
│       │   └── products.schema.ts
│       └── index.ts
│
├── middleware/
│   ├── errorHandler.ts
│   ├── rateLimiter.ts
│   └── validateRequest.ts
│
├── lib/
│   ├── AppError.ts
│   └── responseHelper.ts
│
├── app.ts
└── server.ts
Enter fullscreen mode Exit fullscreen mode

The logic behind this structure:

  • api/v1/ — all routes are versioned from day one. Adding v2 later is a folder, not a refactor.
  • Feature folders (products/) — every domain owns its router, controller, service, and schema. Adding a new domain means adding a new folder, not touching existing files.
  • middleware/ — cross-cutting concerns that apply to multiple routes.

- lib/ — shared utilities with no framework dependency.

Step 2: The Express App Setup

// src/app.ts

import express, { Application } from 'express'
import helmet from 'helmet'
import cors from 'cors'
import { rateLimiter } from './middleware/rateLimiter'
import { errorHandler } from './middleware/errorHandler'
import { setupSwagger } from './lib/swagger'
import v1Router from './api/v1'

export function createApp(): Application {
  const app = express()

  // Security headers
  app.use(helmet())

  // CORS
  app.use(cors({
    origin: process.env.ALLOWED_ORIGINS?.split(',') ?? '*',
    methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
  }))

  // Body parsing
  app.use(express.json({ limit: '10kb' }))

  // Rate limiting — applied globally before any route
  app.use(rateLimiter)

  // API routes
  app.use('/api/v1', v1Router)

  // Swagger docs
  setupSwagger(app)

  // 404 handler — must come after all routes
  app.use((req, res) => {
    res.status(404).json({
      success: false,
      error: { code: 'NOT_FOUND', message: `Route ${req.method} ${req.path} not found` },
    })
  })

  // Centralized error handler — must be last
  app.use(errorHandler)

  return app
}
Enter fullscreen mode Exit fullscreen mode
// src/server.ts

import { createApp } from './app'

const PORT = process.env.PORT ?? 3000

const app = createApp()

app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`)
})
Enter fullscreen mode Exit fullscreen mode

Two files, two responsibilities. app.ts defines the application. server.ts starts it. This separation makes testing significantly easier — you can import createApp() in tests without starting a real server.


Step 3: Routing and Controllers — Separating Concerns

The most common mistake in Express apps is putting business logic inside route handlers:

// ❌ Everything in one place — this is how the mess starts
app.get('/products/:id', async (req, res) => {
  try {
    const product = products.find(p => p.id === req.params.id)
    if (!product) {
      return res.status(404).json({ error: 'Product not found' })
    }
    res.json(product)
  } catch (error) {
    res.status(500).json({ error: 'Something went wrong' })
  }
})
Enter fullscreen mode Exit fullscreen mode

The route handler is doing three things: routing, business logic, and error handling. As the application grows, this becomes unmaintainable.

The fix is a clean separation between the router (where) and the controller (what):

// src/api/v1/products/products.router.ts

import { Router } from 'express'
import { productController } from './products.controller'
import { validateRequest } from '@/middleware/validateRequest'
import {
  createProductSchema,
  updateProductSchema,
  productIdSchema,
} from './products.schema'

const router = Router()

router.get('/', productController.getAll)
router.get('/:id', validateRequest({ params: productIdSchema }), productController.getById)
router.post('/', validateRequest({ body: createProductSchema }), productController.create)
router.put('/:id',
  validateRequest({ params: productIdSchema, body: updateProductSchema }),
  productController.update
)
router.delete('/:id', validateRequest({ params: productIdSchema }), productController.remove)

export default router
Enter fullscreen mode Exit fullscreen mode
// src/api/v1/products/products.controller.ts

import { Request, Response, NextFunction } from 'express'
import { productService } from './products.service'
import { sendSuccess } from '@/lib/responseHelper'
import { AppError } from '@/lib/AppError'

export const productController = {
  async getAll(req: Request, res: Response, next: NextFunction) {
    try {
      const products = await productService.getAll()
      sendSuccess(res, products)
    } catch (error) {
      next(error)
    }
  },

  async getById(req: Request, res: Response, next: NextFunction) {
    try {
      const product = await productService.getById(req.params.id)
      if (!product) throw new AppError('Product not found', 404, 'NOT_FOUND')
      sendSuccess(res, product)
    } catch (error) {
      next(error)
    }
  },

  async create(req: Request, res: Response, next: NextFunction) {
    try {
      const product = await productService.create(req.body)
      sendSuccess(res, product, 201)
    } catch (error) {
      next(error)
    }
  },

  async update(req: Request, res: Response, next: NextFunction) {
    try {
      const product = await productService.update(req.params.id, req.body)
      if (!product) throw new AppError('Product not found', 404, 'NOT_FOUND')
      sendSuccess(res, product)
    } catch (error) {
      next(error)
    }
  },

  async remove(req: Request, res: Response, next: NextFunction) {
    try {
      await productService.remove(req.params.id)
      sendSuccess(res, null, 204)
    } catch (error) {
      next(error)
    }
  },
}
Enter fullscreen mode Exit fullscreen mode

The controller's only job is to translate HTTP into service calls and back. It doesn't know how products are stored. It doesn't know the business rules. It knows HTTP.

Notice the next(error) pattern — every caught error is forwarded to the centralized error handler, which we'll define in Step 6.


Step 4: The Service Layer — Keeping Business Logic Out of Controllers

The service layer is where business logic lives. It knows nothing about HTTP — no req, no res, no status codes.

// src/api/v1/products/products.service.ts

import { Product, CreateProductPayload, UpdateProductPayload } from './products.schema'
import { AppError } from '@/lib/AppError'

// In-memory mock — replace with your DB layer
const mockProducts: Product[] = [
  { id: '1', name: 'Wireless Keyboard', price: 79.99, category: 'Electronics', createdAt: new Date().toISOString() },
  { id: '2', name: 'Standing Desk', price: 349.00, category: 'Furniture', createdAt: new Date().toISOString() },
  { id: '3', name: 'Noise-Cancelling Headphones', price: 199.99, category: 'Electronics', createdAt: new Date().toISOString() },
]

let nextId = 4

export const productService = {
  async getAll(): Promise<Product[]> {
    return mockProducts
  },

  async getById(id: string): Promise<Product | null> {
    return mockProducts.find(p => p.id === id) ?? null
  },

  async create(payload: CreateProductPayload): Promise<Product> {
    // Business rule: no duplicate product names
    const existing = mockProducts.find(
      p => p.name.toLowerCase() === payload.name.toLowerCase()
    )
    if (existing) {
      throw new AppError('A product with this name already exists', 409, 'CONFLICT')
    }

    const product: Product = {
      id: String(nextId++),
      ...payload,
      createdAt: new Date().toISOString(),
    }

    mockProducts.push(product)
    return product
  },

  async update(id: string, payload: UpdateProductPayload): Promise<Product | null> {
    const index = mockProducts.findIndex(p => p.id === id)
    if (index === -1) return null

    mockProducts[index] = { ...mockProducts[index], ...payload }
    return mockProducts[index]
  },

  async remove(id: string): Promise<void> {
    const index = mockProducts.findIndex(p => p.id === id)
    if (index === -1) {
      throw new AppError('Product not found', 404, 'NOT_FOUND')
    }
    mockProducts.splice(index, 1)
  },
}
Enter fullscreen mode Exit fullscreen mode

The service throws AppError for business rule violations — not HTTP errors. The controller translates those into HTTP responses. The layers don't bleed into each other.

When you replace the mock with a real database, you only touch the service. The controller, the router, the validation — none of them change.


Step 5: Validation with Zod — Stop Trusting Your Inputs

Every API that accepts external input needs validation. Without it, you're one malformed request away from a runtime error, a data integrity problem, or a security vulnerability.

Zod lets you define a schema and validate against it with full TypeScript inference — the same schema gives you runtime validation and compile-time types.

// src/api/v1/products/products.schema.ts

import { z } from 'zod'

const CATEGORIES = ['Electronics', 'Furniture', 'Clothing', 'Books', 'Other'] as const

export const createProductSchema = z.object({
  name: z.string()
    .min(2, 'Name must be at least 2 characters')
    .max(100, 'Name must be at most 100 characters')
    .trim(),
  price: z.number()
    .positive('Price must be a positive number')
    .multipleOf(0.01, 'Price must have at most 2 decimal places'),
  category: z.enum(CATEGORIES, {
    errorMap: () => ({ message: `Category must be one of: ${CATEGORIES.join(', ')}` }),
  }),
})

export const updateProductSchema = createProductSchema.partial()

export const productIdSchema = z.object({
  id: z.string().min(1, 'Product ID is required'),
})

// TypeScript types derived directly from the schemas
export type Product = z.infer<typeof createProductSchema> & {
  id: string
  createdAt: string
}
export type CreateProductPayload = z.infer<typeof createProductSchema>
export type UpdateProductPayload = z.infer<typeof updateProductSchema>
Enter fullscreen mode Exit fullscreen mode

Now the validateRequest middleware that the router uses:

// src/middleware/validateRequest.ts

import { Request, Response, NextFunction } from 'express'
import { ZodSchema, ZodError } from 'zod'
import { AppError } from '@/lib/AppError'

interface ValidationSchemas {
  body?: ZodSchema
  params?: ZodSchema
  query?: ZodSchema
}

export function validateRequest(schemas: ValidationSchemas) {
  return (req: Request, res: Response, next: NextFunction) => {
    try {
      if (schemas.body) {
        req.body = schemas.body.parse(req.body)
      }
      if (schemas.params) {
        req.params = schemas.params.parse(req.params)
      }
      if (schemas.query) {
        req.query = schemas.query.parse(req.query)
      }
      next()
    } catch (error) {
      if (error instanceof ZodError) {
        const details = error.errors.map(e => ({
          field: e.path.join('.'),
          message: e.message,
        }))
        next(new AppError('Validation failed', 400, 'VALIDATION_ERROR', details))
      } else {
        next(error)
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Validation happens at the boundary — before the request reaches the controller. If the input is invalid, the request never gets further. The controller and service can trust that req.body and req.params are exactly the shape they expect.


Step 6: Centralized Error Handling

Scattered error handling is one of the most common problems in Node.js APIs. Some routes return { error: "message" }. Others return { message: "error" }. Some return HTML error pages by accident. None of them are consistent.

The fix is a single error handler that every error flows through:

// src/lib/AppError.ts

export class AppError extends Error {
  constructor(
    public readonly message: string,
    public readonly statusCode: number = 500,
    public readonly code: string = 'INTERNAL_ERROR',
    public readonly details?: unknown
  ) {
    super(message)
    this.name = 'AppError'
  }
}
Enter fullscreen mode Exit fullscreen mode
// src/middleware/errorHandler.ts

import { Request, Response, NextFunction } from 'express'
import { AppError } from '@/lib/AppError'
import { ZodError } from 'zod'

export function errorHandler(
  error: unknown,
  req: Request,
  res: Response,
  next: NextFunction
) {
  // Known application error
  if (error instanceof AppError) {
    return res.status(error.statusCode).json({
      success: false,
      error: {
        code: error.code,
        message: error.message,
        ...(error.details ? { details: error.details } : {}),
      },
    })
  }

  // Unhandled Zod error (shouldn't reach here, but just in case)
  if (error instanceof ZodError) {
    return res.status(400).json({
      success: false,
      error: {
        code: 'VALIDATION_ERROR',
        message: 'Validation failed',
        details: error.errors,
      },
    })
  }

  // JSON parse errors from express.json()
  if (error instanceof SyntaxError && 'body' in error) {
    return res.status(400).json({
      success: false,
      error: {
        code: 'INVALID_JSON',
        message: 'Request body contains invalid JSON',
      },
    })
  }

  // Unknown errors — log and return generic response
  console.error('[Unhandled Error]', error)

  return res.status(500).json({
    success: false,
    error: {
      code: 'INTERNAL_ERROR',
      message: 'An unexpected error occurred',
    },
  })
}
Enter fullscreen mode Exit fullscreen mode

Every error in the entire application flows through this single function. Every error response has the same shape. Debugging is easier. Client-side error handling is easier. Adding logging or monitoring is a one-line change in one place.


Step 7: Response Shaping — Consistent API Responses

Error responses are consistent. Success responses should be too.

// src/lib/responseHelper.ts

import { Response } from 'express'

interface SuccessResponse<T> {
  success: true
  data: T
  meta?: Record<string, unknown>
}

export function sendSuccess<T>(
  res: Response,
  data: T,
  statusCode = 200,
  meta?: Record<string, unknown>
): void {
  if (statusCode === 204) {
    res.status(204).send()
    return
  }

  const response: SuccessResponse<T> = {
    success: true,
    data,
    ...(meta ? { meta } : {}),
  }

  res.status(statusCode).json(response)
}
Enter fullscreen mode Exit fullscreen mode

Every successful response now looks like this:

{
  "success": true,
  "data": {
    "id": "1",
    "name": "Wireless Keyboard",
    "price": 79.99,
    "category": "Electronics",
    "createdAt": "2025-06-01T10:00:00.000Z"
  }
}
Enter fullscreen mode Exit fullscreen mode

Every error response looks like this:

{
  "success": false,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Validation failed",
    "details": [
      { "field": "price", "message": "Price must be a positive number" }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

Consistent. Predictable. Easy to consume from a frontend or a third-party client.


Step 8: API Versioning

Versioning from day one costs almost nothing. Adding it later costs a lot.

// src/api/v1/index.ts

import { Router } from 'express'
import productsRouter from './products/products.router'

const v1Router = Router()

v1Router.use('/products', productsRouter)
// v1Router.use('/orders', ordersRouter)
// v1Router.use('/users', usersRouter)

export default v1Router
Enter fullscreen mode Exit fullscreen mode

In app.ts, the router is already mounted at /api/v1. When you need a breaking change, you create src/api/v2/, mount it at /api/v2, and both versions coexist without conflict.

// app.ts — adding v2 is a two-line change
app.use('/api/v1', v1Router)
app.use('/api/v2', v2Router)
Enter fullscreen mode Exit fullscreen mode

No migration pain. No breaking existing clients.


Step 9: Rate Limiting

Rate limiting protects your API from abuse — intentional or accidental. It's a one-time setup with express-rate-limit:

// src/middleware/rateLimiter.ts

import rateLimit from 'express-rate-limit'

export const rateLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100,                  // max 100 requests per window per IP
  standardHeaders: true,     // return rate limit info in headers
  legacyHeaders: false,
  message: {
    success: false,
    error: {
      code: 'RATE_LIMIT_EXCEEDED',
      message: 'Too many requests, please try again later.',
    },
  },
})

// Stricter limiter for write operations
export const writeLimiter = rateLimit({
  windowMs: 60 * 1000, // 1 minute
  max: 20,
  standardHeaders: true,
  legacyHeaders: false,
  message: {
    success: false,
    error: {
      code: 'RATE_LIMIT_EXCEEDED',
      message: 'Too many write requests, please slow down.',
    },
  },
})
Enter fullscreen mode Exit fullscreen mode

Apply the stricter limiter to mutation routes:

// products.router.ts — add writeLimiter to POST, PUT, DELETE
import { writeLimiter } from '@/middleware/rateLimiter'

router.post('/', writeLimiter, validateRequest({ body: createProductSchema }), productController.create)
router.put('/:id', writeLimiter, validateRequest({ params: productIdSchema, body: updateProductSchema }), productController.update)
router.delete('/:id', writeLimiter, validateRequest({ params: productIdSchema }), productController.remove)
Enter fullscreen mode Exit fullscreen mode

Step 10: OpenAPI Documentation with Swagger

An API without documentation is an API that only you can use.

swagger-jsdoc generates an OpenAPI spec from JSDoc comments. swagger-ui-express serves it as an interactive UI.

// src/lib/swagger.ts

import swaggerJsdoc from 'swagger-jsdoc'
import swaggerUi from 'swagger-ui-express'
import { Application } from 'express'

const options: swaggerJsdoc.Options = {
  definition: {
    openapi: '3.0.0',
    info: {
      title: 'Product Catalog API',
      version: '1.0.0',
      description: 'A clean REST API for managing products',
    },
    servers: [{ url: '/api/v1' }],
  },
  apis: ['./src/api/**/*.router.ts'],
}

const spec = swaggerJsdoc(options)

export function setupSwagger(app: Application): void {
  app.use('/api/docs', swaggerUi.serve, swaggerUi.setup(spec))
}
Enter fullscreen mode Exit fullscreen mode

Now add JSDoc annotations to your router:

// products.router.ts — with Swagger annotations

/**
 * @swagger
 * /products:
 *   get:
 *     summary: Get all products
 *     tags: [Products]
 *     responses:
 *       200:
 *         description: List of products
 *         content:
 *           application/json:
 *             schema:
 *               type: object
 *               properties:
 *                 success:
 *                   type: boolean
 *                 data:
 *                   type: array
 *                   items:
 *                     $ref: '#/components/schemas/Product'
 *
 * /products/{id}:
 *   get:
 *     summary: Get a product by ID
 *     tags: [Products]
 *     parameters:
 *       - in: path
 *         name: id
 *         required: true
 *         schema:
 *           type: string
 *     responses:
 *       200:
 *         description: Product found
 *       404:
 *         description: Product not found
 */
Enter fullscreen mode Exit fullscreen mode

The interactive documentation is now available at http://localhost:3000/api/docs. Every endpoint is documented, explorable, and testable directly from the browser.


Why You Might Consider Fastify Instead

Everything we've built works well with Express. But if you're starting a new project in 2025, Fastify deserves serious consideration.

Here's an honest comparison:

Express Fastify
Performance Good ~2x faster (benchmarks)
TypeScript support Manual setup First-class, built-in
Schema validation Via middleware (Zod, Joi) Built-in (JSON Schema / Zod plugin)
Plugin ecosystem Massive, mature Smaller, but growing fast
Learning curve Very low Low
Logging Manual (Winston, Pino) Built-in (Pino)
OpenAPI generation Manual (swagger-jsdoc) Via @fastify/swagger

The most important differences in practice:

Performance. Fastify is consistently faster than Express in benchmarks — sometimes significantly. For most applications, this doesn't matter at all. For high-throughput APIs, it can.

TypeScript. Fastify was designed with TypeScript in mind. Route handlers are typed end-to-end with no extra configuration. In Express, you need to augment Request types manually.

Schema-first validation. Fastify uses JSON Schema (or Zod via a plugin) for validation and serialization. Responses are validated before they're sent, which means TypeScript types and runtime behavior are guaranteed to match.

When to stick with Express:

  • You're joining an existing Express codebase.
  • Your team already knows Express deeply.
  • You rely on Express-specific middleware that has no Fastify equivalent.
  • You want the largest possible ecosystem of tutorials, Stack Overflow answers, and community resources. When to consider Fastify:
  • You're starting a new project from scratch.
  • TypeScript support matters to you out of the box.
  • You care about raw performance.
  • You want built-in logging and schema validation without extra setup. The architecture we've built in this article — feature folders, controller/service separation, centralized error handling, versioning — applies equally to Fastify. The structural principles don't change. Only the framework-specific syntax does.

{% embed https://fastify.dev %}


Final Thoughts

We started with a server.js file and a vague sense that things should be better organized.

We ended with a structure that scales:

  • Routes declare what exists and what middleware applies.
  • Controllers translate HTTP into service calls.
  • Services contain business logic, framework-agnostic.
  • Schemas define and validate the contract at the boundary.
  • Middleware handles cross-cutting concerns once, globally. None of these steps are complicated individually. The value is in the combination — and in doing it from the start, before the codebase makes it expensive.

The next time you start a Node.js API, resist the temptation to put everything in one file "just for now." The structure we've built here isn't overengineering — it's the minimum that makes a backend maintainable by more than one person.


What does your current Express setup look like?

Are you working with a structure like this, or inheriting something that grew organically? Drop your setup — or your horror story — in the comments.

If this was useful, a ❤️ or a 🦄 helps it reach more backend developers who need it.
And follow along for the next article in the series.

Top comments (0)