惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

V
Vulnerabilities – Threatpost
V
Visual Studio Blog
A
About on SuperTechFans
WordPress大学
WordPress大学
B
Blog
Microsoft Azure Blog
Microsoft Azure Blog
Google DeepMind News
Google DeepMind News
P
Palo Alto Networks Blog
C
CERT Recently Published Vulnerability Notes
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
Security Latest
Security Latest
T
Threat Research - Cisco Blogs
AWS News Blog
AWS News Blog
Y
Y Combinator Blog
云风的 BLOG
云风的 BLOG
N
Netflix TechBlog - Medium
S
Securelist
MyScale Blog
MyScale Blog
Recent Announcements
Recent Announcements
阮一峰的网络日志
阮一峰的网络日志
S
SegmentFault 最新的问题
Recorded Future
Recorded Future
GbyAI
GbyAI
P
Privacy & Cybersecurity Law Blog
Project Zero
Project Zero
L
Lohrmann on Cybersecurity
罗磊的独立博客
W
WeLiveSecurity
TaoSecurity Blog
TaoSecurity Blog
雷峰网
雷峰网
Spread Privacy
Spread Privacy
N
News | PayPal Newsroom
Help Net Security
Help Net Security
Know Your Adversary
Know Your Adversary
T
The Exploit Database - CXSecurity.com
博客园 - 叶小钗
C
Check Point Blog
The Hacker News
The Hacker News
C
CXSECURITY Database RSS Feed - CXSecurity.com
Latest news
Latest news
小众软件
小众软件
The Register - Security
The Register - Security
S
Schneier on Security
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
P
Proofpoint News Feed
博客园 - Franky
Stack Overflow Blog
Stack Overflow Blog
量子位
Hugging Face - Blog
Hugging Face - Blog
爱范儿
爱范儿

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant Common SOC 2 Failures (Real World) Stop Vibe-Checking Your AI App: A Practical Guide to Evals How to Use SonarQube and SonarScanner Locally to Level Up Your Code Quality Your Next To-Do App Is Dead — I Replaced Mine with an OpenClaw AI Sign a Nostr event in 60 lines of Python using coincurve — no nostr-sdk, no nbxplorer, no rust toolchain ITGC Audit Explained Like You’re in Big 4 Patch Tuesday abril 2026: Microsoft parcha 163 vulnerabilidades y un zero-day en SharePoint Stop scraping everything: a better way to track competitor price changes Listing on MCPize + the Official MCP Registry while routing payments OUTSIDE the marketplace — how I kept 100% of my x402 revenue Building an AI-Powered Risk Intelligence System Using Serverless Architecture Why We Ripped Function Overloading Out of Our AI Toolchain Testing AI-Generated Code: How to Actually Know If It Works SaaS Churn Is Killing Your Business. Here Is What to Do About It (Without a Support Team) The Speed of AI Is No Longer Linear - And Self-Improving Models Are Why How to Implement RBAC for MCP Tools: A Practical Guide for Engineering Teams From Standard Quote to Persuasive Proposal: AI Automation for Arborists I built a CLI that scaffolds complete multi-tenant SaaS apps Axios CVE-2025–62718: The Silent SSRF Bug That Could Be Hiding in Your Node.js App Right Now The dashboard that ended our friendship Data Pipelines Explained Simply (and How to Build Them with Python) The Hidden Cost of AI Systems Nobody Talks About. undefined vs undeclared, and how typeof behaves Switching from file-based jobs to NATS/Kafka in Rust without changing code io_uring Adventures: Rust Servers That Love Syscalls Why Agentic AI is Killing the Traditional Database The POUR principles of web accessibility for developers and designers Quantum Neural Network 3D — A Deep Dive into Interactive WebGL Visualization How To Install Caveman In Codex On macOS And Windows Automation Pipeline Reliability: Why Your Workflow Breaks When Nobody Is Watching I Built an 'Open World' AI Coding Agent — It Works From ANY Folder From Freelancing to Product: A Tech Service Company's SaaS Transformation China's AI Giants: Adding Tencent Hunyuan & ByteDance Doubao to AI University (74 Providers) On the Vibe Coders and Their Lies clerk: Auto-Summarize Your Claude Code Sessions AI Weekly — 2026/04/10–04/17 | The Model Lockdown Is Here, but the Toolchain Is the Real Battleground AI 週報 — 2026/04/10–2026/04/17 模型封鎖潮來了,但工具鏈才是真戰場 Maybe this is how Open-Source apps are born... 🚀 Fine-Tune LLMs with LoRA and QLoRA: 2026 Guide tRPC v11 + Next.js App Router: End-to-End Type Safety Without the Boilerplate ShadCN UI in 2026: Why I Stopped Installing Component Libraries and Started Owning My Components SaaS Billing in React Server Components: Stripe + Supabase Without a Single `useEffect` Join our DEV Weekend Challenge — $1,000 in Prizes Across TEN winners! Submissions Due April 20 at 6:59 AM UTC. Implementing FSRS Spaced Repetition in Flutter + Supabase — Adding Memory Science to an AI Learning App "I Texted My Localhost From the Train — Claude Code Fixed the Bug Before I Got Home" I Built a Sales Prep AI and It Went Deeper Than Expected Design to Code #2: One JSON, Eleven Outputs Solving the 100M-Row Problem: A Summary Table Pattern for High-Volume Push Notification Logs Flutter Web With Wasm: What Actually Changes For Developers I Built 50 Royalty-Free Soundtracks for My Side Project in a Weekend Using AI Music Generation The Vibe Coding Security Checklist: 7 Things to Check Before You Ship Stop Letting Googlebot Guess Fix Your React App's SEO Right Desconstruindo o Streaming do LinkedIn: Como Criar um Engine de Extração de Vídeo de Alta Performance com HLS e FFmpeg (EDA Part-1) EDA (Exploratory Data Analysis) Explained With Real Life — Why Looking at Your Data Is the Most Important Step in Machine Learning Brand Relationship Management at Scale: Our 4-Touch Outreach System for 200+ Brands Why String.fromEnvironment() Might Return an Empty String in Dart JGuardrails 1.0.0 — Hardening Java LLM Apps Against Jailbreaks, Toxicity, and Prompt Injection Plan and Schedule a Full Week of Threads Content From One Claude Conversation Coding Cat Oran Ep3, Five Tables Changed Everything Updated: BFF Pattern I'm done watching freelancers get buried by 200 proposals. So I'm building the alternative. This is my first post BFS Algorithm in Java Step by Step Tutorial with Examples Tracking LLM Pricing Monthly: An Open Dataset for 22 AI Models How We Measure Content ROI on a Comparison Site: Revenue Attribution Without Perfect Data Introducing Nova AI Ops: The AI-Native Operating System for SRE Teams I built a free desktop video downloader for Windows — Grabbit How Talkie OCR Helps Vision-Impaired & Dyslexic Users Read the World Around Them VRCFaceTracking安装和iPhone面捕配置教程,有bug Even CrowdStrike Can't See Your Agents The Automation Gold Rush: What n8n Workflows and Claude Are Opening Up for Developers Right Now
Clean API Design in Node.js: A Practical Guide
Gavin Cettolo · 2026-06-16 · via DEV Community

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

  • What We're Building
  • Step 1: Project Structure
  • Step 2: The Express App Setup
  • Step 3: Routing and Controllers — Separating Concerns
  • Step 4: The Service Layer — Keeping Business Logic Out of Controllers
  • Step 5: Validation with Zod — Stop Trusting Your Inputs
  • Step 6: Centralized Error Handling
  • Step 7: Response Shaping — Consistent API Responses
  • Step 8: API Versioning
  • Step 9: Rate Limiting
  • Step 10: OpenAPI Documentation with Swagger
  • Why You Might Consider Fastify Instead

- 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

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
}

// 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}`)
})

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' })
  }
})

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

// 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)
    }
  },
}

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)
  },
}

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>

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)
      }
    }
  }
}

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'
  }
}

// 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',
    },
  })
}

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)
}

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"
  }
}

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" }
    ]
  }
}

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

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)

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.',
    },
  },
})

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)


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))
}

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
 */

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.