ULIDs over UUIDs: standardizing identifiers across 40 services
An implicit decision left unenforced becomes 40 services split 60/40 between two incompatible identifier shapes. Here's the standard I wrote to close the door — and why ULID won.
A platform standard that is implicitly made but never enforced is not a standard. It's a coin flip that every new service runs again.
On the platform I'm building, the identifier rule was already written down in three places: "all domain entity IDs MUST be branded ULIDs, never UUIDs in new code." The canonical event tables shipped tenant_id CHAR(26). The reference services used CHAR(26). And yet — when I surveyed the fleet, it was split roughly 60/40 between UUID and ULID. The decision had been made and never landed.
Then it bit. A uuid column physically cannot hold a 26-character tenant sentinel string. A service froze its contract with that contradiction latent inside it. That's the moment an "implicit standard" stops being a style preference and becomes a defect.
This is the standard I wrote to close the door.
The rule, in one sentence#
Every platform-internal identifier is a 26-character Crockford-Base32 ULID, stored as
CHAR(26), transmitted as a 26-character string, and generated through one port. UUIDs are forbidden in any platform-internal surface.
That covers tenant_id, user_id, every aggregate-root ID, event_id, idempotency_key, correlation_id, causation_id, and the database row's surrogate id.
Why ULID over UUID#
The trade-offs are well known, but worth recording for the audit trail:
- Monotonic time prefix. A ULID's first 10 characters encode a millisecond timestamp. B-tree indexes on identifier columns stay near-sequential as rows are appended; UUID v4 fragments them. For high-insert tables — audit, billing, observability — the gain is measurable.
- Compact wire form. 26 ASCII characters versus UUID's 36. Smaller JWTs, smaller event envelopes, smaller log lines. At ~10⁹ events/year, the saving is not noise.
- Lexicographic order is temporal order. Cursor pagination (
WHERE id > $cursor ORDER BY id ASC) is free, and the identifier itself tells you when. - Database-agnostic.
CHAR(26)is identical across PostgreSQL (services), SQLite (mobile offline), and SQLCipher (the desktop shell). UUID needs per-database type handling. - Crockford-Base32 excludes the ambiguous characters
I,L,O,U. Hex UUIDs don't, and their hyphens are non-data at fixed positions.
One port, one generator#
The escape hatch that kept the fleet split was a port doc-comment that read "ULID or UUID — implementation chooses." That sentence is how you get 40 different answers. So the port lost its choice:
/**
* Platform identifier generator — RFC-compliant ULID.
*
* generate() MUST return a 26-character Crockford-Base32 ULID: a monotonic
* time-prefix + random suffix, uppercase, no separators. Implementations
* MUST NOT return UUIDs or any other format.
*/
export interface IdentifierClient {
generate(): string;
}
export const IDENTIFIER_CLIENT_TOKEN = Symbol("IdentifierClient");
Generation happens in one place, injected. Inline ulid() / randomUUID() calls outside the generator and tests are forbidden — and a lint rule enforces it. In the persistence layer that means the column default is application-owned, not a database extension:
// Drizzle: ULID generated in the use-case via the injected client,
// not by a Postgres extension. Keeps every database extension-agnostic.
export const invoices = pgTable("invoices", {
id: char("id", { length: 26 }).primaryKey().$defaultFn(() => identifier.generate()),
tenantId: char("tenant_id", { length: 26 }).notNull(),
// ...
});
The detail I'm most pleased with: the JWT sub#
The platform user ID rides in the OIDC-standard sub claim — as the ULID itself, with no translation step. OIDC only requires sub to be "locally unique and never reassigned"; the format is the issuer's choice. So the identity provider emits sub through a protocol mapper that reads a user attribute the IAM service wrote at provisioning time.
The two alternatives both lose:
- Map-table + cache (keep
subas the IdP's UUID, look up the ULID per request) puts the IAM service on the hot path of every authenticated request. - A parallel custom claim (
sub: UUID, gh_uid: ULID) means two identifiers in flight and a non-standard audit trail.
Emitting the ULID directly in sub means the auth-context boundary reads it once and never translates again. The IdP's internal user PK stays a UUID — it's an implementation detail the platform never sees.
Migrating a fleet without a big-bang#
You don't stop 40 services to change a column type. The migration is per-service, opportunistic on each service's next contract-freeze cycle, with no deprecation window and no parallel columns (this is pre-production; compatibility shims are a luxury you pay for later). The phases:
| Phase | Scope |
|---|---|
| 1 — Close the door | Land the standard, remove the "UUID or ULID" escape hatch, ship the lint rules as warn |
| 2 — Tenant + aggregate IDs | Per service: uuid('x_id') → char('x_id', { length: 26 }), re-sign the contract |
| 3 — Row surrogates | Internal id columns; no cross-service surface affected |
4 — User IDs via sub | IdP emits the ULID; user-id columns migrate |
Each phase flips its lint rule from warn to error as a service completes it. The ratchet is the enforcement — not a one-time review that rots the moment the next service is generated.
The lesson#
The technical choice (ULID vs UUID) is the easy part; reasonable people can disagree and both ship. The expensive part is the gap between "we decided" and "the build fails if you don't." Standards live in CI, not in documents. If your identifier rule can't fail a pull request, you don't have an identifier rule — you have 40 coin flips.
Keep reading
Stop calling new Date() in your services
Time and randomness are infrastructure. When every service rolls its own clock port, you get six file paths and three interface names for one concern. The fix is one platform port — and the testability it unlocks.
- Testability
- Hexagonal architecture
- DDD
- TypeScript
ADR: Outbox over dual-write for cross-context events
Why every state-changing event in the Ghasi suite goes through a transactional outbox instead of a direct publish — and what that costs.
- Event-driven
- DDD
- Reliability
- PostgreSQL
AI-first is a posture, not a feature
Most platforms bolt AI onto a non-AI core. Treating it as foundational architecture — a single governed gateway, provenance on every artifact — changes what you can promise.
- AI-first
- Architecture
- Governance
- DDD