Skip to main content
Modules are isolated: a module never imports another module’s service or points a foreign key at another module’s table (see Modules). Relationships between modules are declared outside the modules, as links, and read through Query. This is what keeps each module independently migratable and upgrade-safe.
Links are a Medusa framework primitive. The examples below link a custom Brand module to Medusa’s built-in Product module — the kind of relationship you’d add in your own project.
A link is a small file that associates two linkable data models. Define it once and sync it to the database with a migration.
src/links/product-brand.ts
import { defineLink } from "@medusajs/framework/utils"
import ProductModule from "@medusajs/medusa/product"
import BrandModule from "../modules/brand"

export default defineLink(
  ProductModule.linkable.product,
  BrandModule.linkable.brand
)
After adding or changing a link, generate and run the migration so the link table exists:
npx medusa db:migrate
Once linked, you read across the boundary with Query — never by calling the other module’s service:
const { data: products } = await query.graph({
  entity: "product",
  fields: ["id", "title", "brand.*"], // follows the product ↔ brand link
})
The order of arguments to defineLink is meaningful and cardinality is controlled with isList. Read it left-to-right as “the left model links to the right model”.
  • defineLink(A.linkable.a, B.linkable.b) — one a ↔ one b.
  • Wrap a side in { linkable, isList: true } to make it the “many” side.
If one brand has many products but each product belongs to a single brand, mark the product side as the list:
src/links/product-brand.ts — one brand, many products
import { defineLink } from "@medusajs/framework/utils"
import ProductModule from "@medusajs/medusa/product"
import BrandModule from "../modules/brand"

export default defineLink(
  {
    linkable: ProductModule.linkable.product,
    isList: true,
  },
  BrandModule.linkable.brand
)
For a many-to-many relationship (a product can carry many brands and a brand spans many products) mark both sides as lists and pin an explicit table name:
src/links/product-brand.ts — many-to-many
export default defineLink(
  { linkable: ProductModule.linkable.product, isList: true },
  { linkable: BrandModule.linkable.brand, isList: true },
  {
    database: {
      table: "product_brand",
    },
  }
)
Direction determines the generated relation names and the shape of the link table. Getting it backwards produces a link that “works” but exposes the wrong nesting (brand.products vs product.brands) and is painful to migrate away from. Decide the natural reading direction first, then set isList on the many side(s).
Links are data, so creating or removing one is a mutation and must happen inside a workflow through the built-in link steps — never by writing to the link table directly.
  • createRemoteLinkStep — create links (and it compensates by removing them on failure).
  • dismissRemoteLinkStep — remove links.
Build the link definitions with transform (never inline logic in the composition function), then pass them to the step. Each entry names the two modules and the ids to associate:
Linking a product to a brand inside a workflow
import { createRemoteLinkStep } from "@medusajs/medusa/core-flows"
import { Modules } from "@medusajs/framework/utils"
import { LinkDefinition } from "@medusajs/framework/types"
import { BRAND_MODULE } from "../modules/brand"

const productBrandLinks = transform(
  { productId, brandId },
  ({ productId, brandId }): LinkDefinition[] => [
    {
      [Modules.PRODUCT]: { product_id: productId },
      [BRAND_MODULE]: { brand_id: brandId },
    },
  ]
)

createRemoteLinkStep(productBrandLinks)
Because createRemoteLinkStep already knows how to compensate, links created this way are torn down automatically if a later step in the workflow throws. This is the whole reason to link inside a workflow rather than in a route.
This is the distinction that trips people up:
  • Reading linked data — fetching brand.* alongside a product — works with query.graph. Query aggregates the two modules’ data to build the result.
  • Filtering by a linked module’s field — “give me products where brand.id = X” — does not work with query.graph.
query.graph cannot filter by a linked (cross-module) field. Because modules are isolated and Query aggregates their data after the fact, there’s no join to filter on. Passing filters: { brand: { id } } to query.graph will not scope products by brand.
You can still filter by a field that lives on the entity’s own module (a plain column like product.status or offer.seller_id) — that’s a normal query.graph filter. It’s only linked-module fields that need a different tool.

Filtering by a linked field — the Index Module

Cross-module filtering is what the Index Module (@medusajs/index) exists for. It ingests data models into a single relational store on startup, so you can filter one entity by another’s fields. Install it, make sure both models are ingested, and query with query.index instead of query.graph:
Filter products by their linked brand — query.index, not query.graph
const { data: products, metadata } = await query.index({
  entity: "product",
  fields: ["id", "title", "brand.name"],
  filters: {
    brand: {
      id: brandId, // ✅ cross-module filter — resolved by the Index Module
    },
  },
})
By default Medusa ingests only Product, ProductVariant, Price, PriceSet, and SalesChannel. To filter products by a custom module like Brand, you must ingest that model into the Index Module first. The Index Module is still marked experimental, though it powers filtering in the Medusa Admin.
query.index takes the same shape as query.graph (entity, fields, filters, pagination), so a route handler can forward req.filterableFields to it exactly the same way — the only change is graphindex.
  • Declared in its own file under src/links/, using defineLink.
  • Argument order reflects the natural reading direction; isList set on the many side(s).
  • Migration generated and run (medusa db:migrate).
  • Cross-module reads go through query.graph, never a service-to-service call.
  • Cross-module filters go through query.index (Index Module, with the model ingested) — query.graph can’t filter by a linked field.
  • Links are created/removed only inside workflows via createRemoteLinkStep / dismissRemoteLinkStep.