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.
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, usePOST /orders/{id}/cancellationsorPATCH /orders/{id}with a payload that updates state.
- Use plural nouns (
Idempotency- Ensure
GET, PUT, DELETEare 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.
- Ensure
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 byHTTPVerbs/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 Pathto make decisions (for example,GETis cached, whilePOSTis 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}/cancelis a bad practice becausecancelis a verb. Including it in the URL transforms your API intoRPC (Remote Procedure Call) - Instead of treating
cancelas a verb, transform it into a noun representing an entity, such ascancellation - 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}/cancellationsto view cancellation history, which the/orders/{id}/cancelAPI cannot do explicitly.
- For example, requiring an API to cancel an order
- Using
PATCHfor State Transitions- If cancelling an order simply changes a status field in the Database from
PENDINGtoCANCELLED, usePATCH /api/v1/orders/{id}with a request body. - This denotes updating a partial section (
PATCH) of the specified Order resource.
- If cancelling an order simply changes a status field in the Database from
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-conversionscreates bad DX - Using
GETmay allow proxies to cache response data, causing clients to receive stale market data
Advanced Search- When executing searches with massive filter sets, using
GETwith 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.
- When executing searches with massive filter sets, using
- System Operations: Actions triggering system workflows or security operations such as:
POST /api/v1/auth/loginorPOST /api/v1/auth/logoutPOST /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
Upsertto replace or initialize data - Uses idempotent endpoints like
/users/user-01. ThePUTAPI updates this user's information (if existing) or creates a new user with IDuser-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:
Conclusion
- When designing an
APIfor 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.
- Default Mindset: Always start with Plural Nouns. Model requirements around Resource lifecycle management (
- Document reasons clearly when intentionally deviating from REST conventions to ensure team alignment.
Happy coding!
Comments
Post a Comment