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 |