Status: Accepted
Who generates entity identifiers (TenantId, ServiceCallId, CorrelationId, EnvelopeId) and when? Options include:
Each approach has implications for idempotency, event correlation, outbox pattern, and API contracts.
(tenantId, entityId)(tenantId, serviceCallId) must be known before DB insert```typescript ignore // ❌ PROBLEM: Can’t use ID until AFTER insert const result = await db.insert(serviceCall).returning([‘id’]) const serviceCallId = result.id // Only available NOW!
// ❌ Can’t include in idempotency check BEFORE insert // ❌ Can’t publish events referencing serviceCallId within same transaction // ❌ Can’t return serviceCallId to client immediately (need DB roundtrip) // ❌ Can’t log with serviceCallId before persistence
**Fundamental issue:** Outbox pattern requires publishing events **within the same transaction** as aggregate insert. Events must reference the aggregate ID. Database-generated IDs aren't available until **after** the insert completes, creating a chicken-and-egg problem.
### Application-Generated IDs: Why They Work
```typescript ignore
// ✅ SOLUTION: Generate ID BEFORE insert
const serviceCallId = clientProvidedId ?? crypto.randomUUID() // UUID v7 preferred
// ✅ Can check idempotency BEFORE insert
const existing = await db.findBy({ tenantId, serviceCallId })
if (existing) return existing
// ✅ Can include ID in domain events
const event = ServiceCallSubmitted({ serviceCallId /* ... */ })
// ✅ Can insert aggregate + publish events in same transaction
await db.transaction(async (tx) => {
await tx.insert(serviceCall)
await tx.outbox.append(event) // References serviceCallId!
})
Decision: Use UUID v7 for time-series data (ServiceCallId, EnvelopeId).
| Identity Type | Generated By | When | Format | Notes |
|---|---|---|---|---|
| TenantId | External (Tenant Provisioning) | Before API call | UUID v7 | Provided in request path/header |
| ServiceCallId | Application (API or Client) | Before DB insert | UUID v7 | Accept from client (idempotency) or generate |
| CorrelationId | Application (API Module) | Request entry | UUID v7 | Generated per request for tracing |
| EnvelopeId | Application (Publisher) | Before publish | UUID v7 | Generated when wrapping message |
Decision: Do NOT create an IdGeneratorPort abstraction.
Rationale (YAGNI):
crypto.randomUUID() available everywhere)Use crypto.randomUUID() directly in application code. If UUID v7 library needed, import directly (not a port).
```typescript ignore // API accepts optional serviceCallId for idempotency interface SubmitServiceCallRequest { serviceCallId?: ServiceCallId // Client-provided (idempotent retry) tenantId: TenantId name: string // … }
// Handler logic const serviceCallId = req.body.serviceCallId ?? Schema.make(ServiceCallId)(crypto.randomUUID())
// Now have ID BEFORE any DB operation
This enables:
- **Idempotent retries** — Client can retry with same ID
- **Client-side ID generation** — For advanced clients who need immediate reference
- **Server-side generation** — Simple clients omit ID, server generates
## Consequences
### Positive
✅ **Idempotency works naturally** — Have `(tenantId, serviceCallId)` before DB insert\
✅ **Outbox pattern works** — Events can reference ServiceCallId within same transaction\
✅ **Immediate availability** — Can log, trace, return ID without DB roundtrip\
✅ **Event correlation** — All messages reference ServiceCallId from creation\
✅ **Client flexibility** — Clients can provide ID for idempotency or let server generate\
✅ **Better DB performance** — UUID v7 insertion order improves index locality\
✅ **Simpler architecture** — No IdGeneratorPort, no extra abstraction
### Negative
⚠️ **Validation required** — Must validate client-provided IDs at API boundary (see Migration below)\
⚠️ **Application responsibility** — Application must generate IDs (can't delegate to DB)\
⚠️ **Collision risk** — Theoretical (but UUID v7 has 74 bits randomness = negligible risk)
### Migration: Brand.nominal → Schema.brand
**Status**: ✅ **Migration Complete** (as of PL-14)
**Previous problem:** `shared.ts` used `Brand.nominal` (type-only, no runtime validation):
```typescript ignore
// ❌ OLD: No validation!
export type TenantId = string & Brand.Brand<'TenantId'>
export const TenantId = Brand.nominal<TenantId>()
// API handler (UNSAFE):
const tenantId = req.body.tenantId as TenantId // Accepts ANYTHING!
Current implementation: Migrated to Schema.brand with UUID v7 validation:
```typescript ignore // ✅ CURRENT: Runtime validation with UUID7 schema import * as DateTime from ‘effect/DateTime’ import * as Effect from ‘effect/Effect’ import * as Schema from ‘effect/Schema’ import { TenantIdBrand } from ‘./tenant-id.schema’ import { UUID7 } from ‘./uuid7.schema’
export class TenantId extends UUID7.pipe(Schema.brand(TenantIdBrand)) { static readonly makeUUID7 = (time?: DateTime.Utc) => Effect.gen(function* () { const uuid7 = yield* Uuid7Service.randomUUIDv7(time) return TenantId.make(uuid7) // Validated UUID7 → branded TenantId })
static readonly decode = (value: string) => Schema.decode(TenantId)(value) // Validates UUID7 format! }
Effect.gen(function* () { // API handler (SAFE): const tenantId = yield* TenantId.decode(req.body.tenantId) // ✅ Validates UUID7 format! Rejects invalid input with ParseError }) ```
Completed in: packages/schemas/src/shared/
Branded types migrated:
packages/schemas/src/shared/tenant-id.schema.tspackages/schemas/src/shared/service-call-id.schema.tspackages/schemas/src/shared/correlation-id.schema.tspackages/schemas/src/shared/envelope-id.schema.tsPattern: All extend UUID7.pipe(Schema.brand(UniqueSymbol)) for double-branding