Medusa v2 module isolation cross module query is the pattern that trips every v1-to-v2 migration hardest: modules can no longer resolve each other's services or hold foreign keys across their boundaries. If your custom module needs data from Product or Order, container.resolve("product") will throw — and that is by design. The correct answers are module links in src/links for the association, query.graph() in API routes for reads, useQueryGraphStep in workflows, and never crossing module boundaries inside a service. Get these four in the right places and the isolation stops feeling like a fence.
Every v1-to-v2 migration ticket we take at some point produces the same message: AwilixResolutionError: Could not resolve 'remoteQuery' or Could not resolve 'product'. The container did not lose the service — you asked from the wrong container. This post is the map of which container has what, and how to reach across. I lead Medusa v2 engineering programmes at MithTech, a Bangalore-based enterprise software practice that designs, builds and operates commerce systems for retailers and B2B operators, and this is the mental model we hand every developer joining a v2 project.
What does module isolation actually mean in Medusa v2?
Answer
Medusa v2 module isolation cross module query is the framework's way of enforcing that each module owns its data and services, with no direct database or service dependencies on any other module. Each module runs in its own dependency-injection container, its services can only resolve resources registered in that container, and its data models cannot hold foreign keys to another module's tables. This is what makes modules genuinely swappable and independently testable — and it is what removes half of the migration patterns from v1 that no longer work.
Skimmable summary: modules are containers; containers don't share.
The Module Isolation documentation states the rule plainly: "A module is unaware of any resources other than its own, such as services or data models." Concretely, the following v1 patterns no longer work inside a module's own service:
- Resolving another module's main service (
container.resolve("product")) — throwsAwilixResolutionError. - Declaring a foreign-key relationship on a data model that points at a data model owned by another module — the migration will fail.
- Reading another module's tables directly through raw SQL — technically possible, absolutely not supported, and will break the moment the other module changes its schema.
The correct crossings all go through explicit framework primitives: module links to declare associations, Query to read across, workflows to write across.
Why does container.resolve("product") fail from a custom module?
Skimmable summary: the resolve target isn't registered in your module's container — it lives in Product's container.
The DI container Medusa injects into your service is scoped to your module. When Medusa boots, each module gets its own container containing its own services (registered via the module's constructor), its own repository access, its own logger. The Product Module's services are registered in Product's container, not yours.
Wrong:
// src/modules/brand/service.ts — inside a custom Brand module
import { MedusaService } from "@medusajs/framework/utils"
export default class BrandModuleService extends MedusaService({ Brand }) {
async listBrandsWithProducts(container) {
// ❌ AwilixResolutionError — "product" is not registered here
const productService = container.resolve("product")
// ...
}
}
Right — do the cross-module fetch from an API route or a workflow, where the application container (not a single module's container) is in scope:
// src/api/admin/brands/route.ts
import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
import { ContainerRegistrationKeys } from "@medusajs/framework/utils"
export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)
const { data: brands } = await query.graph({
entity: "brand",
fields: ["id", "name", "products.*"],
})
res.json({ brands })
}
The req.scope is the request-scoped application container — it has every module's Query surface available. Individual module services (brandService.listBrands(), productService.list()) are also resolvable from req.scope because the API route sits above the module boundary, not inside it.
What is Query and when do you use query.graph()?
Skimmable summary: Query is the read API across all modules; it walks module links so you don't have to.
Query is a single API for reading data across every module in your Medusa app — commerce modules (Product, Order, Cart) and any custom modules you've defined. You resolve it once per request and call query.graph() for each read.
The Query documentation gives the canonical shape:
import { ContainerRegistrationKeys } from "@medusajs/framework/utils"
// In an API route
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)
const { data, metadata } = await query.graph({
entity: "post",
fields: ["id", "title", "author.*", "tags.*"],
filters: { status: "published" },
pagination: { skip: 0, take: 20 },
})
Two things to know about fields:
- Dot-notation walks module links.
"product.*"returns every field of the linked Product;"product.id"returns just its id;"zone.set.location.*"walks three link hops. Query resolves each hop through the module links registered insrc/links. - Singular vs plural matters. A one-to-one link uses the singular entry point (
"product.*"); a link whereisList: truewas set uses the plural ("products.*"). Get this wrong and the field returns asnullon every row.
Query returns { data, metadata } — always. Metadata carries pagination info; don't grab data and throw away metadata if you're paging.
How do you define a module link between two modules?
Skimmable summary: a defineLink call in src/links associates two data models without adding a foreign key.
Module links replace v1's cross-module foreign keys with a declarative association. A link file lives at src/links/<name>.ts:
// src/links/brand-product.ts
import { defineLink } from "@medusajs/framework/utils"
import ProductModule from "@medusajs/medusa/product"
import BrandModule from "../modules/brand"
export default defineLink(
BrandModule.linkable.brand,
ProductModule.linkable.product,
)
This declaration:
- Creates a pivot table (
brand_product_linkor similar) at the framework level to store the associations. - Registers the association with Query so
fields: ["brand.*"]from a Product query andfields: ["product.*"]from a Brand query both resolve. - Does not add a foreign key to either module's own tables — both modules stay independent.
To create the association at runtime, use the Link module in a workflow:
const link = container.resolve(ContainerRegistrationKeys.LINK)
await link.create({
[BrandModule.linkable.brand]: { brand_id: "brand_123" },
[ProductModule.linkable.product]: { product_id: "prod_456" },
})
For a one-to-many link (a brand has many products), set isList: true on the linkable side that owns the "many":
export default defineLink(
BrandModule.linkable.brand,
{
linkable: ProductModule.linkable.product,
isList: true,
},
)
If you only need to read across modules and don't want the pivot table (read-only relationships), the docs note that read-only links are supported — the association exists in Query's view of the graph, but nothing is stored.
Why should you use useQueryGraphStep inside a workflow?
Skimmable summary: workflow steps run in their own scope — the raw Query resolve pattern that works in an API route can fail here.
A Medusa workflow is composed of steps, each executed with its own container scope. That scope may not have the same registrations as the request scope of an API route. Issue #11500 documented a case on v2.5 where an internal step tried to resolve remoteQuery and threw AwilixResolutionError: Could not resolve 'remoteQuery' even though the developer had not written any custom workflow using it — the module container the step ran in did not have remoteQuery registered.
The safe pattern inside a workflow is the framework's own step helper, useQueryGraphStep, which takes care of resolving the right service in the step's scope:
import { createWorkflow, WorkflowResponse } from "@medusajs/framework/workflows-sdk"
import { useQueryGraphStep } from "@medusajs/medusa/core-flows"
export const listPostsWorkflow = createWorkflow(
"list-posts",
() => {
const { data: posts } = useQueryGraphStep({
entity: "post",
fields: ["id", "title"],
filters: { status: "published" },
})
return new WorkflowResponse(posts)
},
)
Same query shape as query.graph() in an API route — but executed as a step, with retries, compensation, and observability all handled by the workflow engine. Related boundary concerns (retries, compensation, and the transactional envelope around cross-module writes) are covered separately in Medusa v2 workflow compensation boundary. For multi-location writes across the Inventory module (a common cross-module write scenario), the reservation flow is covered in Medusa v2 multi-location inventory reservation.
What is remoteQuery and why is Medusa moving away from it?
Skimmable summary: earlier v2 exposed remoteQuery for cross-module reads; Query supersedes it with a cleaner, container-safe API.
remoteQuery was the earlier surface for querying across modules — you resolved it, you called it with a similar signature, it did the graph walk. The problem, as Issue #11500 illustrates, is that remoteQuery requires specific registrations in the resolving container, and when a workflow step runs inside a module container that doesn't have it registered, the resolve fails.
Query fixes this by being the framework's canonical read API — every container that can host a request or a step has Query available. If you inherit v2 code using remoteQuery, the migration is mechanical:
// ❌ Old — remoteQuery
const remoteQuery = container.resolve("remoteQuery")
const posts = await remoteQuery({
post: {
fields: ["id", "title"],
filters: { status: "published" },
},
})
// ✅ New — Query
const query = container.resolve(ContainerRegistrationKeys.QUERY)
const { data: posts } = await query.graph({
entity: "post",
fields: ["id", "title"],
filters: { status: "published" },
})
New code should not add remoteQuery dependencies. If you're on the current v2 release and Medusa itself uses remoteQuery internally, that is fine — it is your custom code that should not.
What is the correct pattern for the four common scenarios?
Read across modules from an API route
Resolve Query from the request scope and call query.graph(). Use dot-notation on fields to walk module links. Return the result — no service resolution of other modules needed. If a required link doesn't exist yet, add a src/links/*.ts file first, then re-run migrations.
Read across modules from inside a workflow
Use useQueryGraphStep() from @medusajs/medusa/core-flows. Same query shape as query.graph() but executes as a step with retries and compensation handled. Do not resolve remoteQuery or query directly inside a workflow step — the container may not have what you expect.
Write across modules — cart + inventory + payment together
Compose a workflow. Each step resolves its own module's service to perform its write. If any step throws, previous compensating actions are triggered by the workflow engine. Do not put cross-module writes inside a single module's service — you lose the transactional envelope and the retry semantics.
Associate two module's records at runtime
Resolve the Link module (ContainerRegistrationKeys.LINK) and call link.create({ ... }) with each side's linkable identifier. Do this inside a workflow step so the association is part of the transactional envelope with whatever create-side-effects triggered it (create-product-then-link-to-brand, for example).
The four scenarios in one table
| Scenario | Where | Primitive | Wrong pattern |
|---|---|---|---|
| Read a Product from a custom module's API route | src/api/admin/*/route.ts | query.graph({ entity: "product", ... }) | container.resolve("product").list() inside your module's service |
| Read a Brand + its Products from a workflow | src/workflows/*.ts | useQueryGraphStep({ entity: "brand", fields: ["*", "products.*"] }) | Resolving remoteQuery or query directly in a step |
| Create an Order + reserve Inventory + capture Payment | Cross-module workflow | Multi-step workflow with per-step service resolves + compensation | Any single module's service trying to orchestrate the others |
| Associate a Brand with a Product | src/links/brand-product.ts + workflow step | defineLink(...) + link.create({ ... }) | A foreign key on Product pointing at Brand |
FAQ
The four questions v1-to-v2 migrations produce most often.
Can I skip module links and just query the database directly with raw SQL?
Technically yes — Medusa doesn't prevent it. Practically no. Any direct SQL join across module tables bypasses the isolation contract that lets modules evolve independently. The moment the Product module renames a column, ships a migration, or changes its indexing strategy, your raw-SQL join breaks in production and there's no upstream contract that says it shouldn't have. Use module links; they cost you one file per association.
What is the difference between container.resolve("query") and req.scope.resolve(ContainerRegistrationKeys.QUERY)?
Functionally in an API route they resolve the same thing. ContainerRegistrationKeys.QUERY is the canonical constant (its value is "query") — using it means the framework can rename or refactor the underlying registration without breaking your code. Prefer the constant. In workflows, do not resolve Query manually at all — use useQueryGraphStep.
Do module links create foreign keys in the database?
No. Module links create a pivot table (like brand_product_link) at the framework level to store the associations. Neither module's own tables gain a foreign key to the other. This is what preserves isolation: dropping the Brand module removes the link and the pivot, and Product's schema is untouched. Read-only links don't even create the pivot table — the association exists only in Query's view of the graph.
Why did my query.graph() call return the linked field as null?
Two common causes. First, the fields name doesn't match the link's entity — check whether the link was defined with isList: true (plural in fields) or without (singular). Second, no link exists between the two modules — Query cannot walk a link that isn't declared in src/links. Adding the defineLink file and re-running migrations makes the field resolvable.
Migrating from Medusa v1 to v2 and hitting module isolation errors?
MithTech designs, builds and operates Medusa v2 commerce systems for Indian retailers and B2B operators, including migrations from v1 codebases that pre-date the isolation model. If your container.resolve calls started throwing after the upgrade and you are not sure whether to reach for a module link, Query, or a workflow, we can help you refactor the boundary cleanly.