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:
- 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.
- 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 inPrefixes.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,iremoved to eliminate visual ambiguity).32^15 ≈ 3.8 × 10^22values 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.kt—generate(): UUIDHandleGenerator.kt—generate(prefix: String): StringPrefixes.kt— authoritative registry of all 3-character prefix constants
See SPEC-028 for the full convention, prefix registry, and usage patterns.
Alternatives considered
| Option | Pros | Cons |
|---|---|---|
| UUIDv7 + NanoId handle (chosen) | B-tree friendly inserts; human-readable external IDs; domain prefix aids debugging | Two IDs per external entity; slight generation overhead |
| UUIDv4 for all | Simple, widely understood | Random distribution causes page splits at scale; raw UUIDs are ugly in APIs and logs |
| Auto-increment integer | Smallest storage; fastest B-tree appends | Sequential IDs are enumerable by callers; not portable across shards or environments |
| ULID | Time-ordered, URL-safe, single ID | Not a UUID — incompatible with PostgreSQL UUID type without casting; less ecosystem support than UUIDv7 |
| Expose UUID directly in API | No extra ID to manage | UUIDs are opaque and long; no domain prefix; leaks internal structure format |
| Single ID for all (no handle) | Simpler model | UUID 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_4k7vx9tz2nqr8w3immediately 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.ktbefore 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