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 — 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.
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:
DecoratorUse it onPurpose
@InjectManager()Read methodsInjects the entity manager so the method runs in the current context.
@InjectTransactionManager()Write methodsRuns the method inside a transaction, enabling rollback.
@MedusaContext()The trailing sharedContext parameterThreads 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.