690427:0812 Update Infras #01
CI / CD Pipeline / build (push) Successful in 5m51s
CI / CD Pipeline / deploy (push) Successful in 2m9s

This commit is contained in:
2026-04-27 08:12:28 +07:00
parent 9384581aee
commit a57fef4d44
68 changed files with 9750 additions and 468 deletions
@@ -5,20 +5,22 @@ impactDescription: Use INT PK internally + UUID for public API per project ADR-0
tags: database, uuid, identifier, adr-019, api-design, typeorm
---
## Hybrid Identifier Strategy (ADR-019)
## Hybrid Identifier Strategy (ADR-019) — March 2026 Pattern
**This project follows ADR-019: INT Primary Key (internal) + UUIDv7 (public API)**
Unlike standard practices that use UUID as the primary key, this project uses a **hybrid approach** optimized for MariaDB performance and API consistency.
> **Updated pattern (March 2026):** Entities extend `UuidBaseEntity`. The `publicId` column is exposed **directly** in API responses — ห้ามใช้ `@Expose({ name: 'id' })` เพื่อ rename.
### The Strategy
| Layer | Field | Type | Usage |
|-------|-------|------|-------|
| **Database PK** | `id` | `INT AUTO_INCREMENT` | Internal foreign keys only |
| **Public API** | `uuid` | `MariaDB UUID` (native) | External references, URLs |
| **DTO Input** | `xxxUuid` | `string` | Accept UUID in create/update |
| **DTO Output** | `id` | `string` | API returns UUID as `id` via `@Expose` |
| Layer | Field | Type | Usage |
| --------------- | ---------- | ----------------------------------- | ------------------------------------------------- |
| **Database PK** | `id` | `INT AUTO_INCREMENT` | Internal foreign keys only (marked `@Exclude()`) |
| **Public API** | `publicId` | `MariaDB UUID` (native, BINARY(16)) | External references, URLs — exposed as-is |
| **DTO Input** | `xxxUuid` | `string` (UUIDv7) | Accept UUID in create/update DTOs |
| **DTO Output** | `publicId` | `string` (UUIDv7) | API returns `publicId` field directly (no rename) |
### Why Hybrid IDs?
@@ -27,31 +29,51 @@ Unlike standard practices that use UUID as the primary key, this project uses a
- **Compatibility**: UUID works well with distributed systems and external integrations
- **MariaDB Native**: Uses MariaDB's native UUID type (stored as BINARY(16), auto-converts to string)
### Entity Definition
### Entity Definition (Current Pattern)
```typescript
import { Entity, PrimaryGeneratedColumn, Column, Index } from 'typeorm';
import { Exclude, Expose } from 'class-transformer';
import { Entity, Column } from 'typeorm';
import { UuidBaseEntity } from '@/common/entities/uuid-base.entity';
@Entity('contracts')
export class Contract {
@PrimaryGeneratedColumn()
@Exclude() // Never expose in API response
id: number; // Internal INT PK - used for FK relationships
@Column({ type: 'uuid', unique: true })
@Expose({ name: 'id' }) // Exposed as 'id' in API
uuid: string; // Public UUIDv7 - what API consumers see
export class Contract extends UuidBaseEntity {
// publicId (string UUIDv7) + id (INT, @Exclude) สืบทอดจาก UuidBaseEntity
// API response → { publicId: "019505a1-7c3e-7000-8000-abc123...", contractCode: ..., ... }
@Column()
contractCode: string;
@Column()
contractName: string;
@Column({ name: 'project_id' })
projectId: number; // INT FK — internal, not exposed if marked @Exclude in UuidBaseEntity
}
```
### DTO Pattern (Accept UUID, Resolve to INT)
**`UuidBaseEntity` (shared base):**
```typescript
import { PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
import { Exclude } from 'class-transformer';
export abstract class UuidBaseEntity {
@PrimaryGeneratedColumn()
@Exclude() // ❗ CRITICAL: INT id must never leak to API
id: number;
@Column({ type: 'uuid', unique: true, generated: 'uuid' })
publicId: string; // UUIDv7, exposed as-is
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
```
### DTO Pattern (Accept UUID, Resolve to INT Internally)
```typescript
// dto/create-contract.dto.ts
@@ -59,8 +81,8 @@ import { IsUUID, IsNotEmpty } from 'class-validator';
export class CreateContractDto {
@IsNotEmpty()
@IsUUID('4')
projectUuid: string; // Accept UUID from client
@IsUUID('7') // UUIDv7 (MariaDB native)
projectUuid: string; // Accept UUID from client
@IsNotEmpty()
contractCode: string;
@@ -69,48 +91,38 @@ export class CreateContractDto {
contractName: string;
}
// dto/contract-response.dto.ts
import { Exclude, Expose } from 'class-transformer';
export class ContractResponseDto {
@Expose({ name: 'id' })
uuid: string; // Returned as 'id' field in JSON
contractCode: string;
contractName: string;
}
// ❌ NO Response DTO with @Expose rename needed.
// Entity class_transformer via TransformInterceptor will serialize publicId directly.
```
### Service/Controller Pattern
```typescript
@Controller('contracts')
@UseGuards(JwtAuthGuard, CaslAbilityGuard)
export class ContractsController {
constructor(
private contractsService: ContractsService,
private uuidResolver: UuidResolver, // Helper to convert UUID → INT
private uuidResolver: UuidResolver
) {}
@Post()
async create(@Body() dto: CreateContractDto) {
// Resolve UUID to INT PK for database operations
// Resolve UUID INT PK for FK relationship
const projectId = await this.uuidResolver.resolveProject(dto.projectUuid);
// Create with INT FK
const contract = await this.contractsService.create({
...dto,
projectId, // INT for database
projectId,
});
// Response automatically transforms via @Expose
// Response: TransformInterceptor + @Exclude on id → publicId exposed directly
return contract;
}
@Get(':id')
async findOne(@Param('id') uuid: string) {
// Controller receives UUID string
// Service handles UUID → INT resolution internally
return this.contractsService.findByUuid(uuid);
@Get(':publicId')
async findOne(@Param('publicId', ParseUuidPipe) publicId: string) {
return this.contractsService.findOneByPublicId(publicId);
}
}
```
@@ -124,21 +136,21 @@ export class UuidResolver {
@InjectRepository(Project)
private projectRepo: Repository<Project>,
@InjectRepository(Contract)
private contractRepo: Repository<Contract>,
private contractRepo: Repository<Contract>
) {}
async resolveProject(uuid: string): Promise<number> {
async resolveProject(publicId: string): Promise<number> {
const project = await this.projectRepo.findOne({
where: { uuid },
select: ['id'], // Only fetch INT PK
where: { publicId },
select: ['id'], // Only INT PK for FK
});
if (!project) throw new NotFoundException('Project not found');
return project.id;
}
async resolveContract(uuid: string): Promise<number> {
async resolveContract(publicId: string): Promise<number> {
const contract = await this.contractRepo.findOne({
where: { uuid },
where: { publicId },
select: ['id'],
});
if (!contract) throw new NotFoundException('Contract not found');
@@ -147,20 +159,20 @@ export class UuidResolver {
}
```
### TransformInterceptor (Required)
### TransformInterceptor (Required — register ONCE)
```typescript
// Must be configured globally to handle @Exclude/@Expose
// Register via APP_INTERCEPTOR in CommonModule — ห้ามซ้ำใน main.ts
@Injectable()
export class TransformInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle().pipe(
map((data) => instanceToPlain(data)), // Applies class-transformer decorators
map((data) => instanceToPlain(data)) // Applies @Exclude / @Expose
);
}
}
// app.module.ts
// common.module.ts
@Module({
providers: [
{
@@ -169,40 +181,42 @@ export class TransformInterceptor implements NestInterceptor {
},
],
})
export class AppModule {}
export class CommonModule {}
```
> **Warning:** ห้ามเรียก `app.useGlobalInterceptors(new TransformInterceptor())` ใน `main.ts` ซ้ำ — จะทำให้ response double-wrap `{ data: { data: ... } }`.
### Critical: NEVER ParseInt on UUID
```typescript
// ❌ WRONG - parseInt on UUID gives garbage value
const id = parseInt(projectUuid); // "0195a1b2-..." → 195 (wrong!)
const id = parseInt(projectPublicId); // "0195a1b2-..." → 195 (wrong!)
// ❌ WRONG - Number() on UUID
const id = Number(projectUuid); // NaN
const id = Number(projectPublicId); // NaN
// ❌ WRONG - Unary plus on UUID
const id = +projectUuid; // NaN
const id = +projectPublicId; // NaN
// ✅ CORRECT - Resolve via database lookup
const projectId = await uuidResolver.resolveProject(projectUuid);
const projectId = await uuidResolver.resolveProject(projectPublicId);
// ✅ CORRECT - Use TypeORM find with UUID column
const project = await projectRepo.findOne({ where: { uuid: projectUuid } });
const id = project.id; // Get INT PK from entity
// ✅ CORRECT - Use TypeORM find with publicId column
const project = await projectRepo.findOne({ where: { publicId: projectPublicId } });
const id = project.id; // Get INT PK from entity
```
### Query with UUID (No Resolution Needed)
### Query with publicId (No Resolution Needed)
```typescript
// Direct UUID lookup in TypeORM
const project = await this.projectRepo.findOne({
where: { uuid: projectUuid }, // Query by UUID column
where: { publicId: projectPublicId },
});
// Relations use INT FK internally
const contracts = await this.contractRepo.find({
where: { projectId: project.id }, // INT for FK query
where: { projectId: project.id }, // INT for FK query
});
```