A module is the lowest layer of the architecture: it owns exactly one domain’s data and nothing else. Modules are isolated — they never reach into another module, never orchestrate a business operation, and never react to events. All of that lives one layer up, in workflows.
A Mercur module is a standard Medusa module. The examples below build a small Brand module — the kind of custom module you’d add to your own project alongside the built-in ones — so the rules stand on their own rather than relying on Mercur internals.
Thin CRUD only
A module service exists to read and write its own tables. Extend MedusaService({ ...models }) and you get typed list, listAndCount, retrieve, create, update, and delete methods for every model for free — use them.
src/modules/brand/models/brand.ts
import { model } from "@medusajs/framework/utils"
export const Brand = model.define("brand", {
id: model.id().primaryKey(),
name: model.text(),
})
src/modules/brand/service.ts
import { MedusaService } from "@medusajs/framework/utils"
import { Brand } from "./models/brand"
class BrandModuleService extends MedusaService({
Brand,
}) {
// Generated for you: listBrands, retrieveBrand, createBrands,
// updateBrands, deleteBrands, listAndCountBrands, ...
}
export default BrandModuleService
Only add a custom method when the logic is about this module’s own data and can’t be expressed with the generated methods — for example, a specialised query. When you do, use Medusa’s DI decorators so the method runs in the ambient context:
A justified custom method — still single-module
class BrandModuleService extends MedusaService({ Brand }) {
@InjectManager()
async listActiveBrands(
filters: FindConfig<BrandDTO> = {},
@MedusaContext() sharedContext: Context = {}
): Promise<BrandDTO[]> {
return this.listBrands({ ...filters, is_active: true }, {}, sharedContext)
}
}
What must never live in a module service: business orchestration, calls to another module’s service, event emission, HTTP concerns, or anything that mutates data outside this module. If a method needs a second module’s data or writes across a boundary, it belongs in a workflow, not here. See the logic-placement cheat sheet.
Naming
-
Register the module by a stable id constant. Export the module id and register the service against it:
src/modules/brand/index.ts
import { Module } from "@medusajs/framework/utils"
import BrandModuleService from "./service"
export const BRAND_MODULE = "brand"
export default Module(BRAND_MODULE, {
service: BrandModuleService,
})
Mercur’s own modules follow the same pattern but read their id from the shared MercurModules enum in @mercurjs/types (e.g. Module(MercurModules.SELLER, …)). For a project-local module, a single exported constant like BRAND_MODULE is enough — just never inline the raw string in more than one place.
-
Methods are
camelCase and model-suffixed. Medusa generates listBrands, createBrands, retrieveBrand — match that casing and pluralisation when you add or override methods. Private helpers end with a trailing underscore (computeBrandStats_).
-
Models are lowercase-defined, referenced by their key.
model.define("brand", { ... }); the object key you pass to MedusaService (Brand) is what drives the generated method names.
-
Types live next to the module (or in a shared types package). Export DTOs like
BrandDTO and import them; never redeclare a model’s shape ad hoc. See Types & augmentation.
Do not call .linkable() — links are declared separately
It is tempting to relate two modules by pointing a model at another module’s table. Don’t. A module model must not reference another module’s data, and you should not wire relationships inside the model definition.
Cross-module relationships are declared outside the modules, with defineLink, and read through Query. A module never imports another module’s .linkable shape to build a foreign key into it. Keeping models link-free is what lets modules stay independently migratable and upgrade-safe.
Define the relationship as its own link file instead — covered in full on Module links:
Relationship declared as a link, not inside the model
import { defineLink } from "@medusajs/framework/utils"
import ProductModule from "@medusajs/medusa/product"
import BrandModule from "../modules/brand"
export default defineLink(
ProductModule.linkable.product,
BrandModule.linkable.brand
)
The model itself stays flat — plain columns, no relations pointing at other modules:
src/modules/brand/models/brand.ts
export const Brand = model.define("brand", {
id: model.id().primaryKey(),
name: model.text(),
is_active: model.boolean().default(true),
metadata: model.json().nullable(),
})
Decorators
Custom service methods that touch the database use Medusa’s dependency-injection decorators so they participate in the ambient transaction and shared context:
| Decorator | Use it on | Purpose |
|---|
@InjectManager() | Read methods | Injects the entity manager so the method runs in the current context. |
@InjectTransactionManager() | Write methods | Runs the method inside a transaction, enabling rollback. |
@MedusaContext() | The trailing sharedContext parameter | Threads the request/transaction context through the call. |
Decorator pattern for a custom write
@InjectTransactionManager()
async deactivateBrand(
id: string,
@MedusaContext() sharedContext: Context = {}
): Promise<BrandDTO> {
return this.updateBrands({ id, is_active: false }, sharedContext)
}
If you don’t need a custom method, don’t write one. The generated MedusaService methods already carry the right decorators and transaction behaviour — reaching for them first keeps modules thin by default.
Checklist for a module
- Extends
MedusaService({ ...models }); leans on generated CRUD.
- Registered with
Module(BRAND_MODULE, { service }) against a stable id.
- No import of, or call into, any other module’s service.
- Models are flat — no
.linkable() wiring, no cross-module foreign keys.
- Custom methods use
@InjectManager / @InjectTransactionManager + @MedusaContext.
- DTOs are exported and imported, never redeclared inline.
- No orchestration, no events, no HTTP — those live in workflows and routes.