Context
Modules own their data (0014), so one module’s model cannot hold a foreign key into another’s tables. Products still need “orders with their customer and product”. Medusa solves this withdefineLink (a pivot
owned by a link module) and query.graph, which fans out to each owning module and stitches the result; its index module is
a Postgres read model for the hot cases.
Decision
- A relation across modules is a
@Linkbetween two models, declared in a module that depends on both. The pivot table belongs to that link, not to either model. query.graph({ entity, fields, filters })reads across links, calling each owning module’s query and joining in memory, with batching through the DataLoader of 0097.- Tenancy and iam apply per owning module: each fan-out call goes through that module’s
@Querypipeline. - Hot read paths use an index read model fed by events, which is the job of
@stackra/search(0089), not a second index package. - Within one module, ordinary relations of 0097 apply;
@Linkis only for crossing a module boundary.
Consequences
- Module boundaries hold without giving up joined reads.
query.graphis slower than a SQL join; the read model is the answer when that matters.- Links appear in the gen manifest, so the registry can show the relation graph.
Enforcement
Planned: the gen validation pass fails a model relation that targets another module’s model without a@Link. Nothing
checks this today.
Supersedes and sources
Supersedes: none. Sources: owner decision in chat, 2026-10-19 (Round 11 W4). Informational: MedusadefineLink,
query.graph and index module.