23 KiB
ADR-019: Hybrid Identifier Strategy (INT + UUIDv7)
Status: Accepted Date: 2026-03-12 Version: 1.8.1 Decision Makers: Development Team, Database Architect Related Documents:
- Data Dictionary
- Database Schema
- ADR-005: Technology Stack
- ADR-009: Database Migration Strategy
- ADR-016: Security & Authentication
Context and Problem Statement
ระบบ LCBP3-DMS ใช้ INT AUTO_INCREMENT เป็น Primary Key ทุกตาราง ซึ่งทำงานได้ดีสำหรับ Internal JOIN/FK แต่มีปัญหาด้านความปลอดภัยและ Scalability:
- ID Enumeration Attack: Sequential INT IDs ถูกเดาได้ง่าย (เช่น
/api/correspondences/1,/api/correspondences/2) ทำให้ผู้ไม่ประสงค์ดีสามารถ Enumerate ข้อมูลได้ - Information Leakage: INT IDs เปิดเผยจำนวนข้อมูลในระบบ (เช่น
user_id=5แปลว่ามีผู้ใช้ 5 คน) - Cross-System Integration: หากในอนาคตต้องการ Sync ข้อมูลข้ามระบบ INT ID จะชนกัน
- API Security: OWASP BOLA (Broken Object Level Authorization) แนะนำให้ใช้ Opaque Identifier แทน Sequential ID
ทั้งนี้ ระบบมีข้อจำกัดด้าน Hardware (QNAP NAS) ที่ต้องพิจารณาเรื่อง Performance
Decision Drivers
- Security: ป้องกัน ID Enumeration และลดความเสี่ยง OWASP BOLA
- Performance: INT PK ยังคงเป็น Primary Key เพื่อ JOIN/FK Performance บน InnoDB
- Backward Compatibility: ไม่ต้อง Migrate ข้อมูลหรือเปลี่ยน FK Relationships ที่มีอยู่
- Simplicity: เปลี่ยนเฉพาะ Public-Facing Tables (ไม่ใช่ทุกตาราง)
- Standards: UUIDv7 (RFC 9562) เป็น Time-ordered UUID ที่ B-tree friendly
Considered Options
Option 1: Replace INT with UUID as Primary Key
Pros:
- ✅ Opaque identifier ทุกที่
Cons:
- ❌ FK ทั้งหมดต้องเปลี่ยนเป็น BINARY(16) — Migration ซับซ้อนมาก
- ❌ JOIN Performance แย่ลง (16 bytes vs 4 bytes)
- ❌ InnoDB Clustered Index ไม่เรียงลำดับตาม INSERT Time (UUIDv4)
- ❌ ต้อง Rewrite Backend ทั้งหมด (Entity, DTO, Controller, Service)
- ❌ Breaking Change กับ Frontend ที่ใช้ INT ID อยู่
Option 2: UUID as String Column (CHAR(36))
Pros:
- ✅ Human-readable
Cons:
- ❌ ใช้พื้นที่ 36 bytes ต่อ row (vs 16 bytes สำหรับ BINARY)
- ❌ Index ใหญ่ ช้ากว่า BINARY(16) อย่างมีนัยสำคัญ
- ❌ Collation issues กับ case-sensitivity
Option 3: Hybrid INT + UUID (MariaDB Native) ⭐ (Selected)
Pros:
- ✅ INT PK ยังเป็น Internal ID → Performance ไม่เปลี่ยน
- ✅ UUID เป็น External ID → ปลอดภัย + Space-efficient (BINARY(16) ภายใน)
- ✅ ไม่ต้อง Migrate FK Relationships
- ✅ UUIDv7 Time-ordered → B-tree friendly, Index Performance ดี
- ✅ Backward Compatible — Frontend ค่อยๆ Migrate ได้
- ✅ ไม่กระทบ Migration Tables (Temporary)
Cons:
- ❌ ต้องเพิ่ม Column ใหม่ + UNIQUE INDEX ทุก Public-Facing Table
- ❌ Application Layer ต้อง Generate UUIDv7 ตอน INSERT
- ❌ API Layer ต้อง Resolve UUID → INT สำหรับ Internal Queries
Decision Outcome
Chosen Option: Option 3 — Hybrid INT + UUID (MariaDB Native Type)
Rationale: เป็นแนวทางที่ Balance ระหว่าง Security, Performance และ Migration Effort ดีที่สุด ไม่ต้อง Rewrite FK ทั้งหมด ไม่ต้อง Migrate ข้อมูล และเพิ่มความปลอดภัยของ API
Technical Specification
1. UUID Format
| Property | Value |
|---|---|
| Type | MariaDB Native UUID (available since 10.7) |
| Storage | BINARY(16) internally (automatic) |
| DB Default | UUID() — generates UUID v1 (time-based, fallback for seed data) |
| App Generation | NestJS @BeforeInsert() generates UUIDv7 (RFC 9562) for time-ordering |
| Display | Auto-converts to string format (8-4-4-4-12) — no conversion function needed |
| Index | UNIQUE INDEX on uuid column |
2. Column Specification
-- Column definition for all public-facing tables (MariaDB 10.7+)
uuid UUID NOT NULL DEFAULT UUID() COMMENT 'UUID Public Identifier (ADR-019)',
UNIQUE INDEX idx_{table}_uuid (uuid)
Note: MariaDB native
UUIDtype เก็บเป็นBINARY(16)ภายใน แต่แสดงผลเป็น String format อัตโนมัติ ไม่ต้องใช้BIN_TO_UUID()/UUID_TO_BIN()DB Default:
UUID()สร้าง UUID v1 (สำหรับ Seed Data และ Fallback)Application Override: NestJS Entity จะ Generate UUIDv7 เองก่อน INSERT เพื่อให้ได้ True UUIDv7 (Time-ordered, B-tree friendly)
3. Tables Requiring UUID Column
Tier 1 — Core Entity Tables (Own UUID Column)
| # | Table Name | Current PK | UUID Column | Notes |
|---|---|---|---|---|
| 1 | users |
user_id INT AI |
uuid UUID |
User profiles |
| 2 | organizations |
id INT AI |
uuid UUID |
Organization data |
| 3 | projects |
id INT AI |
uuid UUID |
Project data |
| 4 | contracts |
id INT AI |
uuid UUID |
Contract data |
| 5 | correspondences |
id INT AI |
uuid UUID |
Main document entity |
| 6 | correspondence_revisions |
id INT AI |
uuid UUID |
Document versions |
| 7 | circulations |
id INT AI |
uuid UUID |
Internal circulations |
| 8 | shop_drawings |
id INT AI |
uuid UUID |
Shop drawing master |
| 9 | shop_drawing_revisions |
id INT AI |
uuid UUID |
Shop drawing versions |
| 10 | contract_drawings |
id INT AI |
uuid UUID |
Contract drawing master |
| 11 | asbuilt_drawings |
id INT AI |
uuid UUID |
As-built drawing master |
| 12 | asbuilt_drawing_revisions |
id INT AI |
uuid UUID |
As-built drawing versions |
| 13 | attachments |
id INT AI |
uuid UUID |
File attachments |
| 14 | notifications |
id INT AI (partitioned) |
uuid UUID |
User notifications |
Tier 2 — Shared-PK Tables (Inherit UUID from Parent)
| # | Table Name | Shared PK Source | UUID Resolution |
|---|---|---|---|
| 1 | rfas |
correspondences.id |
Use correspondences.uuid |
| 2 | rfa_revisions |
correspondence_revisions.id |
Use correspondence_revisions.uuid |
| 3 | transmittals |
correspondences.id |
Use correspondences.uuid |
Already Using UUID — No Changes Needed
| Table Name | Current PK |
|---|---|
workflow_definitions |
CHAR(36) UUID |
workflow_instances |
CHAR(36) UUID |
workflow_histories |
CHAR(36) UUID |
Excluded Tables (Internal/Master/Junction)
ตารางต่อไปนี้ ไม่ต้อง เพิ่ม UUID Column เพราะเป็น Internal-use only:
- Master/Lookup:
organization_roles,disciplines,correspondence_types,correspondence_sub_types,correspondence_status,rfa_types,rfa_status_codes,rfa_approve_codes,circulation_status_codes,tags - RBAC:
roles,permissions,user_assignments - Junction/Mapping:
project_organizations,contract_organizations,correspondence_recipients,correspondence_tags,correspondence_references,contract_drawing_subcat_cat_maps,shop_drawing_revision_contract_refs,asbuilt_revision_shop_revisions_refs, all*_attachmentsjunction tables - Drawing Categories:
contract_drawing_volumes,contract_drawing_cats,contract_drawing_sub_cats,shop_drawing_main_categories,shop_drawing_sub_categories - Document Numbering:
document_number_formats,document_number_counters,document_number_audit,document_number_errors,document_number_reservations - System/Logs:
json_schemas,user_preferences,audit_logs,search_indices,backup_logs,refresh_tokens - Migration (Temporary):
migration_progress,migration_review_queue,migration_errors,migration_fallback_state,import_transactions,migration_daily_summary
TypeORM Entity Pattern
Base Entity with UUID
// src/common/entities/base-uuid.entity.ts
import { Column, BeforeInsert } from 'typeorm';
import { v7 as uuidv7 } from 'uuid';
/**
* Abstract base entity providing a UUID public identifier.
*
* Naming Convention:
* - TypeScript Property: `publicId` — semantic name indicating this is the public-facing identifier
* - Database Column: `uuid` — MariaDB native UUID type (stored as BINARY(16))
*
* This avoids confusion between the property name and the DB data type,
* while clearly indicating this is the ID exposed via API (not internal INT PK).
*/
export abstract class UuidBaseEntity {
@Column({
type: 'uuid',
name: 'uuid', // DB column name (MariaDB native UUID type)
unique: true,
nullable: false,
comment: 'UUID Public Identifier (ADR-019)',
})
publicId!: string; // TypeScript property name — semantic, avoids type confusion
@BeforeInsert()
generateUuid(): void {
if (!this.publicId) {
this.publicId = uuidv7();
}
}
}
Note: MariaDB native
UUIDtype ทำให้ TypeORM ไม่ต้องใช้ transformer อีกต่อไป — ค่าเข้า/ออกเป็น string format เสมอ
Entity Usage Example
// Example: correspondence.entity.ts
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
import { BaseUuidEntity } from '../../common/entities/base-uuid.entity';
@Entity('correspondences')
export class Correspondence extends BaseUuidEntity {
@PrimaryGeneratedColumn()
id!: number; // Internal INT PK — never exposed via API
// publicId (from BaseUuidEntity) is the UUID exposed via API
// DB Column: uuid (MariaDB native UUID type)
// ... existing columns
}
API Layer Changes
URL Pattern
// Before (INT — vulnerable to enumeration)
GET /api/correspondences/42
GET /api/users/5
// After (UUID — opaque identifier)
GET /api/correspondences/019505a1-7c3e-7000-8000-abc123def456
GET /api/users/019505a1-8b2f-7000-8000-abc123def456
Controller Pattern
@Get(':uuid')
async findOne(@Param('uuid', ParseUUIDPipe) uuid: string) {
return this.service.findByUuid(uuid);
}
Service Pattern
async findByUuid(publicId: string): Promise<CorrespondenceDto> {
const entity = await this.repository.findOne({
where: { publicId }, // Use publicId property (DB column is 'uuid')
});
if (!entity) throw new NotFoundException();
return this.toDto(entity);
}
DTO Pattern — Expose publicId Directly
export class CorrespondenceResponseDto {
// ✅ Expose publicId directly in API response
publicId!: string;
// ❌ Never expose internal INT id
// id: number; — REMOVED from response
// ... other fields
// For FK references, also use publicId
projectPublicId!: string;
}
Note: We use
publicIddirectly in API responses (not transformed toid) to maintain naming consistency between Backend Entity property and Frontend Type property. This prevents confusion when mapping data.
Migration SQL Script
-- =====================================================
-- ADR-019: Add UUIDv7 columns to public-facing tables
-- Strategy: Non-destructive — ADD COLUMN only
-- Rollback: DROP COLUMN uuid
-- =====================================================
-- Tier 1: Core Entity Tables
ALTER TABLE users
ADD COLUMN uuid UUID NOT NULL DEFAULT UUID()
COMMENT 'UUID Public Identifier (ADR-019)',
ADD UNIQUE INDEX idx_users_uuid (uuid);
ALTER TABLE organizations
ADD COLUMN uuid UUID NOT NULL DEFAULT UUID()
COMMENT 'UUID Public Identifier (ADR-019)',
ADD UNIQUE INDEX idx_organizations_uuid (uuid);
ALTER TABLE projects
ADD COLUMN uuid UUID NOT NULL DEFAULT UUID()
COMMENT 'UUID Public Identifier (ADR-019)',
ADD UNIQUE INDEX idx_projects_uuid (uuid);
ALTER TABLE contracts
ADD COLUMN uuid UUID NOT NULL DEFAULT UUID()
COMMENT 'UUID Public Identifier (ADR-019)',
ADD UNIQUE INDEX idx_contracts_uuid (uuid);
ALTER TABLE correspondences
ADD COLUMN uuid UUID NOT NULL DEFAULT UUID()
COMMENT 'UUID Public Identifier (ADR-019)',
ADD UNIQUE INDEX idx_correspondences_uuid (uuid);
ALTER TABLE correspondence_revisions
ADD COLUMN uuid UUID NOT NULL DEFAULT UUID()
COMMENT 'UUID Public Identifier (ADR-019)',
ADD UNIQUE INDEX idx_correspondence_revisions_uuid (uuid);
ALTER TABLE circulations
ADD COLUMN uuid UUID NOT NULL DEFAULT UUID()
COMMENT 'UUID Public Identifier (ADR-019)',
ADD UNIQUE INDEX idx_circulations_uuid (uuid);
ALTER TABLE shop_drawings
ADD COLUMN uuid UUID NOT NULL DEFAULT UUID()
COMMENT 'UUID Public Identifier (ADR-019)',
ADD UNIQUE INDEX idx_shop_drawings_uuid (uuid);
ALTER TABLE shop_drawing_revisions
ADD COLUMN uuid UUID NOT NULL DEFAULT UUID()
COMMENT 'UUID Public Identifier (ADR-019)',
ADD UNIQUE INDEX idx_shop_drawing_revisions_uuid (uuid);
ALTER TABLE contract_drawings
ADD COLUMN uuid UUID NOT NULL DEFAULT UUID()
COMMENT 'UUID Public Identifier (ADR-019)',
ADD UNIQUE INDEX idx_contract_drawings_uuid (uuid);
ALTER TABLE asbuilt_drawings
ADD COLUMN uuid UUID NOT NULL DEFAULT UUID()
COMMENT 'UUID Public Identifier (ADR-019)',
ADD UNIQUE INDEX idx_asbuilt_drawings_uuid (uuid);
ALTER TABLE asbuilt_drawing_revisions
ADD COLUMN uuid UUID NOT NULL DEFAULT UUID()
COMMENT 'UUID Public Identifier (ADR-019)',
ADD UNIQUE INDEX idx_asbuilt_drawing_revisions_uuid (uuid);
ALTER TABLE attachments
ADD COLUMN uuid UUID NOT NULL DEFAULT UUID()
COMMENT 'UUID Public Identifier (ADR-019)',
ADD UNIQUE INDEX idx_attachments_uuid (uuid);
ALTER TABLE notifications
ADD COLUMN uuid UUID NOT NULL DEFAULT UUID()
COMMENT 'UUID Public Identifier (ADR-019)',
ADD INDEX idx_notifications_uuid (uuid);
-- Note: UNIQUE constraint on partitioned table requires uuid in partition key
-- Using regular INDEX instead
Rollback SQL
-- Rollback: Remove UUID columns (Non-destructive reverse)
ALTER TABLE users DROP INDEX idx_users_uuid, DROP COLUMN uuid;
ALTER TABLE organizations DROP INDEX idx_organizations_uuid, DROP COLUMN uuid;
ALTER TABLE projects DROP INDEX idx_projects_uuid, DROP COLUMN uuid;
ALTER TABLE contracts DROP INDEX idx_contracts_uuid, DROP COLUMN uuid;
ALTER TABLE correspondences DROP INDEX idx_correspondences_uuid, DROP COLUMN uuid;
ALTER TABLE correspondence_revisions DROP INDEX idx_correspondence_revisions_uuid, DROP COLUMN uuid;
ALTER TABLE circulations DROP INDEX idx_circulations_uuid, DROP COLUMN uuid;
ALTER TABLE shop_drawings DROP INDEX idx_shop_drawings_uuid, DROP COLUMN uuid;
ALTER TABLE shop_drawing_revisions DROP INDEX idx_shop_drawing_revisions_uuid, DROP COLUMN uuid;
ALTER TABLE contract_drawings DROP INDEX idx_contract_drawings_uuid, DROP COLUMN uuid;
ALTER TABLE asbuilt_drawings DROP INDEX idx_asbuilt_drawings_uuid, DROP COLUMN uuid;
ALTER TABLE asbuilt_drawing_revisions DROP INDEX idx_asbuilt_drawing_revisions_uuid, DROP COLUMN uuid;
ALTER TABLE attachments DROP INDEX idx_attachments_uuid, DROP COLUMN uuid;
ALTER TABLE notifications DROP INDEX idx_notifications_uuid, DROP COLUMN uuid;
Storage Impact Analysis
| Item | Size |
|---|---|
| UUID (BINARY(16) internal) per row | 16 bytes |
| UNIQUE INDEX per row | ~16 bytes (key) + ~6 bytes (pointer) ≈ 22 bytes |
| Total per row | ~38 bytes |
| Estimated rows (all 14 tables combined) | ~100,000 (Year 1) |
| Total additional storage | ~3.8 MB |
| Impact on QNAP NAS | Negligible |
Performance Considerations
UUIDv7 vs UUIDv4 for B-tree Index
| Property | UUIDv4 | UUIDv7 |
|---|---|---|
| Ordering | Random | Time-ordered |
| B-tree insert | Random page splits | Sequential append |
| Index fragmentation | High | Low |
| Cache efficiency | Poor | Good |
UUIDv7 ถูกเลือกเพราะ Time-ordering ทำให้ INSERT ไม่ทำให้เกิด Random Page Split บน InnoDB B-tree ซึ่งสำคัญมากสำหรับ QNAP NAS ที่มี I/O จำกัด
Query Pattern
-- Internal query (JOINs still use INT — no performance change)
SELECT c.*, cr.*
FROM correspondences c
JOIN correspondence_revisions cr ON c.id = cr.correspondence_id
WHERE c.id = 42;
-- API query (UUID lookup via UNIQUE INDEX — O(log n))
SELECT c.*, cr.*
FROM correspondences c
JOIN correspondence_revisions cr ON c.id = cr.correspondence_id
WHERE c.uuid = '019505a1-7c3e-7000-8000-abc123def456';
Security Benefits
| Threat | Before (INT) | After (Hybrid) |
|---|---|---|
| ID Enumeration | ❌ Vulnerable (/api/users/1,2,3...) |
✅ Opaque UUID |
| Record Count Leak | ❌ id=500 reveals ~500 records |
✅ UUID reveals nothing |
| BOLA Attack Surface | ❌ Predictable IDs | ✅ 2^122 possible values |
| Cross-System Collision | ❌ Possible | ✅ Globally unique |
Compatibility with Existing ADRs
| ADR | Impact | Notes |
|---|---|---|
| ADR-002 (Doc Numbering) | ✅ None | Document numbers (VARCHAR) are business identifiers, unaffected |
| ADR-005 (Tech Stack) | ✅ Compatible | uuid npm package + MariaDB native UUID type |
| ADR-006 (Redis Caching) | ⚠️ Minor | Cache keys should use UUID instead of INT for public-facing data |
| ADR-009 (DB Migration) | ✅ Compatible | ADD COLUMN is a safe, non-destructive migration |
| ADR-016 (Security) | ✅ Enhanced | Strengthens OWASP BOLA defense |
| ADR-017 (Ollama Migration) | ✅ None | Migration tables are temporary, excluded from UUID scope |
Transition Strategy
Phase 1: Database (Schema Change)
- เพิ่ม
uuid UUIDcolumn (MariaDB native type) กับ UNIQUE INDEX ใน 14 ตาราง - Existing rows ได้รับ UUID อัตโนมัติจาก DB DEFAULT
Phase 2: Backend (Dual-Mode)
- เพิ่ม
publicIdfield ใน TypeORM Entities (DB column ยังชื่อuuid) - สร้าง
BaseUuidEntityclass ด้วยpublicIdproperty - API รับได้ทั้ง INT และ UUID ผ่าน
FindByIdOrUuidpattern - API Response รวม UUID เป็น
idfield (via @Expose)
Phase 3: Frontend (Consistent publicId Usage)
- Frontend ใช้
publicIdเป็น Standard ทุก Type (ไม่ใช้uuidหรือidที่เป็น number) - URL parameters ใช้
publicId(UUID string) - ทุก Type Definition ใช้
publicId?: stringอย่างเดียว — ไม่มี fallback เป็นuuidหรือid
Example:
// ✅ Correct — Consistent publicId usage
type ProjectOption = {
publicId?: string;
projectName?: string;
projectCode?: string;
};
// ❌ Wrong — Multiple identifier fields cause confusion
type ProjectOption = {
publicId?: string;
uuid?: string; // Don't do this
id?: number; // Don't do this
projectName?: string;
};
Phase 4: Cleanup
- ลบ INT ID จาก API Response (DTO)
- ลบ INT-based route handlers
- Update API Documentation
Final Assessment
| Area | Status |
|---|---|
| Security | ✅ Eliminates ID enumeration |
| Performance | ✅ No impact on internal JOINs |
| Migration Risk | ✅ Low — ADD COLUMN only |
| Storage Impact | ✅ Negligible (~3.8 MB) |
| Backward Compatibility | ✅ Dual-mode transition |
| ADR Compliance | ✅ Compatible with all existing ADRs |
Naming Convention Summary
| Context | Backend (TypeORM) | Frontend (TypeScript) | API Response |
|---|---|---|---|
| Entity Property | publicId: string |
publicId?: string |
publicId: string |
| DB Column | uuid UUID |
— | — |
| Internal PK | id: number (excluded) |
— | — |
Rule: ใช้ publicId เป็น Identifier เดียวใน API — ไม่มีการ Transform เป็น id เพื่อป้องกัน confusion ระหว่าง Backend ↔ Frontend
สำหรับรายละเอียดการ Implement ดูที่ Implementation Plan ใน 05-07-hybrid-uuid-implementation-plan.md