Split out of #80, which covers the cosmetic half of the same class. This is the
half where the consequence is a wrong row rather than a mislabelled payload.
The hazard
Every repository and use case names its tenant positionally, next to a string
that is not a tenant:
OrderRepository.find(tenantId, id)
OrderRepository.remove(tenantId, id)
CustomerRepository.find(tenantId, id)
PlaceOrder.execute(tenantId, id, quantity)
FindOrder.execute(tenantId, id)
Verified: swapping them compiles.
declare const find: (tenantId: string, id: string) => void;
find(orderId, tenant); // no diagnostic
Two adjacent strings in a fixed order, and the compiler has nothing to say. The
result is not a wrong error payload — it is a query scoped to the wrong tenant,
in an application whose entire multi-tenancy story rests on that argument being
right. Outbox.pending(tenantId, limit) is the same shape with a number beside
it, which is the one variant TypeScript would catch.
Why branding the tenant alone is enough
Verified: branding only the tenant makes the swap a compile error, and every
id can stay a plain string.
declare const find: (tenantId: TenantId, id: string) => void;
find(orderId, tenant); // Argument of type 'string' is not assignable to 'TenantId'
That is the whole economy of this proposal, and why it is much cheaper than #80:
a pair only has to differ in one position to become unswappable. There is no
need to brand OrderId or CustomerId to close this.
The price
- 21
tenantId: string positions in order-application's ports and use
cases become TenantId.
- A handful of boundaries parse once, and they are already the places each
transport converts: order-api's two controllers (from
context.principal.tenantId and from input.tenantId), the AMQP handler's
envelope, the Temporal activities' args.tenantId, and OUTBOX_TENANTS in
the relay's config.
- Contract inputs stay
string. No client call site changes — the parse
happens on the server, at the boundary, which is where a validation belongs
anyway. This keeps the position contract.ts already states: a brand is a
compile-time fiction, and it is never asked of a caller.
TenantId joins OrderId, CustomerId and Quantity, which
order-domain already defines with z.string().brand(…). No new mechanism.
The relationship to #65
#65 fixed exactly this hazard for provider dependencies — positional deps
silently rebinding when two service shapes happened to match — by naming them.
This is the same hazard one layer down, in the domain's own argument lists, and
#65's fix does not reach it: a use case's parameters are a function signature,
not a deps record.
Naming is not available here the way it was there — a repository method taking
{ tenantId, id } is a different and larger API change. Branding is the cheap
equivalent: it does not name the arguments, it just makes two of them
un-confusable.
Acceptance
TenantId defined in order-domain beside the ids already there.
order-application's ports and use cases take it.
- Each transport parses once at its own boundary; contract inputs unchanged.
- A type test pins that a plain
string — and an order id — cannot be passed
where a tenant is declared.
- A decision on whether
Outbox.pending(tenantId, limit) and the relay's
OUTBOX_TENANTS config parse at the same boundary or earlier.
Split out of #80, which covers the cosmetic half of the same class. This is the
half where the consequence is a wrong row rather than a mislabelled payload.
The hazard
Every repository and use case names its tenant positionally, next to a string
that is not a tenant:
Verified: swapping them compiles.
Two adjacent strings in a fixed order, and the compiler has nothing to say. The
result is not a wrong error payload — it is a query scoped to the wrong tenant,
in an application whose entire multi-tenancy story rests on that argument being
right.
Outbox.pending(tenantId, limit)is the same shape with a number besideit, which is the one variant TypeScript would catch.
Why branding the tenant alone is enough
Verified: branding only the tenant makes the swap a compile error, and every
id can stay a plain
string.That is the whole economy of this proposal, and why it is much cheaper than #80:
a pair only has to differ in one position to become unswappable. There is no
need to brand
OrderIdorCustomerIdto close this.The price
tenantId: stringpositions inorder-application's ports and usecases become
TenantId.transport converts:
order-api's two controllers (fromcontext.principal.tenantIdand frominput.tenantId), the AMQP handler'senvelope, the Temporal activities'
args.tenantId, andOUTBOX_TENANTSinthe relay's config.
string. No client call site changes — the parsehappens on the server, at the boundary, which is where a validation belongs
anyway. This keeps the position
contract.tsalready states: a brand is acompile-time fiction, and it is never asked of a caller.
TenantIdjoinsOrderId,CustomerIdandQuantity, whichorder-domainalready defines withz.string().brand(…). No new mechanism.The relationship to #65
#65 fixed exactly this hazard for provider dependencies — positional deps
silently rebinding when two service shapes happened to match — by naming them.
This is the same hazard one layer down, in the domain's own argument lists, and
#65's fix does not reach it: a use case's parameters are a function signature,
not a deps record.
Naming is not available here the way it was there — a repository method taking
{ tenantId, id }is a different and larger API change. Branding is the cheapequivalent: it does not name the arguments, it just makes two of them
un-confusable.
Acceptance
TenantIddefined inorder-domainbeside the ids already there.order-application's ports and use cases take it.string— and an order id — cannot be passedwhere a tenant is declared.
Outbox.pending(tenantId, limit)and the relay'sOUTBOX_TENANTSconfig parse at the same boundary or earlier.