Tiêu Chuẩn Code — Backend (NestJS)
| Dự Án | PTX Channel Manager (ptx-cm) |
| Cập Nhật | 2026-02-20 |
| Xem thêm | code-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ạpMẫ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 Code | Mục Tiêu |
|---|---|
| Service | 80% |
| Controller | 60% |
| Helper | 90% |
| Guard | 80% |
| Pipe/Filter | 70% |
bash
pnpm test:cov