6.0 KiB
6.0 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)
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.
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 |
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
import { Entity, PrimaryGeneratedColumn, Column, Index } from 'typeorm';
import { Exclude, Expose } from 'class-transformer';
@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
@Column()
contractCode: string;
@Column()
contractName: string;
}
DTO Pattern (Accept UUID, Resolve to INT)
// dto/create-contract.dto.ts
import { IsUUID, IsNotEmpty } from 'class-validator';
export class CreateContractDto {
@IsNotEmpty()
@IsUUID('4')
projectUuid: string; // Accept UUID from client
@IsNotEmpty()
contractCode: string;
@IsNotEmpty()
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;
}
Service/Controller Pattern
@Controller('contracts')
export class ContractsController {
constructor(
private contractsService: ContractsService,
private uuidResolver: UuidResolver, // Helper to convert UUID → INT
) {}
@Post()
async create(@Body() dto: CreateContractDto) {
// Resolve UUID to INT PK for database operations
const projectId = await this.uuidResolver.resolveProject(dto.projectUuid);
// Create with INT FK
const contract = await this.contractsService.create({
...dto,
projectId, // INT for database
});
// Response automatically transforms via @Expose
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);
}
}
UUID Resolver Helper
@Injectable()
export class UuidResolver {
constructor(
@InjectRepository(Project)
private projectRepo: Repository<Project>,
@InjectRepository(Contract)
private contractRepo: Repository<Contract>,
) {}
async resolveProject(uuid: string): Promise<number> {
const project = await this.projectRepo.findOne({
where: { uuid },
select: ['id'], // Only fetch INT PK
});
if (!project) throw new NotFoundException('Project not found');
return project.id;
}
async resolveContract(uuid: string): Promise<number> {
const contract = await this.contractRepo.findOne({
where: { uuid },
select: ['id'],
});
if (!contract) throw new NotFoundException('Contract not found');
return contract.id;
}
}
TransformInterceptor (Required)
// Must be configured globally to handle @Exclude/@Expose
@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
);
}
}
// app.module.ts
@Module({
providers: [
{
provide: APP_INTERCEPTOR,
useClass: TransformInterceptor,
},
],
})
export class AppModule {}
Critical: NEVER ParseInt on UUID
// ❌ WRONG - parseInt on UUID gives garbage value
const id = parseInt(projectUuid); // "0195a1b2-..." → 195 (wrong!)
// ❌ WRONG - Number() on UUID
const id = Number(projectUuid); // NaN
// ❌ WRONG - Unary plus on UUID
const id = +projectUuid; // NaN
// ✅ CORRECT - Resolve via database lookup
const projectId = await uuidResolver.resolveProject(projectUuid);
// ✅ CORRECT - Use TypeORM find with UUID column
const project = await projectRepo.findOne({ where: { uuid: projectUuid } });
const id = project.id; // Get INT PK from entity
Query with UUID (No Resolution Needed)
// Direct UUID lookup in TypeORM
const project = await this.projectRepo.findOne({
where: { uuid: projectUuid }, // Query by UUID column
});
// 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.