Code Standards — Backend (NestJS)
| Project | PTX Channel Manager (ptx-cm) |
| Last Updated | 2026-02-20 |
| See also | code-standards.md (principles & naming) |
1. NestJS Module Structure
Standard Module Layout
src/modules/[module-name]/
├── [module-name].controller.ts # HTTP handlers, decorators
├── [module-name].service.ts # Business logic, DB queries
├── [module-name].module.ts # Module configuration
├── [module-name].service.spec.ts # Unit tests
├── dto/
│ ├── create-[entity].dto.ts # Request DTOs
│ ├── list-[entity].dto.ts # Query parameter DTOs
│ └── update-[entity].dto.ts # Partial update DTOs
└── entities/ (optional)
└── [entity].entity.ts # Response types if complexDTO Validation Pattern
typescript
// ✓ Good: Use class-validator decorators
import { IsEmail, IsString, MinLength } from 'class-validator';
export class CreateUserDto {
@IsEmail()
email: string;
@IsString()
@MinLength(8)
password: string;
@IsString()
name: string;
}
// ✗ Bad: No validation, manual checks in handler
export class CreateUserDto {
email: string;
password: string;
name: string;
}Controller Pattern
typescript
// ✓ Good: Minimal logic, delegate to 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);
}
}
// ✗ Bad: Business logic in controller
@Controller('properties')
export class PropertiesController {
constructor(private prisma: PrismaService) {}
@Get()
async findAll(@Query() dto: ListPropertiesDto) {
// ❌ DB query in controller
const properties = await this.prisma.property.findMany({
where: { isActive: true, country: dto.country },
});
if (!properties) throw new NotFoundException();
return properties;
}
}Service Pattern
typescript
// ✓ Good: Single responsibility, delegated calls
@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. Security Patterns
Authentication: Always Require JWT
typescript
// ✓ Good: JwtAuthGuard applied globally, @Public() for exceptions
@Controller('auth')
export class AuthController {
@Public()
@Post('login')
login(@Body() dto: LoginDto) {
// Public endpoint, no JWT required
}
@Post('logout')
logout(@Req() req: Request) {
// JwtAuthGuard enforced by default
}
}
// ✗ Bad: Missing guard leaves endpoint unprotected
@Controller('sensitive')
export class SensitiveController {
@Get('data')
getData() {
// ❌ No guard = unauthenticated access allowed
}
}Access Control: Use Helpers & Decorators
typescript
// ✓ Good: Centralized access check via assertPropertyAccess helper
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 },
});
}
// ✗ Bad: Manual checks scattered across endpoints
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(); // ❌ Duplicated in 10 other endpoints
}
}Role-Based Access
typescript
// ✓ Good: @RequirePermission decorator
@Post('settings')
@RequirePermission(ModuleKey.SETTINGS, Permission.EDIT)
async updateSettings(@Body() dto: UpdateSettingsDto) {
return this.settingsService.update(dto);
}
// ✗ Bad: Manual role checks in handler body
@Post('settings')
async updateSettings(@Body() dto: UpdateSettingsDto, @Req() req: Request) {
if ((req.user as any).role !== 'manager') {
throw new ForbiddenException(); // ❌ Easy to forget, hard to audit
}
}Input Validation: Whitelist Sort Fields
typescript
// ✓ Good: Explicit whitelist prevents 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' },
});
// ✗ Bad: Direct use of user input
const properties = await this.prisma.property.findMany({
orderBy: { [dto.sortBy]: 'asc' }, // ❌ Unvalidated input
});Rate Limiting
typescript
// ✓ Good: Explicit throttle per sensitive endpoint
@Post('login')
@Throttle({ default: { limit: 5, ttl: 60000 } })
async login(@Body() dto: LoginDto) { }
@Post('refresh')
@Throttle({ default: { limit: 10, ttl: 60000 } })
async refresh() { }3. Error Handling
Standard Exception Pattern
typescript
// ✓ Good: Use NestJS built-in exceptions
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); // Throws NotFoundException if missing
if (countryScope && property.country !== countryScope) {
throw new ForbiddenException('Access denied: property not in your country scope');
}
return this.prisma.property.update({ where: { id }, data: { isActive: false } });
}
// ✗ Bad: Manual error responses, inconsistent format
async findProperty(id: string) {
const property = await this.prisma.property.findUnique({ where: { id } });
if (!property) return { error: 'Not found', statusCode: 404 }; // ❌ Not an exception
}Validation Error Pattern
typescript
// ✓ Good: DTOs + class-validator → ValidationPipe returns 400
export class CreatePropertyDto {
@IsString()
@MinLength(3)
name: string;
@IsIn(['VN', 'ID', 'MY'])
country: string;
}
// Response from GlobalExceptionFilter:
// {
// "error": "Validation failed",
// "code": "VALIDATION_ERROR",
// "details": [{ "field": "name", "message": "name must be at least 3 characters" }]
// }
// ✗ Bad: Manual validation in service
async create(dto: any) {
if (!dto.name || dto.name.length < 3) {
return { error: 'Name required' }; // ❌ Not thrown, inconsistent format
}
}4. Database & Prisma Patterns
Efficient Pagination
typescript
// ✓ Good: Parallel count + data query
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) } };
}
// ✗ Bad: Load all then slice
async findAll(skip: number, take: number) {
const all = await this.prisma.property.findMany(); // ❌ Loads all rows
return all.slice(skip, skip + take);
}Transaction Pattern
typescript
// ✓ Good: $transaction for atomic multi-step operations
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;
});
}
// ✗ Bad: Sequential operations without transaction
async importProperties(properties: ImportDto[]) {
// ❌ If roomType creation fails, property still created (partial state)
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 })) });
}
}Upsert Pattern
typescript
// ✓ Good: Idempotent upsert for OTA booking dedup
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() },
});
}
// ✗ Bad: Separate find + create with 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
}Specific Selects (Avoid N+1)
typescript
// ✓ Good: Select only needed fields, count in single query
const properties = await this.prisma.property.findMany({
select: {
id: true,
name: true,
country: true,
_count: { select: { roomTypes: true, bookings: true } },
},
});
// ✗ Bad: Load full entities + N additional queries
const properties = await this.prisma.property.findMany();
for (const prop of properties) {
const roomCount = await this.prisma.roomType.count({ where: { propertyId: prop.id } }); // N queries
}5. Testing Standards
Framework: Jest + @nestjs/testing. Follow Arrange / Act / Assert pattern.
typescript
// ✓ Good: Clear structure, specific assertions
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 });
});
// ✗ Bad: Vague test, no behavioral verification
it('works', async () => {
const result = await service.create({ name: 'Test' });
expect(result).toBeDefined();
});Coverage Targets
| Code Type | Target |
|---|---|
| Services | 80% |
| Controllers | 60% |
| Helpers | 90% |
| Guards | 80% |
| Pipes/Filters | 70% |
bash
pnpm test:cov