> ## Documentation Index
> Fetch the complete documentation index at: https://docs.mercurjs.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Module links

> Relate modules without coupling them — defineLink, the link-direction rule, built-in link steps, and filtering by links.

Modules are isolated: a module never imports another module's service or points a foreign key at another module's table (see [Modules](/rc/resources/best-practices/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.

<Note>
  Links are a [Medusa framework primitive](https://docs.medusajs.com/learn/fundamentals/module-links). 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.
</Note>

## `defineLink`

A link is a small file that associates two linkable data models. Define it once and sync it to the database with a migration.

```ts title="src/links/product-brand.ts" theme={null}
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:

```bash theme={null}
npx medusa db:migrate
```

Once linked, you read across the boundary with Query — never by calling the other module's service:

```ts theme={null}
const { data: products } = await query.graph({
  entity: "product",
  fields: ["id", "title", "brand.*"], // follows the product ↔ brand link
})
```

## The link-direction rule

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:

```ts title="src/links/product-brand.ts — one brand, many products" theme={null}
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:

```ts title="src/links/product-brand.ts — many-to-many" theme={null}
export default defineLink(
  { linkable: ProductModule.linkable.product, isList: true },
  { linkable: BrandModule.linkable.brand, isList: true },
  {
    database: {
      table: "product_brand",
    },
  }
)
```

<Warning>
  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).
</Warning>

## Built-in link steps — create links inside workflows

Links are **data**, so creating or removing one is a mutation and must happen inside a [workflow](/rc/resources/best-practices/workflows) 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:

```ts title="Linking a product to a brand inside a workflow" theme={null}
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)
```

<Tip>
  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.
</Tip>

## Reading vs filtering across a link

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`.

<Warning>
  `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.
</Warning>

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](https://docs.medusajs.com/learn/fundamentals/module-links/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`:

```ts title="Filter products by their linked brand — query.index, not query.graph" theme={null}
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
    },
  },
})
```

<Note>
  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](https://docs.medusajs.com/learn/fundamentals/module-links/index-module#how-to-ingest-custom-data-models) into the Index Module first. The Index Module is still marked experimental, though it powers filtering in the Medusa Admin.
</Note>

`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 `graph` → `index`.

## Checklist for a link

* 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`.
