@mercurjs/docs 2.2.0-canary.50 → 2.2.0-canary.53

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,286 @@
1
+ ---
2
+ title: "Frontend"
3
+ description: "Build panel UI the right way — @medusajs/ui usage, custom-field extensions (forms, tables, read-only section fields), and new pages, with the correct imports."
4
+ ---
5
+
6
+ The Admin and Vendor panels share one design system. The three things you'll actually do — style with `@medusajs/ui`, extend built-in screens with **custom fields**, and add **new pages** — all have an established shape and, importantly, a set of **correct imports**. This page is that short list. The full reference is the [UI architecture](/rc/references/panel-extension-api).
7
+
8
+ ## Use `@medusajs/ui` — and only it
9
+
10
+ <Warning>
11
+ Never introduce a second UI library, and never restyle Medusa UI components with custom CSS. Build on the primitives; don't work around them.
12
+ </Warning>
13
+
14
+ - **Components** come from `@medusajs/ui`, **icons** from `@medusajs/icons`, and colours/spacing/type from Medusa UI **tokens** (`text-ui-fg-*`, `bg-ui-bg-*`, `border-ui-border-*`) — never hex, `rgb()`, or `text-gray-500`.
15
+
16
+ ```tsx
17
+ import { Container, Heading, Text, Button, Badge, StatusBadge, toast } from "@medusajs/ui"
18
+ import { PencilSquare, Trash, EllipsisHorizontal } from "@medusajs/icons"
19
+ ```
20
+
21
+ - A **section** is a `Container` with the standard shell — a divided card with a header row:
22
+
23
+ ```tsx
24
+ <Container className="divide-y p-0">
25
+ <div className="flex items-center justify-between px-6 py-4">
26
+ <Heading level="h2">Details</Heading>
27
+ <Button size="small" variant="secondary">Edit</Button>
28
+ </div>
29
+ <div className="px-6 py-4">
30
+ <Text size="small" className="text-ui-fg-subtle">Body</Text>
31
+ </div>
32
+ </Container>
33
+ ```
34
+
35
+ ## Extend built-in screens with custom fields
36
+
37
+ The primary way to customise an existing entity's screens (product, order, customer…) is a **custom-fields config** — one file per model that contributes form fields, table columns, and read-only section fields. See [Custom fields](/rc/resources/best-practices/custom-fields) for the full backend↔frontend loop; here's the frontend surface with the right imports.
38
+
39
+ <Note>
40
+ The two imports you need — and where each lives:
41
+
42
+ ```tsx
43
+ import { defineCustomFieldsConfig } from "@mercurjs/dashboard-sdk" // the config helper
44
+ import { createFormHelper } from "@mercurjs/dashboard-shared" // typed form fields (zod)
45
+ ```
46
+
47
+ `defineCustomFieldsConfig` is build-time config (SDK, zod-free); `createFormHelper` is the runtime form surface (dashboard-shared). Don't cross them over.
48
+ </Note>
49
+
50
+ ### Add form fields (edit / create)
51
+
52
+ Contribute inputs into a built-in form `zone`. Values submit under `additional_data`:
53
+
54
+ ```tsx title="apps/vendor/src/custom-fields/product.tsx"
55
+ import { defineCustomFieldsConfig } from "@mercurjs/dashboard-sdk"
56
+ import { createFormHelper } from "@mercurjs/dashboard-shared"
57
+
58
+ const form = createFormHelper<{ custom_fields?: { is_featured?: boolean } }>()
59
+
60
+ export default defineCustomFieldsConfig({
61
+ model: "product",
62
+ link: "custom_fields",
63
+ forms: [
64
+ {
65
+ zone: "edit",
66
+ fields: {
67
+ is_featured: form.define({
68
+ validation: form.boolean().optional(),
69
+ label: "Featured",
70
+ defaultValue: (data) => Boolean(data?.custom_fields?.is_featured),
71
+ }),
72
+ },
73
+ },
74
+ ],
75
+ })
76
+ ```
77
+
78
+ ### Change the list table
79
+
80
+ Add or override a column (and add bulk actions) on the model's built-in list:
81
+
82
+ ```tsx
83
+ list: {
84
+ columns: [
85
+ { id: "is_featured", header: "Featured", component: ({ row }) => (row.custom_fields?.is_featured ? "★" : "") },
86
+ ],
87
+ },
88
+ ```
89
+
90
+ ### Read-only fields in detail sections — e.g. surface (or change) a status
91
+
92
+ `displays` add read-only rows into an existing detail-page section, keyed by `id` (unknown id **adds**, built-in id **replaces**, `component: null` **hides**). A read-only field can render a `StatusBadge`, and a section `action` can trigger a status change through a mutation:
93
+
94
+ ```tsx title="apps/vendor/src/custom-fields/product.tsx"
95
+ import { StatusBadge, Button, toast } from "@medusajs/ui"
96
+
97
+ // inside defineCustomFieldsConfig(...)
98
+ displays: [
99
+ {
100
+ zone: "general",
101
+ fields: [
102
+ {
103
+ id: "review_status",
104
+ component: ({ data }) => (
105
+ <div className="flex items-center justify-between px-6 py-4">
106
+ <StatusBadge color={data.custom_fields?.approved ? "green" : "orange"}>
107
+ {data.custom_fields?.approved ? "Approved" : "Pending"}
108
+ </StatusBadge>
109
+ <Button
110
+ size="small"
111
+ variant="secondary"
112
+ onClick={async () => {
113
+ await sdk.vendor.products.$id.mutate({
114
+ $id: data.id,
115
+ additional_data: { approved: true },
116
+ })
117
+ toast.success("Approved")
118
+ }}
119
+ >
120
+ Approve
121
+ </Button>
122
+ </div>
123
+ ),
124
+ },
125
+ ],
126
+ },
127
+ ],
128
+ ```
129
+
130
+ <Tip>
131
+ Read-only displays are the idiomatic way to expose (and act on) an entity's state — an approval flag, a moderation status, an internal tag — without rebuilding the detail page. The mutation still goes through the typed SDK and rides `additional_data` into a [workflow hook](/rc/resources/best-practices/custom-fields#the-full-override-flow-additional_data--route--workflow-hook), never a direct write.
132
+ </Tip>
133
+
134
+ ## Add a new page
135
+
136
+ A brand-new screen is one file: drop a `page.tsx` under the host app's `src/routes/`. The SDK registers the route from the file path and builds the sidebar entry from an exported `config`.
137
+
138
+ ```tsx title="apps/vendor/src/routes/reviews/page.tsx"
139
+ import { Container, Heading } from "@medusajs/ui"
140
+ import { Star } from "@medusajs/icons"
141
+ import type { RouteConfig } from "@mercurjs/dashboard-sdk"
142
+
143
+ export const config: RouteConfig = {
144
+ label: "Reviews",
145
+ icon: Star,
146
+ }
147
+
148
+ export default function ReviewsPage() {
149
+ return (
150
+ <Container className="divide-y p-0">
151
+ <div className="px-6 py-4">
152
+ <Heading>Reviews</Heading>
153
+ </div>
154
+ </Container>
155
+ )
156
+ }
157
+ ```
158
+
159
+ <Note>
160
+ Correct imports for a page: UI from `@medusajs/ui`, icons from `@medusajs/icons`, and the `RouteConfig` **type** from `@mercurjs/dashboard-sdk`. Dynamic segments use brackets — `src/routes/reviews/[id]/page.tsx` → `/reviews/:id`. See [Extending panels](/rc/resources/customization/extending-panels#routing-conventions).
161
+ </Note>
162
+
163
+ ## Compose a full page: layout, table, sections, edit
164
+
165
+ For a real screen you assemble the same primitives the built-in pages use — all re-exported from `@mercurjs/dashboard-shared`, so you import from **one** place instead of Medusa internals.
166
+
167
+ ```tsx
168
+ import {
169
+ SingleColumnPage,
170
+ TwoColumnPage,
171
+ DataTable,
172
+ useDataTable,
173
+ SectionRow,
174
+ RouteDrawer,
175
+ Form,
176
+ ActionMenu,
177
+ } from "@mercurjs/dashboard-shared"
178
+ import { Container, Heading, Text, Button, Input, toast } from "@medusajs/ui"
179
+ import { createColumnHelper } from "@tanstack/react-table"
180
+ ```
181
+
182
+ ### Layout + list table
183
+
184
+ Pick a layout — `SingleColumnPage` for lists/simple pages, `TwoColumnPage` for a detail with a sidebar — and mount a `DataTable` inside the standard section shell. Build columns with `createColumnHelper`, wire the table with `useDataTable`, page size 20, and `keepPreviousData` for smooth pagination:
185
+
186
+ ```tsx title="apps/vendor/src/routes/reviews/page.tsx"
187
+ const columnHelper = createColumnHelper<Review>()
188
+
189
+ const columns = [
190
+ columnHelper.accessor("title", { header: "Title" }),
191
+ columnHelper.accessor("rating", { header: "Rating" }),
192
+ columnHelper.display({
193
+ id: "actions",
194
+ cell: ({ row }) => (
195
+ <ActionMenu groups={[{ actions: [{ label: "Edit", to: `${row.original.id}/edit` }] }]} />
196
+ ),
197
+ }),
198
+ ]
199
+
200
+ export default function ReviewsPage() {
201
+ const { reviews = [], count = 0, isLoading } = useReviews()
202
+ const { table } = useDataTable({ data: reviews, columns, count, pageSize: 20, getRowId: (r) => r.id })
203
+
204
+ return (
205
+ <SingleColumnPage>
206
+ <Container className="divide-y p-0">
207
+ <div className="flex items-center justify-between px-6 py-4">
208
+ <Heading>Reviews</Heading>
209
+ </div>
210
+ <DataTable table={table} columns={columns} count={count} pageSize={20} isLoading={isLoading} navigateTo={(row) => row.id} pagination search />
211
+ </Container>
212
+ </SingleColumnPage>
213
+ )
214
+ }
215
+ ```
216
+
217
+ ### General section (label / value rows)
218
+
219
+ On a detail page, a "general" section is a `Container` header row plus `SectionRow` label/value pairs — the canonical way Medusa renders read-only entity data:
220
+
221
+ ```tsx
222
+ <Container className="divide-y p-0">
223
+ <div className="flex items-center justify-between px-6 py-4">
224
+ <Heading>{review.title}</Heading>
225
+ <ActionMenu groups={[{ actions: [{ label: "Edit", to: "edit" }] }]} />
226
+ </div>
227
+ <SectionRow title="Rating" value={`${review.rating} / 5`} />
228
+ <SectionRow title="Status" value={review.approved ? "Approved" : "Pending"} />
229
+ </Container>
230
+ ```
231
+
232
+ For a detail page with a sidebar, wrap sections in `TwoColumnPage` and place them under `TwoColumnPage.Main` / `TwoColumnPage.Sidebar` (each stacked with `gap-y-3`).
233
+
234
+ ### Edit page (drawer)
235
+
236
+ Quick edits live in a routed `RouteDrawer` with `Form` (React Hook Form + Zod). Gate the form until the entity has loaded, and use `useRouteModal().handleSuccess()` to close on save:
237
+
238
+ ```tsx title="apps/vendor/src/routes/reviews/[id]/edit/page.tsx"
239
+ export default function EditReviewPage() {
240
+ return (
241
+ <RouteDrawer>
242
+ <RouteDrawer.Header>
243
+ <RouteDrawer.Title asChild>
244
+ <Heading>Edit review</Heading>
245
+ </RouteDrawer.Title>
246
+ </RouteDrawer.Header>
247
+ {/* <EditReviewForm /> — RouteDrawer.Form + KeyboundForm, gated on !isPending && !!review */}
248
+ </RouteDrawer>
249
+ )
250
+ }
251
+ ```
252
+
253
+ <Warning>
254
+ Import these primitives from `@mercurjs/dashboard-shared`, **not** from deep Medusa dashboard paths like `../../../components/table/data-table`. The shared package is the public, stable surface; relative Medusa-internal imports are not available to consumer apps and break on upgrade.
255
+ </Warning>
256
+
257
+ ## Data only through the typed SDK
258
+
259
+ <Warning>
260
+ Never call `fetch` directly from a page. All HTTP goes through the typed SDK — `sdk.admin.*` in the admin panel, `sdk.vendor.*` in the vendor panel — wrapped in TanStack Query hooks.
261
+ </Warning>
262
+
263
+ ```ts title="src/hooks/api/reviews.tsx"
264
+ import { useQuery } from "@tanstack/react-query"
265
+ import { sdk } from "../../lib/client"
266
+ import { queryKeysFactory } from "@mercurjs/dashboard-shared"
267
+
268
+ const reviewKeys = queryKeysFactory("reviews")
269
+
270
+ export const useReviews = (query?: Record<string, unknown>) =>
271
+ useQuery({
272
+ queryKey: reviewKeys.list(query),
273
+ queryFn: () => sdk.vendor.reviews.query({ ...query }),
274
+ })
275
+ ```
276
+
277
+ Invalidate `lists()` / `details()` / `detail(id)` in mutations, throw on `isError` so the route `ErrorBoundary` catches it, and show a `Skeleton` while loading.
278
+
279
+ ## Checklist for panel work
280
+
281
+ - Built only from `@medusajs/ui` + `@medusajs/icons`; Medusa UI tokens only, no custom CSS.
282
+ - Extending an existing screen → a `defineCustomFieldsConfig` file (`@mercurjs/dashboard-sdk`) with `createFormHelper` (`@mercurjs/dashboard-shared`); forms submit under `additional_data`.
283
+ - Read-only state (status/flags) surfaced via `displays`; changes go through the typed SDK + a workflow hook, not a direct write.
284
+ - New screen → a `page.tsx` under `src/routes/` with a typed `RouteConfig`; compose it from `SingleColumnPage`/`TwoColumnPage`, `DataTable`, `SectionRow`, and `RouteDrawer` — all imported from `@mercurjs/dashboard-shared`, never Medusa-internal paths.
285
+ - Data via `sdk.admin.*` / `sdk.vendor.*` in TanStack Query hooks; no raw `fetch`; mutations invalidate the right keys.
286
+ - Every visible string translated; every interactive element has a `data-testid`.
@@ -0,0 +1,157 @@
1
+ ---
2
+ title: "Module links"
3
+ description: "Relate modules without coupling them — defineLink, the link-direction rule, built-in link steps, and filtering by links."
4
+ ---
5
+
6
+ Modules are isolated: a module never imports another module's service or points a foreign key at another module's table (see [Modules](/rc/resources/best-practices/modules)). Relationships between modules are declared **outside** the modules, as **links**, and read through **Query**. This is what keeps each module independently migratable and upgrade-safe.
7
+
8
+ <Note>
9
+ Links are a [Medusa framework primitive](https://docs.medusajs.com/learn/fundamentals/module-links). The examples below link a custom **Brand** module to Medusa's built-in **Product** module — the kind of relationship you'd add in your own project.
10
+ </Note>
11
+
12
+ ## `defineLink`
13
+
14
+ A link is a small file that associates two linkable data models. Define it once and sync it to the database with a migration.
15
+
16
+ ```ts title="src/links/product-brand.ts"
17
+ import { defineLink } from "@medusajs/framework/utils"
18
+ import ProductModule from "@medusajs/medusa/product"
19
+ import BrandModule from "../modules/brand"
20
+
21
+ export default defineLink(
22
+ ProductModule.linkable.product,
23
+ BrandModule.linkable.brand
24
+ )
25
+ ```
26
+
27
+ After adding or changing a link, generate and run the migration so the link table exists:
28
+
29
+ ```bash
30
+ npx medusa db:migrate
31
+ ```
32
+
33
+ Once linked, you read across the boundary with Query — never by calling the other module's service:
34
+
35
+ ```ts
36
+ const { data: products } = await query.graph({
37
+ entity: "product",
38
+ fields: ["id", "title", "brand.*"], // follows the product ↔ brand link
39
+ })
40
+ ```
41
+
42
+ ## The link-direction rule
43
+
44
+ The **order of arguments to `defineLink` is meaningful** and cardinality is controlled with `isList`. Read it left-to-right as "the left model links to the right model".
45
+
46
+ - `defineLink(A.linkable.a, B.linkable.b)` — one `a` ↔ one `b`.
47
+ - Wrap a side in `{ linkable, isList: true }` to make it the "many" side.
48
+
49
+ If one brand has many products but each product belongs to a single brand, mark the **product** side as the list:
50
+
51
+ ```ts title="src/links/product-brand.ts — one brand, many products"
52
+ import { defineLink } from "@medusajs/framework/utils"
53
+ import ProductModule from "@medusajs/medusa/product"
54
+ import BrandModule from "../modules/brand"
55
+
56
+ export default defineLink(
57
+ {
58
+ linkable: ProductModule.linkable.product,
59
+ isList: true,
60
+ },
61
+ BrandModule.linkable.brand
62
+ )
63
+ ```
64
+
65
+ For a many-to-many relationship (a product can carry many brands *and* a brand spans many products) mark both sides as lists and pin an explicit table name:
66
+
67
+ ```ts title="src/links/product-brand.ts — many-to-many"
68
+ export default defineLink(
69
+ { linkable: ProductModule.linkable.product, isList: true },
70
+ { linkable: BrandModule.linkable.brand, isList: true },
71
+ {
72
+ database: {
73
+ table: "product_brand",
74
+ },
75
+ }
76
+ )
77
+ ```
78
+
79
+ <Warning>
80
+ Direction determines the generated relation names and the shape of the link table. Getting it backwards produces a link that "works" but exposes the wrong nesting (`brand.products` vs `product.brands`) and is painful to migrate away from. Decide the natural reading direction first, then set `isList` on the many side(s).
81
+ </Warning>
82
+
83
+ ## Built-in link steps — create links inside workflows
84
+
85
+ Links are **data**, so creating or removing one is a mutation and must happen inside a [workflow](/rc/resources/best-practices/workflows) through the built-in link steps — never by writing to the link table directly.
86
+
87
+ - `createRemoteLinkStep` — create links (and it compensates by removing them on failure).
88
+ - `dismissRemoteLinkStep` — remove links.
89
+
90
+ Build the link definitions with `transform` (never inline logic in the composition function), then pass them to the step. Each entry names the two modules and the ids to associate:
91
+
92
+ ```ts title="Linking a product to a brand inside a workflow"
93
+ import { createRemoteLinkStep } from "@medusajs/medusa/core-flows"
94
+ import { Modules } from "@medusajs/framework/utils"
95
+ import { LinkDefinition } from "@medusajs/framework/types"
96
+ import { BRAND_MODULE } from "../modules/brand"
97
+
98
+ const productBrandLinks = transform(
99
+ { productId, brandId },
100
+ ({ productId, brandId }): LinkDefinition[] => [
101
+ {
102
+ [Modules.PRODUCT]: { product_id: productId },
103
+ [BRAND_MODULE]: { brand_id: brandId },
104
+ },
105
+ ]
106
+ )
107
+
108
+ createRemoteLinkStep(productBrandLinks)
109
+ ```
110
+
111
+ <Tip>
112
+ Because `createRemoteLinkStep` already knows how to compensate, links created this way are torn down automatically if a later step in the workflow throws. This is the whole reason to link inside a workflow rather than in a route.
113
+ </Tip>
114
+
115
+ ## Reading vs filtering across a link
116
+
117
+ This is the distinction that trips people up:
118
+
119
+ - **Reading** linked data — fetching `brand.*` alongside a product — works with `query.graph`. Query aggregates the two modules' data to build the result.
120
+ - **Filtering** by a linked module's field — "give me products *where* `brand.id = X`" — does **not** work with `query.graph`.
121
+
122
+ <Warning>
123
+ `query.graph` **cannot filter by a linked (cross-module) field.** Because modules are isolated and Query aggregates their data after the fact, there's no join to filter on. Passing `filters: { brand: { id } }` to `query.graph` will not scope products by brand.
124
+ </Warning>
125
+
126
+ You can still filter by a field that lives on the entity's **own** module (a plain column like `product.status` or `offer.seller_id`) — that's a normal `query.graph` filter. It's only *linked-module* fields that need a different tool.
127
+
128
+ ### Filtering by a linked field — the Index Module
129
+
130
+ Cross-module filtering is what the [Index Module](https://docs.medusajs.com/learn/fundamentals/module-links/index-module) (`@medusajs/index`) exists for. It ingests data models into a single relational store on startup, so you can filter one entity by another's fields. Install it, make sure both models are ingested, and query with `query.index` instead of `query.graph`:
131
+
132
+ ```ts title="Filter products by their linked brand — query.index, not query.graph"
133
+ const { data: products, metadata } = await query.index({
134
+ entity: "product",
135
+ fields: ["id", "title", "brand.name"],
136
+ filters: {
137
+ brand: {
138
+ id: brandId, // ✅ cross-module filter — resolved by the Index Module
139
+ },
140
+ },
141
+ })
142
+ ```
143
+
144
+ <Note>
145
+ By default Medusa ingests only `Product`, `ProductVariant`, `Price`, `PriceSet`, and `SalesChannel`. To filter products by a **custom** module like Brand, you must [ingest that model](https://docs.medusajs.com/learn/fundamentals/module-links/index-module#how-to-ingest-custom-data-models) into the Index Module first. The Index Module is still marked experimental, though it powers filtering in the Medusa Admin.
146
+ </Note>
147
+
148
+ `query.index` takes the same shape as `query.graph` (entity, fields, filters, pagination), so a route handler can forward `req.filterableFields` to it exactly the same way — the only change is `graph` → `index`.
149
+
150
+ ## Checklist for a link
151
+
152
+ - Declared in its own file under `src/links/`, using `defineLink`.
153
+ - Argument order reflects the natural reading direction; `isList` set on the many side(s).
154
+ - Migration generated and run (`medusa db:migrate`).
155
+ - Cross-module **reads** go through `query.graph`, never a service-to-service call.
156
+ - Cross-module **filters** go through `query.index` (Index Module, with the model ingested) — `query.graph` can't filter by a linked field.
157
+ - Links are created/removed only inside workflows via `createRemoteLinkStep` / `dismissRemoteLinkStep`.
@@ -0,0 +1,144 @@
1
+ ---
2
+ title: "Modules"
3
+ description: "Keep modules thin — data access and CRUD only. Naming, decorators, and what must never live in a module service."
4
+ ---
5
+
6
+ A module is the lowest layer of the [architecture](/rc/resources/best-practices/overview): 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](/rc/resources/best-practices/workflows).
7
+
8
+ <Note>
9
+ A Mercur module is a standard [Medusa module](https://docs.medusajs.com/learn/fundamentals/modules). 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.
10
+ </Note>
11
+
12
+ ## Thin CRUD only
13
+
14
+ 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.
15
+
16
+ ```ts title="src/modules/brand/models/brand.ts"
17
+ import { model } from "@medusajs/framework/utils"
18
+
19
+ export const Brand = model.define("brand", {
20
+ id: model.id().primaryKey(),
21
+ name: model.text(),
22
+ })
23
+ ```
24
+
25
+ ```ts title="src/modules/brand/service.ts"
26
+ import { MedusaService } from "@medusajs/framework/utils"
27
+ import { Brand } from "./models/brand"
28
+
29
+ class BrandModuleService extends MedusaService({
30
+ Brand,
31
+ }) {
32
+ // Generated for you: listBrands, retrieveBrand, createBrands,
33
+ // updateBrands, deleteBrands, listAndCountBrands, ...
34
+ }
35
+
36
+ export default BrandModuleService
37
+ ```
38
+
39
+ 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:
40
+
41
+ ```ts title="A justified custom method — still single-module"
42
+ class BrandModuleService extends MedusaService({ Brand }) {
43
+ @InjectManager()
44
+ async listActiveBrands(
45
+ filters: FindConfig<BrandDTO> = {},
46
+ @MedusaContext() sharedContext: Context = {}
47
+ ): Promise<BrandDTO[]> {
48
+ return this.listBrands({ ...filters, is_active: true }, {}, sharedContext)
49
+ }
50
+ }
51
+ ```
52
+
53
+ <Warning>
54
+ **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](/rc/resources/best-practices/workflows), not here. See the [logic-placement cheat sheet](/rc/resources/best-practices/overview#logic-placement-cheat-sheet).
55
+ </Warning>
56
+
57
+ ## Naming
58
+
59
+ - **Register the module by a stable id constant.** Export the module id and register the service against it:
60
+
61
+ ```ts title="src/modules/brand/index.ts"
62
+ import { Module } from "@medusajs/framework/utils"
63
+ import BrandModuleService from "./service"
64
+
65
+ export const BRAND_MODULE = "brand"
66
+
67
+ export default Module(BRAND_MODULE, {
68
+ service: BrandModuleService,
69
+ })
70
+ ```
71
+
72
+ <Tip>
73
+ 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.
74
+ </Tip>
75
+
76
+ - **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_`).
77
+ - **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.
78
+ - **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](/rc/resources/best-practices/types).
79
+
80
+ ## Do not call `.linkable()` — links are declared separately
81
+
82
+ 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.
83
+
84
+ <Warning>
85
+ 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.
86
+ </Warning>
87
+
88
+ Define the relationship as its own link file instead — covered in full on [Module links](/rc/resources/best-practices/module-links):
89
+
90
+ ```ts title="Relationship declared as a link, not inside the model"
91
+ import { defineLink } from "@medusajs/framework/utils"
92
+ import ProductModule from "@medusajs/medusa/product"
93
+ import BrandModule from "../modules/brand"
94
+
95
+ export default defineLink(
96
+ ProductModule.linkable.product,
97
+ BrandModule.linkable.brand
98
+ )
99
+ ```
100
+
101
+ The model itself stays flat — plain columns, no relations pointing at other modules:
102
+
103
+ ```ts title="src/modules/brand/models/brand.ts"
104
+ export const Brand = model.define("brand", {
105
+ id: model.id().primaryKey(),
106
+ name: model.text(),
107
+ is_active: model.boolean().default(true),
108
+ metadata: model.json().nullable(),
109
+ })
110
+ ```
111
+
112
+ ## Decorators
113
+
114
+ Custom service methods that touch the database use Medusa's dependency-injection decorators so they participate in the ambient transaction and shared context:
115
+
116
+ | Decorator | Use it on | Purpose |
117
+ | --- | --- | --- |
118
+ | `@InjectManager()` | Read methods | Injects the entity manager so the method runs in the current context. |
119
+ | `@InjectTransactionManager()` | Write methods | Runs the method inside a transaction, enabling rollback. |
120
+ | `@MedusaContext()` | The trailing `sharedContext` parameter | Threads the request/transaction context through the call. |
121
+
122
+ ```ts title="Decorator pattern for a custom write"
123
+ @InjectTransactionManager()
124
+ async deactivateBrand(
125
+ id: string,
126
+ @MedusaContext() sharedContext: Context = {}
127
+ ): Promise<BrandDTO> {
128
+ return this.updateBrands({ id, is_active: false }, sharedContext)
129
+ }
130
+ ```
131
+
132
+ <Tip>
133
+ 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.
134
+ </Tip>
135
+
136
+ ## Checklist for a module
137
+
138
+ - Extends `MedusaService({ ...models })`; leans on generated CRUD.
139
+ - Registered with `Module(BRAND_MODULE, { service })` against a stable id.
140
+ - No import of, or call into, any other module's service.
141
+ - Models are flat — no `.linkable()` wiring, no cross-module foreign keys.
142
+ - Custom methods use `@InjectManager` / `@InjectTransactionManager` + `@MedusaContext`.
143
+ - DTOs are exported and imported, never redeclared inline.
144
+ - No orchestration, no events, no HTTP — those live in workflows and routes.