RESTful API Design

Introduction

REST (Representational State Transfer) working with JSON over HTTP/1.1 or HTTP/2 remains the default choice for public APIs and B2B integrations due to its popularity, debuggability and broad ecosystem.

alt text

API Design & Resource Modeling

When adopting REST, the design must maintain consistency, including the following characteristics:

  • Resource-oriented
    • Use plural nouns (/api/v1/orders, /api/v1/users).
    • Placing actions in the URL, such as /orders/{id}/cancel, is a bad practice. Instead, use POST /orders/{id}/cancellations or PATCH /orders/{id} with a payload that updates state.
  • Idempotency
    • Ensure GET, PUT, DELETE are always idempotent.
    • For POST (creating resources or processing payments), enforcing an Idempotency-Key in the Header is mandatory to prevent duplicate transactions during network glitches and client retries.
  • Versioning
    • Implement versioning from day one.
    • Prefer URL Versioning (/api/v1/...) because it is explicit, easy to route at the Gateway layer (Nginx/Kong) and easier to cache compared to Header Versioning.

Uniform Interface

In REST, the system operates on two independent concepts:

  • Resource: Identified by Nouns in the URL (Nouns locate What).
  • Action: Defined by HTTP Verbs/Methods (GET, POST, PUT, DELETE, PATCH).

Self-descriptive

  • A standard REST API must be self-explicit through HTTP Method.
  • Instead of examining the URL to determine its action, systems like Web Caching, Reverse Proxies and Clients need only look at HTTP Method + Resource Path to make decisions (for example, GET is cached, while POST is not).

RESTful Solutions for Actions

To solve action-based requirements while maintaining proper REST standards, two common patterns are recommended:

  • Treating "Action" as a Sub-resource, recommended for complex workflows
    • For example, requiring an API to cancel an order /orders/{id}/cancel
    • Implementing this API as POST /orders/{id}/cancel is a bad practice because cancel is a verb. Including it in the URL transforms your API into RPC (Remote Procedure Call)
    • Instead of treating cancel as a verb, transform it into a noun representing an entity, such as cancellation
    • The resulting API will be POST /api/v1/orders/{id}/cancellations
    • The meaning is "Create (POST)" a "Cancellation request (cancellations)" belonging to this "Order (orders/{id})".
    • Using this pattern is more effective
      • It allows expanding business logic effortlessly. If you later need to store information about the actor, reason or timestamp, you simply include it in the request body to that same API.
      • You can even execute GET /orders/{id}/cancellations to view cancellation history, which the /orders/{id}/cancel API cannot do explicitly.
  • Using PATCH for State Transitions
    • If cancelling an order simply changes a status field in the Database from PENDING to CANCELLED, use PATCH /api/v1/orders/{id} with a request body.
    • This denotes updating a partial section (PATCH) of the specified Order resource.

Exceptions

For REST APIs, nouns should be used in almost all situations. However, exceptions exist where forcing nouns lowers Developer Experience (DX) and makes APIs confusing. In such cases, adopting verbs (Hybrid/Pragmatic REST) is effective:

  • Pure Calculations or Logic Execution
    • Example: Creating an API for currency conversion or calculator functions
    • Use POST /api/v1/currency/convert?from=USD&to=VND&amount=100
    • Forcing REST structure like POST /api/v1/currency-conversions creates bad DX
    • Using GET may allow proxies to cache response data, causing clients to receive stale market data
  • Advanced Search
    • When executing searches with massive filter sets, using GET with Query Strings hits URL length restrictions (Browsers/Gateways usually limit to ~2KB - 8KB).
    • Use POST /api/v1/products/search, placing complex filter payloads in the Body.
    • Although search is a verb here, it resolves the technical constraint cleanly.
  • System Operations: Actions triggering system workflows or security operations such as:
    • POST /api/v1/auth/login or POST /api/v1/auth/logout
    • POST /api/v1/users/{id}/block (When avoiding management through a sub-resource like blocks).

Distinguishing POST, PUT, PATCH

These HTTP Methods initialize and update data. While similar, they have fundamental differences:

POST

  • Used for creating new data
  • Uses non-idempotent endpoints like /users
  • Calling the API multiple times generates multiple distinct records

PUT

  • Operates as an Upsert to replace or initialize data
  • Uses idempotent endpoints like /users/user-01. The PUT API updates this user's information (if existing) or creates a new user with ID user-01
  • Used for full replacement or override. If data exists and fields are missing in the request payload, missing fields become NULL
  • Calling the API multiple times creates only one record, with subsequent calls updating existing data

PATCH

  • Used for updates similarly to PUT, but updates only fields supplied in the payload while preserving unpassed fields
  • Calling the API multiple times returns an error if data does not exist, or updates data if it exists

Detail

Create file dto/order.dto.ts

import {Type} from 'class-transformer'
import {
  IsArray,
  IsDateString,
  IsEnum,
  IsInt,
  IsNotEmpty,
  IsNumber,
  IsOptional,
  IsPositive,
  IsString,
  Min,
  ValidateNested,
} from 'class-validator'

export class CreateOrderItemDto {
  @IsNotEmpty()
  @IsString()
  productId: string

  @IsNotEmpty()
  @IsNumber()
  @IsPositive()
  quantity: number

  @IsNotEmpty()
  @IsNumber()
  @IsPositive()
  price: number
}

export class CreateOrderDto {
  @IsNotEmpty()
  @IsString()
  userId: string

  @IsArray()
  @ValidateNested({each: true})
  @Type(() => CreateOrderItemDto)
  items: CreateOrderItemDto[]

  @IsNotEmpty()
  @IsString()
  shippingAddress: string

  @IsNotEmpty()
  @IsString()
  paymentMethod: string
}

export class FilterOrderDto {
  @IsOptional()
  @Type(() => Number)
  @IsInt()
  @Min(1)
  page?: number = 1

  @IsOptional()
  @Type(() => Number)
  @IsInt()
  @Min(1)
  limit?: number = 10

  @IsOptional()
  @IsString()
  status?: string

  @IsOptional()
  @IsDateString()
  startDate?: string

  @IsOptional()
  @IsDateString()
  endDate?: string
}

export enum OrderStatus {
  PENDING = 'PENDING',
  PROCESSING = 'PROCESSING',
  SHIPPED = 'SHIPPED',
  DELIVERED = 'DELIVERED',
  CANCELLED = 'CANCELLED',
}

export class PatchOrderStatusDto {
  @IsNotEmpty()
  @IsEnum(OrderStatus, {
    message: `status must be one of: ${Object.values(OrderStatus).join(', ')}`,
  })
  status: OrderStatus
}

export class UpdateOrderDto extends CreateOrderDto {}

export class SearchOrderDto {
  @IsOptional()
  @IsString()
  q?: string

  @IsOptional()
  @IsEnum(OrderStatus)
  status?: OrderStatus

  @IsOptional()
  @Type(() => Number)
  @IsNumber()
  @Min(0)
  minAmount?: number

  @IsOptional()
  @Type(() => Number)
  @IsNumber()
  @Min(0)
  maxAmount?: number

  @IsOptional()
  @IsDateString()
  startDate?: string

  @IsOptional()
  @IsDateString()
  endDate?: string

  @IsOptional()
  @Type(() => Number)
  @IsInt()
  @Min(1)
  page?: number = 1

  @IsOptional()
  @Type(() => Number)
  @IsInt()
  @Min(1)
  limit?: number = 10
}

Create file controller/order.controller.ts

import {
  Body,
  Controller,
  Delete,
  Get,
  HttpCode,
  HttpStatus,
  Param,
  Patch,
  Post,
  Put,
  Query,
} from '@nestjs/common'
import type {
  CreateOrderDto,
  FilterOrderDto,
  PatchOrderStatusDto,
  SearchOrderDto,
  UpdateOrderDto,
} from 'src/dto/order.dto'

@Controller({
  path: 'orders',
  version: '1',
})
export class OrdersController {
  @Post()
  @HttpCode(HttpStatus.CREATED)
  create(@Body() createOrderDto: CreateOrderDto) {
    return {
      message: 'Order created successfully',
      data: createOrderDto,
    }
  }

  @Get()
  @HttpCode(HttpStatus.OK)
  findAll(@Query() filterOrderDto: FilterOrderDto) {
    return {
      message: 'Get orders list successfully',
      data: [],
      meta: {page: 1, limit: 10, total: 0},
    }
  }

  @Get('search')
  @HttpCode(HttpStatus.OK)
  search(@Query() searchOrderDto: SearchOrderDto) {
    return {
      message: 'Search orders successfully',
      data: [],
      meta: {
        page: searchOrderDto.page || 1,
        limit: searchOrderDto.limit || 10,
        total: 0,
      },
    }
  }

  @Get(':id')
  @HttpCode(HttpStatus.OK)
  findOne(@Param('id') id: string) {
    return {
      message: `Get order #${id} detail successfully`,
      data: {id},
    }
  }

  @Put(':id')
  @HttpCode(HttpStatus.OK)
  update(@Param('id') id: string, @Body() updateOrderDto: UpdateOrderDto) {
    return {
      message: `Order #${id} updated successfully`,
      data: updateOrderDto,
    }
  }

  @Patch(':id')
  @HttpCode(HttpStatus.OK)
  updateStatus(
    @Param('id') id: string,
    @Body() patchOrderStatusDto: PatchOrderStatusDto
  ) {
    return {
      message: `Order #${id} status updated successfully`,
      data: patchOrderStatusDto,
    }
  }

  @Delete(':id')
  @HttpCode(HttpStatus.NO_CONTENT)
  remove(@Param('id') id: string) {
    return
  }
}

Create file controller/order.v2.controller.ts

import {Controller, Get, HttpCode, HttpStatus, Query} from '@nestjs/common'
import type {SearchOrderDto} from 'src/dto/order.dto'

@Controller({
  path: 'orders',
  version: '2',
})
export class OrdersV2Controller {
  @Get('search')
  @HttpCode(HttpStatus.OK)
  findAll(@Query() searchOrderDto: SearchOrderDto) {
    return {
      success: true,
      statusCode: 200,
      version: 'v2',
      data: {
        orders: [],
        pagination: {page: 1, limit: 10, totalPages: 0},
      },
    }
  }
}

Modify file src/app/app.ts

import {VersioningType} from '@nestjs/common'
import {NestFactory} from '@nestjs/core'
import type {NestExpressApplication} from '@nestjs/platform-express'
import {DocumentBuilder, SwaggerModule} from '@nestjs/swagger'
import {AppModule} from 'src/app.module'

export async function bootstrap() {
  const app = await NestFactory.create<NestExpressApplication>(AppModule)

  app.enableVersioning({
    type: VersioningType.URI,
    defaultVersion: '1',
  })
  app.setGlobalPrefix('api')

  const config = new DocumentBuilder()
    .setTitle('NestJS API')
    .setDescription('The API description')
    .setVersion('1.0')
    .build()
  const document = SwaggerModule.createDocument(app, config)
  SwaggerModule.setup('api', app, document)

  const port = process.env.PORT ?? 4000
  await app.listen(port)
}

The result is as follows:

alt text

Conclusion

  • When designing an API for large systems, the core goal is consistency and scalability rather than blindly strictly adhering to REST theory
    • Default Mindset: Always start with Plural Nouns. Model requirements around Resource lifecycle management (CRUD + State Transition).
    • Rule of Thumb for Verbs: Use verbs only when the action does not directly alter state on a specific database record (like calculations, search, login) or when forcing a noun harms developer experience.
  • Document reasons clearly when intentionally deviating from REST conventions to ensure team alignment.

Happy coding!

See more articles here.

Comments

Popular posts from this blog

All Practice Series

Understanding React Server Component

Kubernetes Deployment for Zero Downtime

Sitemap

Deploying a NodeJS Server on Google Kubernetes Engine

React Practice Series

Helm for beginer - Deploy nginx to Google Kubernetes Engine

Docker Practice Series

A Handy Guide to Using Dynamic Import in JavaScript

DevOps Practice Series