Skip to main content
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. It is the kind of custom module you 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. Define the model with plain columns.
src/modules/brand/models/brand.ts
Extend MedusaService to get the generated CRUD methods.
src/modules/brand/service.ts
Add a custom method only when the logic is about this module’s own data and can’t be expressed with the generated methods, such as a specialised query. When you do, use Medusa’s DI decorators so the method runs in the ambient context.
src/modules/brand/service.ts
Some logic 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

Follow four conventions so the module reads like the built-in ones.
  • Register the module by a stable id constant. Export the module id and register the service against it:
    src/modules/brand/index.ts
    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 such as 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, and 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 such as BrandDTO and import them. Never redeclare a model’s shape ad hoc. See Types & augmentation.
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. This is covered in full on Module links.
Relationship declared as a link, not inside the model
The model itself stays flat: plain columns, no relations pointing at other modules.
src/modules/brand/models/brand.ts

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 pattern for a custom write
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 }) and 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 plus @MedusaContext.
  • DTOs are exported and imported, never redeclared inline.
  • No orchestration, no events, no HTTP. Those live in workflows and routes.

Next steps

Workflows

Orchestrate business operations across modules, with compensation on failure.

Module links

Relate two modules with defineLink and read the relationship through Query.

Types & augmentation

Export DTOs and share a model’s shape instead of redeclaring it inline.

Best practices overview

See the layered architecture and the logic-placement cheat sheet.