Skip to main content

ADR-015: ID Generation Strategy — UUIDv7 Primary Keys and NanoId Handles

Context

Every persisted entity needs a stable identifier. Two distinct concerns must be addressed:

  1. Database primary keys — used for joins, foreign keys, and internal log correlation. The choice of key type has direct B-tree performance implications on INSERT-heavy tables.
  2. External identifiers — surfaced in API responses, stored in PSP metadata, and referenced across domain boundaries. These must be human-readable, URL-safe, and must not leak internal database structure.

Initially, the options were uuidv4 (fully random) or auto-increment integers for primary keys, and raw UUIDs or short random strings for external identifiers.

Decision

Use a two-ID model for all external entities: a UUIDv7 internal primary key and a NanoId-based handle for external exposure. Internal-only entities (audit tables, PSP clearing, settlement records) carry only a UUIDv7 primary key — no handle is generated.

Primary keys — UUIDv7

All tables use UUIDv7 (RFC 9562) as the primary key type.

UUIDv7 embeds a 48-bit millisecond timestamp in the most-significant bits. This means every new row sorts higher than all existing rows, so B-tree index pages are always appended to the right — eliminating the random page splits that make uuidv4 pathological on INSERT-heavy tables. This gives the index locality of an auto-increment integer with the global uniqueness and portability of a UUID.

Library: com.fasterxml.uuid:java-uuid-generator:5.2.0 (JUG) by FasterXML — the standard Java UUID library since 2012, actively maintained, pure JVM, thread-safe.

External handles — NanoId with domain prefix

Every entity that appears in a public API response, a cross-domain request, or PSP metadata carries a handle of the form {prefix}_{nanoId}:

  • Prefix: 3-character lowercase domain identifier (e.g. pmt, usr, sub) registered in Prefixes.kt. Makes the owning domain identifiable from any log line, PSP dashboard, or support ticket.
  • NanoId: 15 characters from the alphabet 123456789abcdefghjkmnpqrstuvwxyz (32 chars; 0, o, l, i removed to eliminate visual ambiguity). 32^15 ≈ 3.8 × 10^22 values per prefix — 50% collision probability after ~229 billion IDs per entity type.
  • Total length: 19 characters for all handles system-wide.

Library: com.aventrix.jnanoid:jnanoid:2.0.0 — cryptographically secure random source (SecureRandom), thread-safe, no external dependencies.

Generation location

Both IDs are generated in the service layer, before T1 opens. Never inside a repository, never inside an open transaction, never accepted from the caller.

Implementation

Utilities live in backend/shared/id/:

  • UuidGenerator.ktgenerate(): UUID
  • HandleGenerator.ktgenerate(prefix: String): String
  • Prefixes.kt — authoritative registry of all 3-character prefix constants

See SPEC-028 for the full convention, prefix registry, and usage patterns.

Alternatives considered

OptionProsCons
UUIDv7 + NanoId handle (chosen)B-tree friendly inserts; human-readable external IDs; domain prefix aids debuggingTwo IDs per external entity; slight generation overhead
UUIDv4 for allSimple, widely understoodRandom distribution causes page splits at scale; raw UUIDs are ugly in APIs and logs
Auto-increment integerSmallest storage; fastest B-tree appendsSequential IDs are enumerable by callers; not portable across shards or environments
ULIDTime-ordered, URL-safe, single IDNot a UUID — incompatible with PostgreSQL UUID type without casting; less ecosystem support than UUIDv7
Expose UUID directly in APINo extra ID to manageUUIDs are opaque and long; no domain prefix; leaks internal structure format
Single ID for all (no handle)Simpler modelUUID in PSP metadata has no domain context; raw UUID in support tickets is hard to trace

Consequences

What becomes easier

  • INSERT performance on all tables — no random page splits, predictable B-tree growth
  • Debugging — pmt_4k7vx9tz2nqr8w3 immediately tells you which domain, which entity, and is safe to paste anywhere
  • PSP integration — pmt_ prefix in Stripe metadata makes the owning domain identifiable from the PSP dashboard without a database lookup
  • Security — internal UUIDs are never exposed; handles are opaque and carry no structural information

What becomes harder

  • Every external entity carries two IDs — both must be generated, stored, and indexed
  • New entities require a prefix registration step in Prefixes.kt before any code is written
  • Teams unfamiliar with the two-ID model need onboarding

Constraints

  • The UUID primary key is never exposed in public API responses, client-facing error messages, or PSP metadata
  • A handle is immutable once assigned — never regenerated for an existing entity
  • All prefix constants must be registered in Prefixes.kt — no inline strings at call sites