Skip to content

ADR 0010 Prefixed ULID Identifiers

Tiana_ edited this page May 30, 2026 · 1 revision

ADR-0010: Prefixed ULID identifiers at the API edge

Status: Accepted Date: 2026-05-30 Decider: Maintainer

Context

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:

  1. 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.
  2. Database sequence / bigserial - compact and ordered, but leaks row counts, is guessable, and couples ids to one storage engine.
  3. 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.

Decision

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/uuid in 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.

Consequences

Positive:

  • Debuggability: acc_01HXT4M9... vs tx_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.

Alternatives rejected

  • Bare UUIDv4 everywhere: opaque, index-unfriendly, and inconsistent with the UI mockups.
  • Exposing the database sequence: leaks volume and is enumerable.

Related

  • Data-Model - id columns and indexes
  • ADR-0007 - invariant enforcement
  • Implementation: docs/plans/ledger-domain-foundation/decisions.md (D3)

Clone this wiki locally