API Design Standards
MANDATORY — Mọi API endpoint trong project PHẢI tuân thủ document này. Bất kỳ code nào vi phạm phải được sửa ngay lập tức.
1. URL Convention
Base Structure
{domain}/api/{resource}
{domain}/api/{resource}/{id}
{domain}/api/{resource}/{id}/{sub-resource}
Rules (CRITICAL)
| Rule | Correct | Wrong |
|---|---|---|
| Plural nouns | /api/bookings |
/api/booking |
| Lowercase kebab-case | /api/service-categories |
/api/serviceCategories |
| No verbs in URL | /api/bookings + POST |
/api/create-booking |
| No trailing slashes | /api/bookings |
/api/bookings/ |
| Nested max 2 levels | /api/resources/:id/skills |
/api/tenants/:id/resources/:id/skills |
ID param named :id |
/api/bookings/:id |
/api/bookings/:bookingId |
Action Endpoints (exceptions to no-verb rule)
Khi REST verb không đủ express intent, dùng action pattern:
POST /api/bookings/:id/confirm
POST /api/bookings/:id/cancel
POST /api/bookings/:id/check-in
POST /api/auth/login
POST /api/auth/logout
POST /api/auth/refresh
Action endpoints LUÔN dùng POST, KHÔNG dùng PATCH.
2. HTTP Methods
| Method | Usage | Idempotent | Request Body |
|---|---|---|---|
GET |
Read resource(s) | Yes | No |
POST |
Create resource / Execute action | No | Yes |
PATCH |
Partial update | No | Yes (partial) |
DELETE |
Remove resource | Yes | No |
KHÔNG dùng PUT — luôn dùng PATCH cho updates. Lý do: mọi update trong system là partial update, PUT yêu cầu gửi full object gây risk overwrite.
3. Response Envelope (CRITICAL)
MỌI response PHẢI wrapped trong envelope format. Không bao giờ return raw data.
Success Response — Single Item
{
"success": true,
"data": {
"id": "uuid-here",
"name": "Nails by Anna",
"slug": "nails-by-anna",
"createdAt": "2026-04-06T10:00:00.000Z"
}
}
Success Response — List (Paginated)
{
"success": true,
"data": [
{ "id": "uuid-1", "name": "Gel Manicure" },
{ "id": "uuid-2", "name": "Pedicure" }
],
"meta": {
"total": 45,
"page": 1,
"limit": 20,
"totalPages": 3
}
}
Error Response
{
"success": false,
"error": {
"code": "BOOKING_CONFLICT",
"message": "Resource has a conflicting booking at the requested time",
"details": [
{
"field": "startTime",
"message": "Conflicts with existing booking from 10:00 to 11:00"
}
]
}
}
Envelope TypeScript Interface
// PHẢI dùng cho mọi controller response
interface ApiResponse<T> {
success: boolean;
data?: T;
error?: ApiError;
meta?: PaginationMeta;
}
interface ApiError {
code: string; // UPPER_SNAKE_CASE error code
message: string; // Human-readable message (có thể show cho user)
details?: FieldError[];
}
interface FieldError {
field: string;
message: string;
}
interface PaginationMeta {
total: number;
page: number;
limit: number;
totalPages: number;
}
Implementation
Dùng NestJS interceptor để auto-wrap responses. Controller return raw data, interceptor wrap thành envelope.
// response.interceptor.ts — GLOBAL interceptor
@Injectable()
export class ResponseInterceptor<T> implements NestInterceptor<T, ApiResponse<T>> {
intercept(context: ExecutionContext, next: CallHandler): Observable<ApiResponse<T>> {
return next.handle().pipe(
map(data => ({
success: true,
data: data?.data ?? data,
meta: data?.meta,
})),
);
}
}
Error responses qua exception filter:
// http-exception.filter.ts — GLOBAL filter
@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
// Map NestJS exceptions to ApiResponse error format
}
}
4. Pagination
Request
GET /api/bookings?page=1&limit=20
| Param | Type | Default | Max | Description |
|---|---|---|---|---|
page |
number | 1 | - | 1-indexed page number |
limit |
number | 20 | 100 | Items per page |
Rules
pagestarts at 1 (not 0)limitmax 100 — reject nếu > 100- Response PHẢI include
metaobject - Empty list → return
{ success: true, data: [], meta: { total: 0, page: 1, limit: 20, totalPages: 0 } }
Implementation
// pagination.dto.ts — REUSE everywhere
export class PaginationDto {
@IsInt()
@Min(1)
@IsOptional()
@Type(() => Number)
page?: number = 1;
@IsInt()
@Min(1)
@Max(100)
@IsOptional()
@Type(() => Number)
limit?: number = 20;
}
// Usage in service
async findAll(tenantId: string, { page = 1, limit = 20 }: PaginationDto) {
const [data, total] = await Promise.all([
this.prisma.booking.findMany({
where: { tenantId },
skip: (page - 1) * limit,
take: limit,
}),
this.prisma.booking.count({ where: { tenantId } }),
]);
return {
data,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
5. Filtering & Sorting
Filter Format
Query params, flat structure:
GET /api/bookings?status=CONFIRMED&date=2026-04-07&resourceId=uuid-123
GET /api/services?isActive=true&categoryId=uuid-456
Rules
- Filter params PHẢI match field names (camelCase)
- Multiple values cho cùng field: comma-separated →
?status=CONFIRMED,PENDING - Date range: dùng
dateFrom+dateTo→?dateFrom=2026-04-01&dateTo=2026-04-30 - Boolean:
true/false(string)
Sort Format
GET /api/bookings?sort=startTime&order=asc
GET /api/customers?sort=createdAt&order=desc
| Param | Values | Default |
|---|---|---|
sort |
Any sortable field name | createdAt |
order |
asc, desc |
desc |
6. HTTP Status Codes
Success
| Code | When |
|---|---|
200 OK |
GET success, PATCH success, action success |
201 Created |
POST tạo resource mới thành công |
204 No Content |
DELETE thành công |
Client Errors
| Code | When | Error Code Pattern |
|---|---|---|
400 Bad Request |
Validation failed, malformed request | VALIDATION_ERROR |
401 Unauthorized |
Missing/invalid/expired token | UNAUTHORIZED |
403 Forbidden |
Authenticated nhưng không có quyền | FORBIDDEN |
404 Not Found |
Resource không tồn tại | {RESOURCE}_NOT_FOUND |
409 Conflict |
Business logic conflict | {RESOURCE}_{REASON} |
422 Unprocessable |
Valid format nhưng business rule fail | {BUSINESS_RULE} |
429 Too Many Requests |
Rate limit exceeded | RATE_LIMIT_EXCEEDED |
Server Errors
| Code | When |
|---|---|
500 Internal Server Error |
Unexpected error |
503 Service Unavailable |
Database/Redis down |
Error Code Convention (CRITICAL)
Error codes PHẢI là UPPER_SNAKE_CASE và follow pattern:
BOOKING_CONFLICT — Booking overlap
BOOKING_NOT_FOUND — Booking không tồn tại
RESOURCE_NOT_FOUND — Resource không tồn tại
RESOURCE_NOT_AVAILABLE — Resource ngoài schedule
TENANT_SLUG_EXISTS — Slug đã tồn tại
VALIDATION_ERROR — Input validation failed
UNAUTHORIZED — Auth failed
FORBIDDEN — Permission denied
KHÔNG BAO GIỜ dùng generic error codes như ERROR, FAILED, INVALID.
7. Multi-tenancy
Tenant Resolution
Mọi tenant-scoped endpoint nhận tenant context qua:
- Header
x-tenant-id— cho API calls từ authenticated apps - JWT claim
tenantId— extracted từ access token - Subdomain — cho customer portal (
{slug}.app.no→ resolve tenant)
Priority: JWT > Header > Subdomain
Rules (CRITICAL)
- MỌI query tenant-scoped PHẢI filter by
tenantId - KHÔNG BAO GIỜ return data cross-tenant
- Controller KHÔNG handle tenant resolution — dùng middleware/guard
- Tenant context injected via
@TenantId()custom decorator
// tenant.decorator.ts
export const TenantId = createParamDecorator(
(data: unknown, ctx: ExecutionContext): string => {
const request = ctx.switchToHttp().getRequest();
return request.tenantId; // Set by TenantGuard
},
);
// Usage
@Get()
findAll(@TenantId() tenantId: string) {
return this.service.findAll(tenantId);
}
Tenant-free Endpoints
Chỉ các endpoint sau KHÔNG cần tenant context:
POST /api/auth/loginPOST /api/auth/registerGET /api/tenants/slug/:slug(resolve tenant)GET/POST /api/tenants(admin only)
8. Authentication & Authorization
Auth Headers
Authorization: Bearer <access_token>
Token Structure (JWT)
{
"sub": "user-uuid",
"tenantId": "tenant-uuid",
"role": "OWNER",
"iat": 1712400000,
"exp": 1713004800
}
Role Hierarchy
ADMIN > OWNER > STAFF > CUSTOMER
Permission Matrix
| Endpoint | ADMIN | OWNER | STAFF | CUSTOMER | Public |
|---|---|---|---|---|---|
GET /api/tenants |
Yes | No | No | No | No |
POST /api/tenants |
Yes | No | No | No | No |
PATCH /api/tenants/:id |
Yes | Own | No | No | No |
GET /api/resources |
Yes | Own | Own | No | No |
POST /api/resources |
Yes | Own | No | No | No |
GET /api/services |
Yes | Own | Own | No | Via portal |
GET /api/bookings |
Yes | Own | Own* | Own* | No |
POST /api/bookings |
Yes | Own | Own | Via portal | No |
PATCH /api/bookings/:id |
Yes | Own | Own* | No | No |
GET /api/customers |
Yes | Own | Own | No | No |
GET /api/availability |
Yes | Own | Own | No | Via portal |
- Own = chỉ data thuộc tenant của mình
- Own* = Staff chỉ thấy bookings assigned cho mình + unassigned
Guards
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.OWNER, UserRole.ADMIN)
@Post()
create() { ... }
9. Request/Response Field Conventions
Field Naming
- camelCase cho mọi JSON field:
startTime,tenantId,isPaid - snake_case cho database columns (Prisma
@map) - UPPER_SNAKE_CASE cho enum values:
CONFIRMED,WALK_IN - kebab-case cho URL paths:
/service-categories
Date/Time
- Format: ISO 8601 —
2026-04-07T10:00:00.000Z - Timezone: LUÔN UTC trong API. Client convert sang local timezone.
- Date-only params:
YYYY-MM-DD—?date=2026-04-07
ID
- Format: UUID v4 —
550e8400-e29b-41d4-a716-446655440000 - KHÔNG dùng auto-increment integer IDs
- ID field luôn tên
id, foreign key tên{resource}Id
Money
- Unit: Smallest currency unit (øre for NOK)
- Type: Integer (không dùng float/decimal)
- Example: 450 NOK =
45000(øre) - Display: Client convert:
price / 100+ format with locale
Null vs Undefined
null= explicitly empty (e.g., unassigned booking →resourceId: null)- Omitted field in PATCH = no change (không update field đó)
- KHÔNG BAO GIỜ return
undefinedtrong JSON response
10. Validation
DTO Rules
- Mọi request body PHẢI có DTO class với decorators
- Create DTO: required fields + optional fields
- Update DTO: tất cả fields optional (partial update)
- KHÔNG dùng
PartialType()— viết explicit để control validation rules riêng
Validation Decorators
// LUÔN dùng class-validator + class-transformer
import { IsString, IsInt, Min, IsOptional, IsUUID } from 'class-validator';
import { Type } from 'class-transformer';
Global Validation Pipe
// Đã set trong main.ts — KHÔNG đổi
app.useGlobalPipes(
new ValidationPipe({
whitelist: true, // Strip unknown fields
forbidNonWhitelisted: true, // Reject unknown fields
transform: true, // Auto-transform types
}),
);
whitelist: true— auto strip fields không có trong DTOforbidNonWhitelisted: true— return 400 nếu gửi field lạtransform: true— auto parse string → number, etc.
Validation Error Format
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "Validation failed",
"details": [
{ "field": "name", "message": "name must be a string" },
{ "field": "duration", "message": "duration must be at least 5" }
]
}
}
11. Swagger / OpenAPI
Rules
- Mọi controller PHẢI có
@ApiTags() - Mọi endpoint PHẢI có
@ApiOperation({ summary }) - Mọi DTO field PHẢI có
@ApiProperty()hoặc@ApiPropertyOptional() - Mọi DTO field PHẢI có
examplevalue - Swagger UI available at
/api/docs - OpenAPI JSON at
/api/docs-json— source of truth cho codegen
Codegen Flow
NestJS Swagger → openapi.json → openapi-typescript-codegen → typed client
Web/Mobile chạy codegen script để generate typed API client. Do đó Swagger decorators PHẢI chính xác — sai decorator = sai generated types = runtime bugs.
12. Versioning
API KHÔNG version trong MVP. Khi cần:
/api/v2/bookings
- Không dùng header-based versioning
- v1 deprecated nhưng giữ 6 tháng
- Breaking changes chỉ ở major version
13. Rate Limiting
| Scope | Limit | Window |
|---|---|---|
| Global per IP | 100 req | 1 minute |
| Auth endpoints | 10 req | 1 minute |
| Booking creation | 20 req | 1 minute |
Response khi exceeded:
{
"success": false,
"error": {
"code": "RATE_LIMIT_EXCEEDED",
"message": "Too many requests, please try again later"
}
}
Headers:
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1712400060
14. Checklist cho mọi endpoint mới
Trước khi merge bất kỳ endpoint nào, PHẢI đạt tất cả:
- URL follows plural nouns, kebab-case
- Correct HTTP method (GET/POST/PATCH/DELETE)
- Response wrapped in envelope (
{ success, data, meta?, error? }) - Paginated list endpoints return
meta - Error responses dùng
UPPER_SNAKE_CASEerror codes - DTO với class-validator decorators
- Swagger decorators:
@ApiTags,@ApiOperation,@ApiPropertywith examples - Tenant-scoped endpoints filter by
tenantId - Auth guard + Role guard
- Dates in ISO 8601 UTC
- Money in smallest currency unit (integer)
- IDs are UUID
- No
PUTmethod used - No trailing slashes
- No verbs in URL (except action endpoints)