architecture/api-design.md

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

  • page starts at 1 (not 0)
  • limit max 100 — reject nếu > 100
  • Response PHẢI include meta object
  • 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:

  1. Header x-tenant-id — cho API calls từ authenticated apps
  2. JWT claim tenantId — extracted từ access token
  3. 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/login
  • POST /api/auth/register
  • GET /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 undefined trong 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 DTO
  • forbidNonWhitelisted: 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ó example value
  • 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_CASE error codes
  • DTO với class-validator decorators
  • Swagger decorators: @ApiTags, @ApiOperation, @ApiProperty with 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 PUT method used
  • No trailing slashes
  • No verbs in URL (except action endpoints)