25
Backend
04
Security
08
iOS
07
Infra

Wrap every domain identifier in its own UUID newtype

ADR-0045 ACCEPTED · 2026-03-22
Wrap every domain identifier in its own UUID newtype

Decision

Give every domain identifier its own newtype over a UUID, defined once in ztp-core. Each one serializes transparently as the bare UUID and is its own async-graphql scalar. It derefs to the UUID for sqlx binds and library calls. Effects carry the newtypes directly.

Why

When every id is a Uuid, the compiler can't tell a trip id from a user id. Effects carry several ids each, and in a trip rebuilt by replay a swapped id corrupts state quietly instead of failing. A newtype turns the swap into a type error at every function boundary. The GraphQL schema also states what each argument expects, tripId: TripId! where it used to say UUID!. This is parse-don't-validate (ADR-0043) applied to identity.

Rejected alternatives

  • Bare UUIDs everywhere. The default in most Rust database code, and it saves unwrapping at every query and library call. The compiler then can't catch an id passed where a different kind belongs.
  • One generic Id<T> with a phantom marker type. UniFFI exports no generic types, and each id kind needs its own named scalar in the GraphQL schema, so a generic wrapper would still need a declaration per kind.

Consequences

Database reads wrap and writes deref. Deref lets any newtype pass where a &Uuid is expected, so code typed on bare UUIDs accepts every kind. The inner field is public, so the type records which kind an id claims to be and checks nothing about the value. Across UniFFI and Apollo codegen every id becomes a Swift String typealias, so the Swift side gets none of this protection.