Skip to content

Tiêu Chuẩn Code — Backend (NestJS)

Dự ÁnPTX Channel Manager (ptx-cm)
Cập Nhật2026-02-20
Xem thêmcode-standards.md (nguyên tắc & đặt tên)

1. Cấu Trúc Module NestJS

Bố Cục Module Tiêu Chuẩn

src/modules/[tên-module]/
├── [tên-module].controller.ts    # HTTP handler, decorator
├── [tên-module].service.ts       # Logic nghiệp vụ, truy vấn DB
├── [tên-module].module.ts        # Cấu hình module
├── [tên-module].service.spec.ts  # Unit test
├── dto/
│   ├── create-[entity].dto.ts    # DTO request
│   ├── list-[entity].dto.ts      # DTO tham số truy vấn
│   └── update-[entity].dto.ts    # DTO cập nhật một phần
└── entities/ (tùy chọn)
    └── [entity].entity.ts         # Kiểu response nếu phức tạp

Mẫu Validation DTO

typescript
// ✓ Tốt: Dùng class-validator decorator
import { IsEmail, IsString, MinLength } from 'class-validator';

export class CreateUserDto {
  @IsEmail()
  email: string;

  @IsString()
  @MinLength(8)
  password: string;

  @IsString()
  name: string;
}

// ✗ Xấu: Không validation, kiểm tra thủ công trong handler
export class CreateUserDto {
  email: string;
  password: string;
  name: string;
}

Mẫu Controller

typescript
// ✓ Tốt: Logic tối thiểu, ủy quyền cho service
@Controller('properties')
export class PropertiesController {
  constructor(private propertiesService: PropertiesService) {}

  @Get()
  async findAll(
    @Query() dto: ListPropertiesDto,
    @CountryScope() countryScope?: string,
  ) {
    return this.propertiesService.findAll(dto, countryScope);
  }

  @Post()
  @Roles('manager')
  async create(@Body() dto: CreatePropertyDto) {
    return this.propertiesService.create(dto);
  }
}

// ✗ Xấu: Logic nghiệp vụ trong controller
@Controller('properties')
export class PropertiesController {
  constructor(private prisma: PrismaService) {}

  @Get()
  async findAll(@Query() dto: ListPropertiesDto) {
    // ❌ Truy vấn DB trong controller
    const properties = await this.prisma.property.findMany({
      where: { isActive: true, country: dto.country },
    });
    if (!properties) throw new NotFoundException();
    return properties;
  }
}

Mẫu Service

typescript
// ✓ Tốt: Đơn trách nhiệm, gọi ủy quyền
@Injectable()
export class PropertiesService {
  constructor(private prisma: PrismaService) {}

  async findAll(dto: ListPropertiesDto, countryScope?: string) {
    const where = this.buildWhereClause(dto, countryScope);

    const [data, total] = await Promise.all([
      this.prisma.property.findMany({
        where,
        skip: (dto.page - 1) * dto.limit,
        take: dto.limit,
      }),
      this.prisma.property.count({ where }),
    ]);

    return {
      data,
      pagination: {
        page: dto.page,
        limit: dto.limit,
        total,
        totalPages: Math.ceil(total / dto.limit),
      },
    };
  }

  private buildWhereClause(dto: ListPropertiesDto, countryScope?: string) {
    const where: Prisma.PropertyWhereInput = { isActive: true };
    if (countryScope) where.country = countryScope;
    if (dto.status) where.isActive = dto.status === 'active';
    if (dto.search) where.name = { contains: dto.search, mode: 'insensitive' };
    return where;
  }
}

2. Mẫu Bảo Mật

Xác Thực: Luôn Yêu Cầu JWT

typescript
// ✓ Tốt: JwtAuthGuard áp dụng toàn cục, @Public() cho ngoại lệ
@Controller('auth')
export class AuthController {
  @Public()
  @Post('login')
  login(@Body() dto: LoginDto) {
    // Endpoint công khai, không cần JWT
  }

  @Post('logout')
  logout(@Req() req: Request) {
    // JwtAuthGuard thực thi mặc định
  }
}

// ✗ Xấu: Thiếu guard để endpoint không được bảo vệ
@Controller('sensitive')
export class SensitiveController {
  @Get('data')
  getData() {
    // ❌ Không có guard = cho phép truy cập không xác thực
  }
}

Kiểm Soát Truy Cập: Dùng Helper & Decorator

typescript
// ✓ Tốt: Kiểm tra truy cập tập trung qua helper assertPropertyAccess
async deleteProperty(
  @Param('propertyId') propertyId: string,
  @CountryScope() countryScope?: string,
) {
  await assertPropertyAccess(this.prisma, propertyId, countryScope);
  return this.prisma.property.update({
    where: { id: propertyId },
    data: { isActive: false },
  });
}

// ✗ Xấu: Kiểm tra thủ công rải rác khắp endpoint
async deleteProperty(@Param('propertyId') propertyId: string, @Req() req: Request) {
  const user = req.user as any;
  const property = await this.prisma.property.findUnique({ where: { id: propertyId } });
  if (user.country && property.country !== user.country) {
    throw new ForbiddenException(); // ❌ Lặp lại ở 10 endpoint khác
  }
}

Truy Cập Theo Vai Trò

typescript
// ✓ Tốt: Decorator @RequirePermission
@Post('settings')
@RequirePermission(ModuleKey.SETTINGS, Permission.EDIT)
async updateSettings(@Body() dto: UpdateSettingsDto) {
  return this.settingsService.update(dto);
}

// ✗ Xấu: Kiểm tra vai trò thủ công trong body handler
@Post('settings')
async updateSettings(@Body() dto: UpdateSettingsDto, @Req() req: Request) {
  if ((req.user as any).role !== 'manager') {
    throw new ForbiddenException(); // ❌ Dễ quên, khó audit
  }
}

Validation Input: Whitelist Trường Sắp Xếp

typescript
// ✓ Tốt: Whitelist rõ ràng ngăn injection
const ALLOWED_SORT_FIELDS = ['name', 'country', 'createdAt'];

function validateSortBy(sortBy?: string): string {
  if (!sortBy || !ALLOWED_SORT_FIELDS.includes(sortBy)) return 'name';
  return sortBy;
}

const sortBy = validateSortBy(dto.sortBy);
const properties = await this.prisma.property.findMany({
  orderBy: { [sortBy]: 'asc' },
});

// ✗ Xấu: Dùng trực tiếp input user
const properties = await this.prisma.property.findMany({
  orderBy: { [dto.sortBy]: 'asc' }, // ❌ Input chưa validation
});

Giới Hạn Tốc Độ

typescript
// ✓ Tốt: Throttle rõ ràng cho endpoint nhạy cảm
@Post('login')
@Throttle({ default: { limit: 5, ttl: 60000 } })
async login(@Body() dto: LoginDto) { }

@Post('refresh')
@Throttle({ default: { limit: 10, ttl: 60000 } })
async refresh() { }

3. Xử Lý Lỗi

Mẫu Exception Tiêu Chuẩn

typescript
// ✓ Tốt: Dùng exception tích hợp NestJS
async findProperty(id: string) {
  const property = await this.prisma.property.findUnique({ where: { id } });
  if (!property) throw new NotFoundException(`Property ${id} not found`);
  return property;
}

async deleteProperty(id: string, countryScope?: string) {
  const property = await this.findProperty(id); // Ném NotFoundException nếu thiếu
  if (countryScope && property.country !== countryScope) {
    throw new ForbiddenException('Truy cập bị từ chối: property không thuộc phạm vi quốc gia');
  }
  return this.prisma.property.update({ where: { id }, data: { isActive: false } });
}

// ✗ Xấu: Response lỗi thủ công, format không nhất quán
async findProperty(id: string) {
  const property = await this.prisma.property.findUnique({ where: { id } });
  if (!property) return { error: 'Not found', statusCode: 404 }; // ❌ Không phải exception
}

Mẫu Lỗi Validation

typescript
// ✓ Tốt: DTO + class-validator → ValidationPipe trả về 400
export class CreatePropertyDto {
  @IsString()
  @MinLength(3)
  name: string;

  @IsIn(['VN', 'ID', 'MY'])
  country: string;
}

// Response từ GlobalExceptionFilter:
// {
//   "error": "Validation failed",
//   "code": "VALIDATION_ERROR",
//   "details": [{ "field": "name", "message": "name must be at least 3 characters" }]
// }

// ✗ Xấu: Validation thủ công trong service
async create(dto: any) {
  if (!dto.name || dto.name.length < 3) {
    return { error: 'Name required' }; // ❌ Không ném, format không nhất quán
  }
}

4. Mẫu Database & Prisma

Phân Trang Hiệu Quả

typescript
// ✓ Tốt: Truy vấn count + data song song
async findAll(skip: number, take: number) {
  const [data, total] = await Promise.all([
    this.prisma.property.findMany({ skip, take }),
    this.prisma.property.count(),
  ]);
  return { data, pagination: { total, totalPages: Math.ceil(total / take) } };
}

// ✗ Xấu: Load tất cả rồi cắt
async findAll(skip: number, take: number) {
  const all = await this.prisma.property.findMany(); // ❌ Load tất cả hàng
  return all.slice(skip, skip + take);
}

Mẫu Transaction

typescript
// ✓ Tốt: $transaction cho thao tác đa bước nguyên tử
async importProperties(properties: ImportDto[]) {
  return this.prisma.$transaction(async (tx) => {
    const results = [];
    for (const prop of properties) {
      const property = await tx.property.create({ data: { name: prop.name, country: prop.country } });
      const roomTypes = await tx.roomType.createMany({
        data: prop.rooms.map(r => ({ ...r, propertyId: property.id })),
      });
      results.push({ property, roomTypes });
    }
    return results;
  });
}

// ✗ Xấu: Thao tác tuần tự không có transaction
async importProperties(properties: ImportDto[]) {
  // ❌ Nếu tạo roomType thất bại, property vẫn được tạo (trạng thái thiếu)
  for (const prop of properties) {
    const property = await this.prisma.property.create({ data: prop });
    await this.prisma.roomType.createMany({ data: prop.rooms.map(r => ({ ...r, propertyId: property.id })) });
  }
}

Mẫu Upsert

typescript
// ✓ Tốt: Upsert idempotent cho loại trùng booking OTA
async upsertBooking(booking: BookingData) {
  return this.prisma.booking.upsert({
    where: { ota_booking_id: `${booking.otaType}#${booking.otaBookingId}` },
    create: booking,
    update: { status: booking.status, updatedAt: new Date() },
  });
}

// ✗ Xấu: Find + create riêng biệt với race condition
async upsertBooking(booking: BookingData) {
  const existing = await this.prisma.booking.findUnique({ where: { otaBookingId: booking.otaBookingId } });
  if (existing) {
    return this.prisma.booking.update({ where: { id: existing.id }, data: booking });
  }
  return this.prisma.booking.create({ data: booking }); // ❌ Race condition
}

Select Cụ Thể (Tránh N+1)

typescript
// ✓ Tốt: Chỉ select trường cần, count trong một truy vấn
const properties = await this.prisma.property.findMany({
  select: {
    id: true,
    name: true,
    country: true,
    _count: { select: { roomTypes: true, bookings: true } },
  },
});

// ✗ Xấu: Load toàn bộ entity + N truy vấn thêm
const properties = await this.prisma.property.findMany();
for (const prop of properties) {
  const roomCount = await this.prisma.roomType.count({ where: { propertyId: prop.id } }); // N truy vấn
}

5. Tiêu Chuẩn Testing

Framework: Jest + @nestjs/testing. Theo mẫu Arrange / Act / Assert.

typescript
// ✓ Tốt: Cấu trúc rõ ràng, assertion cụ thể
it('should create a property with valid DTO', async () => {
  // Arrange
  const dto: CreatePropertyDto = { name: 'Hotel', country: 'VN', timezone: 'Asia/Ho_Chi_Minh', currency: 'VND' };
  jest.spyOn(prisma.property, 'create').mockResolvedValue({ id: 'uuid', ...dto });

  // Act
  const result = await service.create(dto);

  // Assert
  expect(result.id).toBeDefined();
  expect(prisma.property.create).toHaveBeenCalledWith({ data: dto });
});

// ✗ Xấu: Test mơ hồ, không xác minh hành vi
it('works', async () => {
  const result = await service.create({ name: 'Test' });
  expect(result).toBeDefined();
});

Mục Tiêu Coverage

Loại CodeMục Tiêu
Service80%
Controller60%
Helper90%
Guard80%
Pipe/Filter70%
bash
pnpm test:cov

PTX Channel Manager — Tài Liệu Nội Bộ