Files
lcbp3/.agents/skills/nestjs-best-practices/rules/db-hybrid-identifier.md
T
admin a57fef4d44
CI / CD Pipeline / build (push) Successful in 5m51s
CI / CD Pipeline / deploy (push) Successful in 2m9s
690427:0812 Update Infras #01
2026-04-27 08:12:28 +07:00

7.3 KiB

title, impact, impactDescription, tags
title impact impactDescription tags
Hybrid Identifier Strategy (ADR-019) CRITICAL Use INT PK internally + UUID for public API per project ADR-019 database, uuid, identifier, adr-019, api-design, typeorm

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 (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?

  • Performance: INT PK is faster for joins and indexing than UUID
  • Security: Internal IDs never exposed in API (enumerable IDs are a risk)
  • 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 (Current Pattern)

import { Entity, Column } from 'typeorm';
import { UuidBaseEntity } from '@/common/entities/uuid-base.entity';

@Entity('contracts')
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
}

UuidBaseEntity (shared base):

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)

// dto/create-contract.dto.ts
import { IsUUID, IsNotEmpty } from 'class-validator';

export class CreateContractDto {
  @IsNotEmpty()
  @IsUUID('7') // UUIDv7 (MariaDB native)
  projectUuid: string; // Accept UUID from client

  @IsNotEmpty()
  contractCode: string;

  @IsNotEmpty()
  contractName: string;
}

// ❌ NO Response DTO with @Expose rename needed.
// Entity class_transformer via TransformInterceptor will serialize publicId directly.

Service/Controller Pattern

@Controller('contracts')
@UseGuards(JwtAuthGuard, CaslAbilityGuard)
export class ContractsController {
  constructor(
    private contractsService: ContractsService,
    private uuidResolver: UuidResolver
  ) {}

  @Post()
  async create(@Body() dto: CreateContractDto) {
    // Resolve UUID → INT PK for FK relationship
    const projectId = await this.uuidResolver.resolveProject(dto.projectUuid);

    const contract = await this.contractsService.create({
      ...dto,
      projectId,
    });

    // Response: TransformInterceptor + @Exclude on id → publicId exposed directly
    return contract;
  }

  @Get(':publicId')
  async findOne(@Param('publicId', ParseUuidPipe) publicId: string) {
    return this.contractsService.findOneByPublicId(publicId);
  }
}

UUID Resolver Helper

@Injectable()
export class UuidResolver {
  constructor(
    @InjectRepository(Project)
    private projectRepo: Repository<Project>,
    @InjectRepository(Contract)
    private contractRepo: Repository<Contract>
  ) {}

  async resolveProject(publicId: string): Promise<number> {
    const project = await this.projectRepo.findOne({
      where: { publicId },
      select: ['id'], // Only INT PK for FK
    });
    if (!project) throw new NotFoundException('Project not found');
    return project.id;
  }

  async resolveContract(publicId: string): Promise<number> {
    const contract = await this.contractRepo.findOne({
      where: { publicId },
      select: ['id'],
    });
    if (!contract) throw new NotFoundException('Contract not found');
    return contract.id;
  }
}

TransformInterceptor (Required — register ONCE)

// 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 @Exclude / @Expose
    );
  }
}

// common.module.ts
@Module({
  providers: [
    {
      provide: APP_INTERCEPTOR,
      useClass: TransformInterceptor,
    },
  ],
})
export class CommonModule {}

Warning: ห้ามเรียก app.useGlobalInterceptors(new TransformInterceptor()) ใน main.ts ซ้ำ — จะทำให้ response double-wrap { data: { data: ... } }.

Critical: NEVER ParseInt on UUID

// ❌ WRONG - parseInt on UUID gives garbage value
const id = parseInt(projectPublicId); // "0195a1b2-..." → 195 (wrong!)

// ❌ WRONG - Number() on UUID
const id = Number(projectPublicId); // NaN

// ❌ WRONG - Unary plus on UUID
const id = +projectPublicId; // NaN

// ✅ CORRECT - Resolve via database lookup
const projectId = await uuidResolver.resolveProject(projectPublicId);

// ✅ 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 publicId (No Resolution Needed)

// Direct UUID lookup in TypeORM
const project = await this.projectRepo.findOne({
  where: { publicId: projectPublicId },
});

// Relations use INT FK internally
const contracts = await this.contractRepo.find({
  where: { projectId: project.id }, // INT for FK query
});

Reference

Warning

: Using parseInt(), Number(), or unary + on UUID values violates ADR-019 and will cause data corruption. Always resolve UUIDs via database lookup.