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:
src/modules/wishlist/
├── models/
│ └── wishlist.ts # data models
├── service.ts # the module service
├── index.ts # module definition and export
└── migrations/ # generated, committedRegistered in config:
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
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
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 WishlistModuleServiceMedusaService 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
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
npx medusa db:generate wishlist
npx medusa db:migrateGenerate 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
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 to | Build |
|---|---|
| Store new entities with their own lifecycle | A module |
| Orchestrate a process across modules | A [workflow](/blog/medusa-workflows-explained) |
| React to something happening | A [subscriber](/blog/medusa-events-subscribers) |
| Expose an endpoint | An API route |
| Add a field to an existing entity | A link, or `metadata` for inert data |
| Integrate a third-party service | A 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:
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 v1 | Medusa v2 |
|---|---|
| Extended entity | A model in your own module |
| Overridden core service | A workflow that composes core flows |
| Custom repository | Generated service methods, or a query |
| Subscriber on a core event | Subscriber, largely unchanged |
| Custom column on `product` | Module + link, or `metadata` |
| Plugin with entity overrides | Plugin 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.
Medusa Project Structure: Where Every Kind of Code Belongs
Medusa loads code by convention. A directory-by-directory guide to what goes where, and the decision tree for placing any new piece of logic.
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.
Medusa Module Links: Relating Data Across Isolated Modules
Modules cannot reference each other's tables, so how do you attach a brand to a product? Links — and the query layer that reads across them.



