3 MIN READ

Medusa v2 Modules Explained: Building Custom Commerce Domains

Modules are the unit of everything in Medusa v2. What they are, why isolation matters, and how to build one that will not embarrass you in a year.

BY RAHUL MEHTAUPDATED
Illustration for “Medusa v2 Modules Explained: Building Custom Commerce Domains” — Modules

Medusa v1 asked you to extend its entities and override its services. It worked, and then you upgraded and it did not. Version 2 replaced that with modules: isolated packages that own their data and expose a service, and never reach into each other.

The isolation feels restrictive for about a day. Then you upgrade a minor version without reading a migration guide and it stops feeling restrictive.

What a module is

Four files, conventionally:

text
src/modules/wishlist/
├── models/
│   └── wishlist.ts      # data models
├── service.ts           # the module service
├── index.ts             # module definition and export
└── migrations/          # generated, committed

Registered in config:

medusa-config.tsts
module.exports = defineConfig({
  modules: [
    {
      resolve: "./src/modules/wishlist",
    },
  ],
})

From that point the module is resolvable by name anywhere in the application — API routes, workflow steps, subscribers.

The isolation rules

Three rules. They explain most of the design.

1. A module may not import another module's code. Not its service, not its models. If your wishlist module needs product data, it does not import the Product module.

2. A module may not create a foreign key to another module's table. Your wishlist_item table cannot have a real FK to product. This is what makes it possible to replace, extract or scale a module independently.

3. Anything crossing a module boundary goes through a workflow or a link. Reading related data across modules is the query layer's job; orchestrating a process across modules is a workflow's job.

The payoff: Medusa can change the internals of the Product module without breaking your wishlist, because your wishlist never depended on them.

Building one, properly

A wishlist module — small enough to show completely, real enough to be useful.

The data model

src/modules/wishlist/models/wishlist.tsts
import { model } from "@medusajs/framework/utils"

export const Wishlist = model.define("wishlist", {
  id: model.id().primaryKey(),
  customer_id: model.text().index(),
  name: model.text().default("My wishlist"),
  is_public: model.boolean().default(false),
  items: model.hasMany(() => WishlistItem),
})

export const WishlistItem = model.define("wishlist_item", {
  id: model.id().primaryKey(),
  // A plain text column, not a foreign key — the product lives in another module.
  product_variant_id: model.text().index(),
  note: model.text().nullable(),
  wishlist: model.belongsTo(() => Wishlist, { mappedBy: "items" }),
})

Note product_variant_id is text, not a relation. That is rule two in practice.

The service

src/modules/wishlist/service.tsts
import { MedusaService } from "@medusajs/framework/utils"
import { Wishlist, WishlistItem } from "./models/wishlist"

class WishlistModuleService extends MedusaService({
  Wishlist,
  WishlistItem,
}) {
  // createWishlists, listWishlists, retrieveWishlist, updateWishlists,
  // deleteWishlists and the WishlistItem equivalents are generated.

  async addItem(wishlistId: string, variantId: string, note?: string) {
    const existing = await this.listWishlistItems({
      wishlist_id: wishlistId,
      product_variant_id: variantId,
    })

    if (existing.length) {
      return existing[0]
    }

    const [item] = await this.createWishlistItems([
      { wishlist_id: wishlistId, product_variant_id: variantId, note },
    ])

    return item
  }
}

export default WishlistModuleService

MedusaService generates the CRUD surface from the models you pass it. You write only the methods that encode actual business rules — here, "adding a duplicate is a no-op rather than an error."

The definition

src/modules/wishlist/index.tsts
import { Module } from "@medusajs/framework/utils"
import WishlistModuleService from "./service"

export const WISHLIST_MODULE = "wishlist"

export default Module(WISHLIST_MODULE, {
  service: WishlistModuleService,
})

Export the constant. Everything that resolves the module imports this name rather than typing the string.

Migrations

bash
npx medusa db:generate wishlist
npx medusa db:migrate

Generate from the models, commit the output, run on deploy. Treat generated migrations as source: review them before committing, because a rename you did not intend reads as a drop-and-create.

Using it

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

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

  const wishlists = await wishlistService.listWishlists({
    customer_id: req.auth_context.actor_id,
  })

  res.json({ wishlists })
}

req.scope is the container. Resolve by module name, get a typed service.

When something is not a module

Not every customisation deserves one. The test is whether it owns data.

You want toBuild
Store new entities with their own lifecycleA module
Orchestrate a process across modulesA [workflow](/blog/medusa-workflows-explained)
React to something happeningA [subscriber](/blog/medusa-events-subscribers)
Expose an endpointAn API route
Add a field to an existing entityA link, or `metadata` for inert data
Integrate a third-party serviceA module (it owns credentials and mapping)

The common mistake is a module with no data models that only calls other services. That is a workflow wearing a costume.

Practical guidance

Name the module for the domain, not the feature. loyalty, not points-calculator. Features move; domains do not.

Keep the service thin. Generated CRUD plus real business rules. Orchestration belongs in workflows.

Do not denormalise product data into your module. Copying titles and prices in for convenience means maintaining a cache you did not intend to build. Query across the link instead.

Write integration tests against the module service. They are fast, and they are the cheapest regression protection you will get.

Testing a module

Module services are the easiest thing in a Medusa codebase to test, which makes them the cheapest regression protection available:

src/modules/wishlist/__tests__/service.spec.tsts
import { moduleIntegrationTestRunner } from "@medusajs/test-utils"
import { WISHLIST_MODULE } from ".."
import WishlistModuleService from "../service"

moduleIntegrationTestRunner<WishlistModuleService>({
  moduleName: WISHLIST_MODULE,
  testSuite: ({ service }) => {
    it("treats adding a duplicate as a no-op", async () => {
      const [list] = await service.createWishlists([{ customer_id: "cus_1" }])

      await service.addItem(list.id, "variant_1")
      await service.addItem(list.id, "variant_1")

      const items = await service.listWishlistItems({ wishlist_id: list.id })
      expect(items).toHaveLength(1)
    })
  },
})

Test the rules you wrote, not the CRUD Medusa generated. More in testing Medusa.

Migrating a v1 customisation

If you are coming from v1, the translation is mechanical once you see the mapping:

Medusa v1Medusa v2
Extended entityA model in your own module
Overridden core serviceA workflow that composes core flows
Custom repositoryGenerated service methods, or a query
Subscriber on a core eventSubscriber, largely unchanged
Custom column on `product`Module + link, or `metadata`
Plugin with entity overridesPlugin shipping modules and workflows

The rule of thumb: anything that worked by extending core becomes something that sits beside core. That is more code in the short term and dramatically less on every subsequent upgrade, because there is no longer a core class whose internals your customisation depends on.


Modules are the piece of Medusa that most repays getting right early. If you want a second opinion on a domain model before it becomes migrations, we do that.

Frequently asked questions

What is the difference between a module and a plugin in Medusa v2?

A module is a commerce domain inside your application — data models, a service, migrations. A [plugin](/blog/medusa-plugins-guide) is a distributable package that can contain modules, workflows, API routes and admin extensions, meant to be installed across projects. Build a module for your own domain; build a plugin when you want to share it.

Can a Medusa module import another module?

No, and it is enforced by design. Modules are isolated so they can evolve, be replaced or be scaled independently. Cross-module relationships use module links, and cross-module logic uses workflows.

How do I add a custom field to Medusa's Product?

Two options. For inert display data, use the `metadata` field on the product. For anything you query, validate or build logic against, create a module owning that data and define a link between it and Product — you get a real schema, real indexes and real migrations.

Do I have to write migrations by hand?

No. `npx medusa db:generate <module>` produces them from your models. Review the output before committing — automatic generation cannot tell a rename from a drop plus an add, and that distinction matters in production.

What does MedusaService actually generate?

For each model you pass it: `list`, `listAndCount`, `retrieve`, `create`, `update`, `delete`, plus soft-delete and restore where applicable, pluralised from the model name. That is why most module services start almost empty and only grow methods that encode genuine business rules.

Can I use a different database for my module?

Yes — module isolation makes it possible, and modules that wrap third-party APIs often have no database at all. In practice most teams keep everything in one Postgres instance until there is a concrete reason not to, because a single database keeps operations simple.

[ Keep reading ]