3 MIN READ

The Medusa Container: Dependency Injection Without the Ceremony

Everything in Medusa is resolved from a container. What is registered, how scoping works per request, and how to avoid the two mistakes that cause leaks.

BY RAHUL MEHTAUPDATED
Illustration for “The Medusa Container: Dependency Injection Without the Ceremony” — Architecture

Every piece of Medusa you will ever write receives a container. API routes get it on req.scope, workflow steps get it in context, subscribers and scheduled jobs get it as an argument. Understanding what is in it — and what its lifetime is — removes most of the confusion people have about where code should live.

What is in there

KeyWhat it is
Your module nameYour module's service instance
`Modules.PRODUCT`, `Modules.CART`, …Core commerce module services
`query`The cross-module query layer
`logger`Structured logger
`event_bus`Event publishing
`configModule`Resolved configuration
`manager`The database manager, rarely needed directly

Core module keys come from the Modules enum. Your own modules are keyed by whatever you exported:

src/modules/wishlist/index.tsts
export const WISHLIST_MODULE = "wishlist"

Resolving, in each place you might

API route:

src/api/store/wishlists/route.tsts
import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
import { WISHLIST_MODULE } from "../../../modules/wishlist"
import type WishlistModuleService from "../../../modules/wishlist/service"

export async function GET(req: MedusaRequest, res: MedusaResponse) {
  const wishlists: WishlistModuleService = req.scope.resolve(WISHLIST_MODULE)
  const logger = req.scope.resolve("logger")

  logger.info(`Listing wishlists for ${req.auth_context.actor_id}`)

  res.json({ wishlists: await wishlists.listWishlists({}) })
}

Workflow step:

ts
export const notifyStep = createStep(
  "notify",
  async (input: { email: string }, { container }) => {
    const notification = container.resolve(Modules.NOTIFICATION)
    await notification.createNotifications({
      to: input.email,
      channel: "email",
      template: "wishlist-shared",
    })
    return new StepResponse({ sent: true })
  }
)

Subscriber:

src/subscribers/order-placed.tsts
import type { SubscriberArgs, SubscriberConfig } from "@medusajs/framework"

export default async function orderPlacedHandler({
  event: { data },
  container,
}: SubscriberArgs<{ id: string }>) {
  const logger = container.resolve("logger")
  logger.info(`Order ${data.id} placed`)
}

export const config: SubscriberConfig = {
  event: "order.placed",
}

Same object, three entry points.

Request scoping, and why it matters

The application has a root container. Each incoming request gets a scoped child — req.scope. Request-specific state, including authentication context, lives on the scope, and it is discarded when the request ends.

This produces the two mistakes worth knowing about.

Mistake one: caching a resolved service at module scope.

ts
// ✗ Resolves once at import time, then holds a stale scope forever.
const wishlistService = container.resolve(WISHLIST_MODULE)

export async function GET(req: MedusaRequest, res: MedusaResponse) {
  return res.json(await wishlistService.listWishlists({}))
}

Resolve inside the handler. It is cheap.

Mistake two: holding req.scope past the request.

ts
// ✗ The scope is gone by the time this runs.
export async function POST(req: MedusaRequest, res: MedusaResponse) {
  setTimeout(() => {
    const service = req.scope.resolve(WISHLIST_MODULE)
  }, 5000)
  res.json({ ok: true })
}

Background work belongs in a scheduled job or an event subscriber, both of which get their own container.

Typing what you resolve

resolve returns any unless you help it. Annotate:

ts
import type WishlistModuleService from "../../../modules/wishlist/service"

const wishlists: WishlistModuleService = req.scope.resolve(WISHLIST_MODULE)

Or wrap it once per module in a small typed helper. Either way, do it — an untyped container is how a rename silently becomes a runtime error.

The coupling smell

If you are ever tempted to write this inside a module:

ts
// ✗ Direct import couples two modules.
import ProductModuleService from "@medusajs/medusa/product/service"

Stop. That is the coupling module isolation exists to prevent. The right answers, in order of preference:

  1. Read across a link using query.
  2. Orchestrate in a workflow that resolves both services and coordinates them.
  3. Resolve from the container inside a step, which at least keeps the dependency at runtime rather than compile time.

Never a direct import between modules.

Practical guidance

Export a name constant per module and always import it. String keys typed by hand are a bug waiting to happen.

Resolve logger and use it. Structured logs with request context are the difference between debugging in minutes and in hours.

Prefer query for reads that cross modules. It resolves links and returns exactly the fields you asked for.

Registering additional resources

A module loader can register resources into that module's scope — useful for a configured third-party client you would rather build once than per call:

src/modules/crm/loaders/client.tsts
import { LoaderOptions } from "@medusajs/framework/types"
import { asValue } from "awilix"
import { CrmClient } from "../lib/client"

export default async function crmClientLoader({ container, options }: LoaderOptions) {
  const client = new CrmClient({
    apiKey: (options as { apiKey: string }).apiKey,
  })

  container.register("crmClient", asValue(client))
}

Registered in the module's scope only, which keeps it invisible to the rest of the application — the isolation rule applies to registrations as much as to imports.

A typed resolve helper

resolve returns any, and repeating the annotation everywhere is how a rename becomes a runtime error six files later. Wrap it once per module:

src/modules/wishlist/resolve.tsts
import type { MedusaContainer } from "@medusajs/framework/types"
import { WISHLIST_MODULE } from "."
import type WishlistModuleService from "./service"

export const resolveWishlist = (container: MedusaContainer): WishlistModuleService =>
  container.resolve(WISHLIST_MODULE)

Then resolveWishlist(req.scope) everywhere. One place to change if the module is ever renamed, and full type inference at every call site.

Testing with the container

The container is what makes integration tests straightforward — the test harness gives you one, and everything resolves exactly as it does in production:

ts
medusaIntegrationTestRunner({
  testSuite: ({ getContainer }) => {
    it("resolves the module and applies the rule", async () => {
      const container = getContainer()
      const wishlists = container.resolve(WISHLIST_MODULE)

      const [list] = await wishlists.createWishlists([{ customer_id: "cus_1" }])
      await wishlists.addItem(list.id, "variant_1")

      expect(await wishlists.listWishlistItems({ wishlist_id: list.id })).toHaveLength(1)
    })
  },
})

No mocking of dependencies, because there are none to mock — the module resolves its collaborators from the same container the test holds. That is the practical payoff of dependency injection, and it is why testing a Medusa module is unusually pleasant.


The container is deliberately unremarkable, and that is the point. If it feels like it is in your way, the design is usually telling you something — happy to take a look.

Frequently asked questions

What is the Medusa container?

A dependency injection container holding every resolvable resource in the application: your module services, core commerce module services, the logger, the query layer, the event bus and configuration. API routes, workflow steps, subscribers and scheduled jobs all receive it.

How do I resolve a custom module service?

Call `container.resolve(MY_MODULE)` — or `req.scope.resolve(MY_MODULE)` in an API route — using the name constant your module's `index.ts` exports. Annotate the result with your service type, since `resolve` is otherwise untyped.

What is the difference between req.scope and the global container?

`req.scope` is a child container created per request, carrying request-specific state such as authentication context, and destroyed when the request ends. The global container is application-lifetime. Always use `req.scope` inside request handling, and never hold a reference to it after the response.

Why should I not import a module service directly?

A direct import couples the two modules at compile time, which defeats the isolation that lets modules be replaced, extracted or upgraded independently. Read across a module link, orchestrate in a workflow, or resolve from the container instead.

Can I register my own services in the container?

Yes — modules register their service automatically, and a module loader can register additional resources within that module's scope. Anything you want available application-wide is usually best expressed as a module, which gives you registration for free.

Why is my resolved service undefined?

Almost always because the module is not registered in `medusa-config.ts`, or the resolution key does not match the name the module exported. Import the exported constant rather than typing the string, and confirm the module appears in the `modules` array.

[ Keep reading ]