DEV Community

Cover image for MedusaJS Dropped the Foreign Keys Between Its Modules: The defineLink Gamble
Andrii B.
Andrii B.

Posted on AI-assisted

MedusaJS Dropped the Foreign Keys Between Its Modules: The defineLink Gamble

MedusaJS 2.0 did something that would get a junior engineer's pull request rejected on sight: it deleted the foreign keys between its own tables. On purpose. Not by accident, not as tech debt, but as the load-bearing decision of the whole architecture. The release notes say it in one flat sentence, and it's worth reading twice: "We've also eliminated all database-level dependencies, removing foreign keys between data models in different modules."

If you grew up on Rails, Django, or a decade of Magento's EAV tables, that line should make you flinch. Foreign keys are how the database protects you from yourself. They stop orphaned rows, they cascade deletes, they turn "these two things are related" into a rule the storage engine enforces whether your application code remembers to or not. MedusaJS looked at all of that and decided the cost was too high. This is the defineLink gamble, and once you understand what it buys and what it takes away, you'll either love it or quietly plan your reads around it.

What module isolation actually buys

Start with the why, because the no-foreign-keys thing sounds reckless until you see the constraint it's serving.

MedusaJS 2.0 ships its commerce logic as separate modules, twenty-plus of them, from Product and Pricing to Cart, Order, Inventory, and Fulfillment. Each one is a self-contained package: its own data models, its own service, its own migrations. The rule that makes them modules rather than just folders is isolation. The docs are blunt about it:

A module is unaware of any resources other than its own, such as services or data models.

So the Product module can't reach into the Pricing module's service, and the Pricing module's tables can't hold a relationship to the Product module's tables. There's no product_id foreign key sitting on a price row pointing back across the boundary. The release blog frames it as a rewrite from the ground up: "all business domains (services and data models) have been rewritten from scratch to eliminate interdependencies between them."

The payoff is portability. Because a module knows nothing about its neighbors, you can swap MedusaJS's built-in Pricing module for your own, run a module against a different datastore, or lift one out and use it somewhere else. A foreign key is the enemy of that goal. The moment the Pricing table references the Product table at the database level, the two are welded together forever, and you can never move one without the other. MedusaJS's bet is that in a commerce platform people actually customize, replaceable parts are worth more than referential integrity across those parts.

One myth to kill early: isolation does not mean every module gets its own database. By default they all share one Postgres. Isolation is enforced in the code and the schema, not by physically scattering your data. You can point a module at a separate datastore if you want, but you don't have to, and most people don't.

The link table, not the foreign key

So if a price can't point at a product with a foreign key, how do you say "this price belongs to this product"? You define a link.

A link lives in its own file under src/links, and you declare it with defineLink, wiring together the linkable handles that each module exposes for its data models:

// src/links/product-post.ts
import BlogModule from "../modules/blog"
import ProductModule from "@medusajs/medusa/product"
import { defineLink } from "@medusajs/framework/utils"

export default defineLink(
  ProductModule.linkable.product,
  BlogModule.linkable.post
)
Enter fullscreen mode Exit fullscreen mode

That's the whole declaration for a link between a product and a blog post. Every module hangs a linkable property off its service holding these configs for its models, so you never touch the other module's internals, you just reference the handle it published.

Now here's the part that matters. When you sync this link, MedusaJS doesn't add a column to the product table or the post table. It creates a brand new table that sits between them:

npx medusa db:sync-links
Enter fullscreen mode Exit fullscreen mode

That command (or db:migrate, which runs migrations and syncs links together) creates a link table named after both sides, something like product_product_blog_post. It has two columns: the product's id and the post's id. And the crucial detail, straight from the docs, is what those columns are not: they "store only the IDs of the linked records and do not hold a foreign key constraint."

Two isolated MedusaJS module tables, Product and Blog, joined by a separate link table holding only the product and post IDs with no foreign key constraint

Look at what just happened. The relationship exists, but it lives in a third table, and the database has no opinion about whether those IDs point at anything real. Delete a product and its row in the link table just dangles, pointing at a ghost. There's no ON DELETE CASCADE to clean it up, because there's no foreign key to hang the cascade on. That responsibility moved out of the database and into MedusaJS's application layer, where the framework manages links as records commit and delete. Whether you trust that trade is the entire question of the article.

Reading across the gap with Query

Fine, the relationship is in a link table. How do you actually read a product and its linked blog post in one go, without hand-writing joins across three tables? You use Query.

Query is MedusaJS's cross-module read tool. You resolve it from the container and call graph, describing what you want in a shape that looks a lot like GraphQL:

import { ContainerRegistrationKeys } from "@medusajs/framework/utils"

const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)

const { data: posts } = await query.graph({
  entity: "post",
  fields: ["id", "title", "product.*"],
  filters: { id: "post_123" },
})
Enter fullscreen mode Exit fullscreen mode

The magic is in "product.*". You ask for the post's own fields, then you reach across the link and pull the whole linked product too, all in one call. If the link is a one-to-many, you use the plural form, "products.*", and get an array back. Query knows about every module's models and every link between them, so it stitches the result together for you.

But read that word carefully: stitches. Query does not run a SQL join across the module tables, because it can't, there are no foreign keys to join on. Instead it builds an internal graph of your modules and their links, fetches from each module separately, and aggregates the pieces into the final result. The docs describe exactly this: "Medusa aggregates the data coming from different modules to create the end result."

For the common case, this is genuinely nice. Your module boundaries stay clean, you never write cross-module SQL by hand, and adding a new link is one defineLink file plus a sync. The gamble is paying off. And then you hit the wall.

The join you can't write

Here's the scenario that finds the edge. You want products in a specific sales channel, sorted by price, cheapest first. Product lives in the Product module. The sales-channel association is a link. Price lives in the Pricing module, another link. In a foreign-key world this is a two-join query with an ORDER BY, the kind of thing you'd write half-asleep.

In MedusaJS's aggregate-don't-join world, you can't. Because Query pulls from each module separately and merges afterward, it has no single result set to filter or sort by a linked module's field. The docs name the limitation directly: "This approach limits your ability to filter data by linked modules." Filtering by the post's own id is fine. Filtering products by a linked sales channel's id, or sorting them by a linked price, is not something plain query.graph can do, because at the moment it would need to filter, the linked data hasn't been joined in, it's still sitting in another module waiting to be aggregated.

This is the "what breaks the first time you need a real join" moment, and it's not a bug. It's the direct, unavoidable cost of deleting the foreign keys. You removed the mechanism that makes cross-entity filtering cheap, so cross-entity filtering got expensive. Nobody who chooses this architecture gets to be surprised by it. You either design your reads so the filter and sort always live inside a single module, or you reach for the escape hatch.

The escape hatch: the Index module

The escape hatch is the Index module, and it's MedusaJS's honest answer to the problem its own isolation created.

The idea is a central, read-optimized index. On startup and as data changes, MedusaJS ingests the modules' data into one relational store that does know how everything connects. Then you query that store, and because it's a single joined-up representation, you finally can filter and sort across module boundaries. The API is deliberately identical to Query, so switching is nearly free, you swap graph for index:

const { data: products } = await query.index({
  entity: "product",
  fields: ["*", "sales_channels.*"],
  filters: {
    sales_channels: { id: "sc_123" },
  },
})
Enter fullscreen mode Exit fullscreen mode

That filters.sales_channels.id is the thing plain query.graph couldn't do: filtering products by a linked module's field. The docs put the division of labor plainly: "The Index Module adds a new index method to Query and it has the same API as the graph method," and it exists "to filter linked modules."

Left, query.graph aggregating separate module results and unable to filter by a linked field; right, query.index reading one central store that filters and sorts across modules

Now the catch, because there's always a catch. The Index module is experimental. The docs say so in as many words: "The Index Module is experimental and still in development, so it is subject to change." It's gated behind a feature flag and, as the team wrote in September 2025, it "is not used by default in the core APIs and workflows." It has been maturing steadily, more entities became ingestible in v2.10.2, caching arrived in v2.11.0, admin routes to inspect and resync the index landed in v2.11.2 at the end of October 2025, but it is not yet the default read path, and you shouldn't present it to yourself as one.

So the real state of the gamble is this: MedusaJS gave you a clean, isolated, foreign-key-free architecture today, and the tool that makes cross-module querying feel normal again is still growing up. If your product needs heavy cross-module filtering right now, you're either enabling an experimental module or shaping your data model so you don't need it. That's the tax, stated honestly.

Managing links at runtime

One more piece, because defining a link type is not the same as saying "this specific product is linked to this specific post." For that you use the Link service at runtime:

import { ContainerRegistrationKeys, Modules } from "@medusajs/framework/utils"

const link = req.scope.resolve(ContainerRegistrationKeys.LINK)

await link.create({
  [Modules.PRODUCT]: { product_id: "prod_123" },
  blog: { post_id: "post_123" },
})
Enter fullscreen mode Exit fullscreen mode

link.create writes the row into that link table, and link.dismiss removes it, same shape. This is the application-layer bookkeeping that stands in for what a foreign key and a cascade used to do automatically. You create the association, you're responsible for tearing it down.

Warning
If you're reading an older tutorial that resolves remoteLink and calls it the current API, stop. As of MedusaJS v2.2.0, Remote Link was deprecated in favor of Link. Same usage, you just resolve LINK from the container instead. The one place the old name lingers is workflow steps, which still ship as createRemoteLinkStep, dismissRemoteLinkStep, and friends, so don't be surprised to see both spellings in the same codebase.

That split, a renamed service but legacy step names, is a small tell about how fast this part of MedusaJS is still moving. Which brings us back to whether the whole bet was worth making.

Is the gamble worth it?

Here's my read, coming from years of both Magento's foreign-key-everything schema and the microservices tax on the other extreme.

What MedusaJS built is a modular monolith with unusually honest boundaries. In a typical monolith, "modules" are a naming convention that a single JOIN quietly violates the first time someone's in a hurry. MedusaJS made that violation impossible at the database level. You physically cannot couple two modules through the schema, which means the boundaries you drew on the whiteboard are the boundaries you actually have in production a year later. Anyone who's watched a "modular" codebase rot into a big ball of cross-table joins knows how rare and valuable that is. You get the clean domain separation you chased with microservices, without paying for a network hop and a separate deploy per module.

The price is real and you should say it out loud: you gave up the database's referential integrity and its cheap cross-entity queries. Orphaned link rows are now your problem, not Postgres's. Cross-module filtering and sorting, the bread and butter of any admin grid or storefront facet, is either an experimental module or a data-modeling constraint you design around. That's not a small tax on a commerce platform, where "show me products in this channel sorted by margin" is a Tuesday.

So the honest verdict: the defineLink gamble is the right call when you're going to customize and replace modules, when clean boundaries matter more to you than referential integrity, and when your heavy filtering happens to live inside single modules. It's the wrong call if your product is one giant cross-module reporting surface and you need those joins today, because the tool that makes them painless is still wearing an experimental flag. MedusaJS bet that portability beats foreign keys for the kind of teams that outgrow Shopify and would otherwise fork Magento. For a lot of those teams, it's the right bet. Just go in knowing which half of the trade you're standing on.
Originally published at andriiboyko.com.


If you found this helpful, follow me here and on LinkedIn

Top comments (1)

Collapse
 
igordop profile image
Игорь

A modular monolith where the modules physically can't cheat on each other is a good trick. You get the data isolation of microservices without the network bill, and Postgres goes from enforcing the rules to just holding the rows.😁😁