Skip to content

Node.js – API Design

Well-designed APIs are predictable, consistent, and hard to misuse. Every endpoint at Cygnus Dynamics follows the same conventions so that clients — whether internal front-ends or external partners — can integrate once and apply that knowledge everywhere.

Core Rules

  • Use nouns for resources, HTTP verbs for actions
  • Return consistent response shapes on every endpoint — { data, meta } for success
  • Validate all inputs with Zod at the route level — never in the service
  • Always version your API: /api/v1/
  • Paginate all list endpoints — never return unbounded arrays
  • Use correct HTTP status codes — 200 is not the answer to everything
  • Document every endpoint in OpenAPI — the spec is the source of truth

Resource Naming

Resources are always plural nouns. Actions are expressed through HTTP verbs and sub-resources, never verbs in the URL.

# ✅ Correct — nouns, plural, lowercase, hyphenated for multi-word
GET    /api/v1/orders
GET    /api/v1/orders/:orderId
POST   /api/v1/orders
PATCH  /api/v1/orders/:orderId
DELETE /api/v1/orders/:orderId

GET    /api/v1/orders/:orderId/items
POST   /api/v1/orders/:orderId/cancellation   # sub-resource for state transitions
POST   /api/v1/orders/:orderId/refund

GET    /api/v1/users/:userId/addresses
POST   /api/v1/users/:userId/addresses

# ❌ Incorrect — verbs in URLs, inconsistent naming
POST   /api/v1/createOrder
GET    /api/v1/getOrderById/:id
POST   /api/v1/cancelOrder/:id
POST   /api/v1/order/list             # singular noun, verb in path

HTTP Method Semantics

Method Semantics Body Idempotent?
GET Retrieve resource(s) None Yes
POST Create a new resource Required No
PUT Replace a resource entirely Required Yes
PATCH Partially update a resource Required No
DELETE Remove a resource Optional Yes
// ✅ Correct — HTTP verbs match semantics
router.get('/',           authenticate, asyncHandler(ordersController.list))
router.get('/:orderId',   authenticate, asyncHandler(ordersController.getById))
router.post('/',          authenticate, validate(createOrderSchema),  asyncHandler(ordersController.create))
router.patch('/:orderId', authenticate, validate(updateOrderSchema),  asyncHandler(ordersController.update))
router.delete('/:orderId',authenticate, asyncHandler(ordersController.cancel))

// State transition — use a POST to a sub-resource
router.post('/:orderId/refund', authenticate, validate(refundSchema), asyncHandler(ordersController.refund))

Consistent Response Shape

Every endpoint returns the same envelope structure. Clients parse one shape, not a different one for each endpoint.

// ✅ Single item — success
res.status(200).json({
  data: {
    id:        'ord_abc123',
    reference: 'CYG-2026-001',
    status:    'confirmed',
    total:     99.99,
    currency:  'GBP',
  }
})

// ✅ List — success with pagination meta
res.status(200).json({
  data: [
    { id: 'ord_abc123', reference: 'CYG-2026-001', status: 'confirmed' },
    { id: 'ord_def456', reference: 'CYG-2026-002', status: 'pending' },
  ],
  meta: {
    total:    142,
    page:     1,
    pageSize: 20,
    hasMore:  true,
  }
})

// ✅ Created resource — 201 with the full resource
res.status(201).json({ data: createdOrder })

// ✅ No content — 204 with empty body
res.status(204).send()

// ❌ Inconsistent — never do this
res.json(order)                                    // no envelope
res.json({ success: true, order })                 // inconsistent key
res.json({ message: 'Order created', id: '123' }) // partial response
res.status(200).json({ error: '...' })             // error in a 200 body

Pagination

All list endpoints must be paginated. Never return an unbounded array. Use cursor-based pagination for large, frequently-updated datasets and offset-based for smaller, stable datasets.

Offset-based pagination

// orders.validator.ts
export const listOrdersSchema = z.object({
  page:     z.coerce.number().int().min(1).default(1),
  pageSize: z.coerce.number().int().min(1).max(100).default(20),
  status:   z.enum(['pending', 'confirmed', 'shipped', 'delivered', 'cancelled']).optional(),
  sort:     z.enum(['createdAt', 'total']).default('createdAt'),
  order:    z.enum(['asc', 'desc']).default('desc'),
})

// orders.service.ts
export const listOrders = async (
  userId: string,
  params: ListOrdersParams
): Promise<PaginatedResult<Order>> => {
  const { page, pageSize, status, sort, order } = params
  const skip = (page - 1) * pageSize

  const [orders, total] = await Promise.all([
    ordersRepository.findMany({ userId, status, skip, take: pageSize, sort, order }),
    ordersRepository.count({ userId, status }),
  ])

  return {
    data: orders,
    meta: {
      total,
      page,
      pageSize,
      hasMore: skip + orders.length < total,
    },
  }
}

// ✅ Client usage
GET /api/v1/orders?page=2&pageSize=20&status=confirmed&sort=createdAt&order=desc

Cursor-based pagination (for large datasets)

// Cursor-based — use when dataset is large or frequently updated
export const listOrdersWithCursor = async (
  userId: string,
  cursor: string | undefined,
  limit: number
): Promise<CursorPaginatedResult<Order>> => {
  const orders = await ordersRepository.findMany({
    userId,
    cursor: cursor ? { id: cursor } : undefined,
    take: limit + 1,   // fetch one extra to check hasMore
    orderBy: { createdAt: 'desc' },
  })

  const hasMore = orders.length > limit
  const items   = hasMore ? orders.slice(0, limit) : orders
  const nextCursor = hasMore ? items[items.length - 1]?.id : undefined

  return { data: items, meta: { nextCursor, hasMore } }
}

// ✅ Client usage
GET /api/v1/orders?limit=20
GET /api/v1/orders?limit=20&cursor=ord_abc123

Request Validation

All inputs are validated with Zod at the route level, before the controller runs. The service layer receives typed, validated data — it never re-validates.

// ✅ Route — validation middleware runs before controller
router.post(
  '/',
  authenticate,
  validate(createOrderSchema, 'body'),
  asyncHandler(ordersController.create)
)

router.get(
  '/',
  authenticate,
  validate(listOrdersSchema, 'query'),   // validate query params
  asyncHandler(ordersController.list)
)

router.get(
  '/:orderId',
  authenticate,
  validate(z.object({ orderId: z.string().uuid() }), 'params'),  // validate route params
  asyncHandler(ordersController.getById)
)

// ✅ Controller receives clean, typed data
export const create = asyncHandler(async (req: Request, res: Response) => {
  const body = req.body as CreateOrderRequest  // already validated and typed by middleware
  const order = await ordersService.createOrder(req.user!.id, body)
  res.status(201).json({ data: order })
})

// ❌ Incorrect — validation in the service layer
export const createOrder = async (userId: string, request: unknown): Promise<Order> => {
  const parsed = createOrderSchema.parse(request)  // validation belongs in the route layer
  return ordersRepository.create({ userId, ...parsed })
}

API Versioning

All APIs are versioned from day one. Version in the URL path — not in headers or query parameters (harder to test and debug).

// ✅ Version in the URL path — clear and explicit
app.use('/api/v1/orders', v1OrdersRouter)
app.use('/api/v1/users',  v1UsersRouter)
app.use('/api/v2/orders', v2OrdersRouter)  // v2 runs alongside v1 during migration

// ❌ Version in Accept header — hard to test with a browser or curl
app.use('/api/orders', ordersRouter)
// client must send: Accept: application/vnd.cygnus.v1+json

Deprecation strategy

// Add a deprecation warning header when a version is being retired
const deprecateV1 = (_req: Request, res: Response, next: NextFunction): void => {
  res.setHeader('Deprecation', 'true')
  res.setHeader('Sunset', 'Sat, 31 Dec 2026 23:59:59 GMT')
  res.setHeader('Link', '</api/v2/orders>; rel="successor-version"')
  next()
}

app.use('/api/v1/orders', deprecateV1, v1OrdersRouter)

OpenAPI Documentation

Every endpoint must be documented in an OpenAPI 3.1 spec. Use zod-to-openapi or @asteasolutions/zod-to-openapi to generate the spec from the same Zod schemas used for validation — the spec never drifts from the implementation.

// ✅ Generate OpenAPI spec from Zod schemas — never hand-write it
import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi'
import { z } from 'zod'

extendZodWithOpenApi(z)

export const createOrderSchema = z.object({
  items: z.array(
    z.object({
      productId: z.string().uuid().openapi({ example: 'prod_abc123' }),
      quantity:  z.number().int().min(1).openapi({ example: 2 }),
    })
  ).min(1),
  shippingAddressId: z.string().uuid().openapi({ example: 'addr_def456' }),
}).openapi('CreateOrderRequest')

// The spec is served at /api/docs
app.use('/api/docs', swaggerUi.serve, swaggerUi.setup(generatedSpec))

Documentation requirements for every endpoint:

  • Summary and description
  • All path, query, and body parameters with types and examples
  • All possible response status codes with response body schemas
  • Authentication requirements
  • Rate limit information (if applicable)

Tooling Quick Reference

Tool Purpose Command / Config
Zod Runtime validation + type inference zod
zod-to-openapi Generate OpenAPI spec from Zod schemas @asteasolutions/zod-to-openapi
swagger-ui-express Serve OpenAPI docs at /api/docs swagger-ui-express
express-rate-limit Rate limiting middleware express-rate-limit
helmet Security headers helmet
cors CORS policy enforcement cors
Vitest + supertest API integration tests vitest, supertest