-
Notifications
You must be signed in to change notification settings - Fork 0
ADR 0010 Prefixed ULID Identifiers
Tiana_ edited this page May 30, 2026
·
1 revision
Status: Accepted Date: 2026-05-30 Decider: Maintainer
Every resource (account, transaction, entry, payment, decision) needs a public identifier that appears in API responses, URLs, logs, support tickets, and cache keys. The choice affects database performance, debuggability, client safety, and how the product reads to an external developer.
Options considered:
-
Bare UUIDv4 - random 128-bit value, exposed as
550e8400-e29b-41d4-.... Simple, universal, but opaque (you cannot tell an account id from a transaction id), and random UUIDv4 fragments b-tree indexes and hurts range/cursor locality. - Database sequence / bigserial - compact and ordered, but leaks row counts, is guessable, and couples ids to one storage engine.
-
Prefixed ULID at the edge - store a time-sortable 128-bit value internally; serialize it to clients as a type-prefixed string (
acc_,tx_,ent_,pay_,dec_) over Crockford base32. The pattern popularized by Stripe.
Use a typed, prefixed, ULID-based identifier at the API boundary, with the raw 128-bit value stored internally.
-
Storage: the column stays a 16-byte value (
UUID/uuidin Postgres, or a native ULID stored as UUID). The prefix is not part of the primary key and is never stored. -
Serialization: a single edge converter renders the stored value as
<prefix>_<crockford-base32>on the way out and parses it on the way in. One Jackson serializer/deserializer plus a typed-id wrapper per aggregate. -
Prefixes:
acc_account,tx_transaction,ent_entry,pay_payment,dec_decision. New aggregates register a new prefix. -
ULID over UUIDv4: identifiers are time-sortable, so they preserve b-tree
index locality and make
created_at-ordered cursor pagination cheap.
Positive:
-
Debuggability:
acc_01HXT4M9...vstx_01HXVK4T...is unambiguous in logs, traces, and support tickets. - Type safety at the boundary: a transaction id cannot be silently accepted where an account id is expected; the prefix mismatch fails fast.
- Index and pagination locality: time-sortable ids reduce write amplification on the primary key index and give stable cursor pagination.
- Cache keys: Redis keys are strings regardless; a prefixed id is a self-describing namespace and reduces cross-type key collisions. No regression versus storing a UUID as a string.
- Developer experience and brand: the API reads like a mature fintech product; the sandbox UI mockups already use this format.
Negative / cost:
- One edge converter and a typed-id wrapper per aggregate to write and test.
- Clients must treat the id as an opaque string (documented); they must not parse the prefix for logic beyond type assertion.
- Path parameters validate against the prefixed pattern, not bare
uuid; the OpenAPI spec encodes the pattern.
- Bare UUIDv4 everywhere: opaque, index-unfriendly, and inconsistent with the UI mockups.
- Exposing the database sequence: leaks volume and is enumerable.
- Data-Model - id columns and indexes
- ADR-0007 - invariant enforcement
- Implementation:
docs/plans/ledger-domain-foundation/decisions.md(D3)
- Overview
- Services
- Data Model
- Domain Model
- Event Flow
- Security
- Observability
- Resilience
- SLA / SLI / SLO