ADR-0002. Vendor link via VendorItem only — no vendorId on principals
| Field | Value |
|---|---|
| Status | Accepted |
| Date | 2026-03-20 |
| Deciders | inventory-team, PM |
| Supersedes | — |
Context
- A
Material(orProductVariant) can be supplied by multiple vendors at different prices and UoMs. - A vendor supplies many items.
- Real-world relationship is M:N with non-trivial attributes (price, UoM, multiplier, isPreferred, lastInvoiced snapshot).
- Initial impulse: add
vendorIdcolumn toMaterial(the "preferred vendor"). This was rejected.
Decision
Vendor↔principal link lives only in the VendorItem join table. Material and ProductVariant have no vendorId column. To express a "preferred vendor" for an item, set VendorItem.isPreferred = true (atomic flip via setPreferredAtomic, partial unique per (merchantId, itemType, itemId)).
Consequences
| Pros | Cons |
|---|---|
| Single source of truth for vendor relations | Querying "preferred vendor for material X" requires a JOIN |
| Naturally supports M:N: same material from 3 vendors at different prices | "Material has no preferred vendor" is allowed (no NOT NULL constraint) |
lastInvoiced snapshot scoped to VendorItem (one history per vendor-item pair) | Need atomic flip helper (setPreferredAtomic) |
recordPurchase updates one row, not a Material column | UI must show a "vendor selector" rather than a hardcoded one |
Alternatives Considered
| Option | Pros | Cons | Why rejected |
|---|---|---|---|
Material.vendorId (single) | Simple JOIN-free read | Can't model M:N; "secondary vendors" need parallel structure anyway | Doesn't match real procurement |
Material.preferredVendorId + VendorItem table | Fast lookup | Two sources of truth, sync drift inevitable | Anti-pattern |
Vendor.itemIds[] jsonb | Single-row reads | No constraint, no queryability per-item | Worse than no decision |
References
core/src/models/schemas/inventory/vendor-item/schema.tsinventory/src/services/vendor-item.service.ts—setPreferred,recordPurchase- Memory:
feedback_vendor_via_vendoritem_only.md