Medusa Development
Medusa is a headless commerce framework built around modules, data models, and workflows; almost every piece of business logic — from an API route to a scheduled job — should be expressed as a workflow made of discrete, composable steps.
Workflow for Building a Medusa Feature
- Define or extend the data model — Use the
model utility from @medusajs/framework/utils to declare the module's data model(s) under src/modules/<module>/models/.
- Write the module service — Create a service in
src/modules/<module>/service.ts that extends MedusaService when the module has data models, exposing async methods for domain operations.
- Register the module — Add the module to
medusa-config.ts so Medusa's container can resolve it.
- Build steps — Define each unit of work as a step with
createStep from @medusajs/framework/workflows-sdk, including a compensation function for anything that needs to be undone on failure.
- Compose the workflow — Wire steps together with
createWorkflow, using transform for data shaping and when for conditional branches.
- Expose the workflow — Call the workflow from an API route, a scheduled job, or a subscriber — never put business logic directly in the route/job/subscriber handler.
- Read data with Query — Use Medusa's Query (
req.scope.resolve("query") or the workflow-level useQueryGraphStep) to fetch data instead of calling module services directly for reads.
General Rules
- Don't use type aliases when importing files — import types and values directly from their source module rather than re-exporting through a local alias.
- When throwing errors, always throw
MedusaError (from @medusajs/framework/utils) instead of a plain Error, so the API layer can map it to the correct HTTP status and error code.
- Always use Query to retrieve data rather than calling a module's service methods directly for reads — Query understands module links and can join data across modules in one call.
import { MedusaError } from "@medusajs/framework/utils"
if (!product) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Product with id "${productId}" was not found`
)
}
Data Model Rules
- Use the
model utility from @medusajs/framework/utils to define data models.
- Data model variables should be camelCase; the name passed to
model.define should be snake_case.
- When adding an
id field to a data model, always make it a primary key with .primaryKey().
- A data model can have only one
id field — any other identifier should be a text field instead.
- Data model fields should be snake_case.
// src/modules/loyalty/models/loyalty-account.ts
import { model } from "@medusajs/framework/utils"
const LoyaltyAccount = model.define("loyalty_account", {
id: model.id().primaryKey(),
customer_id: model.text(),
points_balance: model.number().default(0),
tier: model.enum(["bronze", "silver", "gold"]).default("bronze"),
})
export default LoyaltyAccount
Service Rules
- When creating a service, always make its methods async.
- If a module has data models, make the service extend
MedusaService so it inherits generated CRUD methods for each model.
// src/modules/loyalty/service.ts
import { MedusaService } from "@medusajs/framework/utils"
import LoyaltyAccount from "./models/loyalty-account"
class LoyaltyModuleService extends MedusaService({
LoyaltyAccount,
}) {
async addPoints(accountId: string, points: number) {
const account = await this.retrieveLoyaltyAccount(accountId)
return await this.updateLoyaltyAccounts({
id: account.id,
points_balance: account.points_balance + points,
})
}
}
export default LoyaltyModuleService
Workflow Rules
- When creating a workflow or step, always use Medusa's Workflow SDK (
@medusajs/framework/workflows-sdk) to define it.
- When creating a feature in an API route, scheduled job, or subscriber, always create a workflow for it rather than inlining the logic in the handler.
- When creating a workflow, always create a step for each discrete unit of work in it.
- In workflows, use
transform for any data transformation between steps — don't manipulate step output directly in the workflow function body.
- In workflows, use
when to define conditional branches instead of a plain if around step calls.
- Don't use
await when calling steps inside a workflow — step invocation returns a special reference the workflow engine resolves, and await-ing it breaks the workflow's ability to orchestrate compensation and retries.
- In workflows, don't make the workflow function itself
async — the function body only describes the step graph, it doesn't execute imperatively.
- Don't add typing to a compensation function's input — the compensation function receives whatever the step's invoke function returned, and Medusa infers this automatically.
- Only use steps in a workflow — don't call services, Query, or other side-effecting code directly inside the workflow function; put that logic in a step.
// src/workflows/redeem-loyalty-points.ts
import {
createStep,
createWorkflow,
StepResponse,
transform,
when,
WorkflowResponse,
} from "@medusajs/framework/workflows-sdk"
import { MedusaError } from "@medusajs/framework/utils"
import { LOYALTY_MODULE } from "../modules/loyalty"
import LoyaltyModuleService from "../modules/loyalty/service"
type RedeemPointsInput = {
accountId: string
points: number
}
const deductPointsStep = createStep(
"deduct-points-step",
async (input: RedeemPointsInput, { container }) => {
const loyaltyService: LoyaltyModuleService = container.resolve(LOYALTY_MODULE)
const account = await loyaltyService.retrieveLoyaltyAccount(input.accountId)
if (account.points_balance < input.points) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Insufficient points balance"
)
}
const previousBalance = account.points_balance
const updated = await loyaltyService.updateLoyaltyAccounts({
id: account.id,
points_balance: previousBalance - input.points,
})
return new StepResponse(updated, { accountId: account.id, previousBalance })
},
async (compensationInput, { container }) => {
if (!compensationInput) return
const loyaltyService: LoyaltyModuleService = container.resolve(LOYALTY_MODULE)
await loyaltyService.updateLoyaltyAccounts({
id: compensationInput.accountId,
points_balance: compensationInput.previousBalance,
})
}
)
export const redeemLoyaltyPointsWorkflow = createWorkflow(
"redeem-loyalty-points",
(input: RedeemPointsInput) => {
const account = deductPointsStep(input)
const tierDowngrade = when(account, (acc) => acc.points_balance === 0)
.then(() => {
return transform({ account }, (data) => ({
...data.account,
tier: "bronze" as const,
}))
})
return new WorkflowResponse(account)
}
)
API Routes and Reading Data
- Expose workflows through API routes under
src/api/; the route handler should validate input, call the workflow, and shape the HTTP response — nothing more.
- Always use Query to retrieve data for reads (list/detail endpoints) instead of resolving a module service directly.
// src/api/store/loyalty/[id]/route.ts
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
export async function GET(req: MedusaRequest, res: MedusaResponse) {
const query = req.scope.resolve("query")
const { data: accounts } = await query.graph({
entity: "loyalty_account",
fields: ["id", "points_balance", "tier"],
filters: { id: req.params.id },
})
res.json({ loyalty_account: accounts[0] })
}
export async function POST(req: MedusaRequest, res: MedusaResponse) {
const { result } = await redeemLoyaltyPointsWorkflow(req.scope).run({
input: req.validatedBody as { accountId: string; points: number },
})
res.json({ loyalty_account: result })
}
Subscribers and Scheduled Jobs
- Subscribers and scheduled jobs should call a workflow, exactly like API routes — they are just a different trigger for the same business logic.
// src/subscribers/order-placed.ts
import type { SubscriberArgs, SubscriberConfig } from "@medusajs/framework"
import { redeemLoyaltyPointsWorkflow } from "../workflows/redeem-loyalty-points"
export default async function orderPlacedHandler({ event, container }: SubscriberArgs<{ id: string }>) {
await redeemLoyaltyPointsWorkflow(container).run({
input: { accountId: event.data.id, points: 0 },
})
}
export const config: SubscriberConfig = { event: "order.placed" }
Admin Customization Rules
- When sending requests from admin customizations (widgets, custom pages), always use Medusa's JS SDK (
@medusajs/js-sdk) rather than raw fetch.
- Use TailwindCSS for styling admin customizations, matching the conventions of Medusa Admin's own UI.
// src/admin/widgets/loyalty-widget.tsx
import { defineWidgetConfig } from "@medusajs/admin-sdk"
import { useQuery } from "@tanstack/react-query"
import { sdk } from "../lib/sdk"
const LoyaltyWidget = ({ data }: { data: { id: string } }) => {
const { data: result } = useQuery({
queryFn: () => sdk.admin.customer.retrieve(data.id),
queryKey: ["customer", data.id],
})
return <p className="text-ui-fg-subtle">{result?.customer.email}</p>
}
export const config = defineWidgetConfig({ zone: "customer.details.after" })
export default LoyaltyWidget
Common Mistakes
- Calling a module service directly from an API route handler instead of going through a workflow — this skips retries, compensation, and the standard event/observability hooks workflows provide.
- Throwing a plain
Error instead of MedusaError, which loses the mapped HTTP status code and structured error type on the API response.
- Awaiting a step call inside a workflow function, which breaks the workflow engine's ability to build the step graph.
- Reading data by resolving a module service instead of using Query, which misses cross-module joins and links that Query resolves automatically.
- Naming a data model field or
model.define name in camelCase instead of snake_case, causing inconsistency with the rest of the schema.
Additional Resources
1---2name: medusa-development3description: Best practices for building commerce applications with Medusa v2, the headless e-commerce framework. Use when defining Medusa data models, writing workflows and steps with the Workflow SDK, creating API routes or subscribers, building module services that extend MedusaService, throwing MedusaError, or customizing the Medusa admin dashboard.4---5
6# Medusa Development
7
8Medusa is a headless commerce framework built around modules, data models, and workflows; almost every piece of business logic — from an API route to a scheduled job — should be expressed as a workflow made of discrete, composable steps.
9
10## Workflow for Building a Medusa Feature
11
121. **Define or extend the data model** — Use the `model` utility from `@medusajs/framework/utils` to declare the module's data model(s) under `src/modules/<module>/models/`.
132. **Write the module service** — Create a service in `src/modules/<module>/service.ts` that extends `MedusaService` when the module has data models, exposing async methods for domain operations.
143. **Register the module** — Add the module to `medusa-config.ts` so Medusa's container can resolve it.
154. **Build steps** — Define each unit of work as a step with `createStep` from `@medusajs/framework/workflows-sdk`, including a compensation function for anything that needs to be undone on failure.
165. **Compose the workflow** — Wire steps together with `createWorkflow`, using `transform` for data shaping and `when` for conditional branches.
176. **Expose the workflow** — Call the workflow from an API route, a scheduled job, or a subscriber — never put business logic directly in the route/job/subscriber handler.
187. **Read data with Query** — Use Medusa's Query (`req.scope.resolve("query")` or the workflow-level `useQueryGraphStep`) to fetch data instead of calling module services directly for reads.
19
20## General Rules
21
22- Don't use type aliases when importing files — import types and values directly from their source module rather than re-exporting through a local alias.
23- When throwing errors, always throw `MedusaError` (from `@medusajs/framework/utils`) instead of a plain `Error`, so the API layer can map it to the correct HTTP status and error code.
24- Always use Query to retrieve data rather than calling a module's service methods directly for reads — Query understands module links and can join data across modules in one call.
25
26```ts
27import { MedusaError } from "@medusajs/framework/utils"
28
29if (!product) {
30 throw new MedusaError(
31 MedusaError.Types.NOT_FOUND,
32 `Product with id "${productId}" was not found`
33 )
34}
35```
36
37## Data Model Rules
38
39- Use the `model` utility from `@medusajs/framework/utils` to define data models.
40- Data model variables should be camelCase; the name passed to `model.define` should be snake_case.
41- When adding an `id` field to a data model, always make it a primary key with `.primaryKey()`.
42- A data model can have only one `id` field — any other identifier should be a `text` field instead.
43- Data model fields should be snake_case.
44
45```ts
46// src/modules/loyalty/models/loyalty-account.ts
47import { model } from "@medusajs/framework/utils"
48
49const LoyaltyAccount = model.define("loyalty_account", {
50 id: model.id().primaryKey(),
51 customer_id: model.text(),
52 points_balance: model.number().default(0),
53 tier: model.enum(["bronze", "silver", "gold"]).default("bronze"),
54})
55
56export default LoyaltyAccount
57```
58
59## Service Rules
60
61- When creating a service, always make its methods async.
62- If a module has data models, make the service extend `MedusaService` so it inherits generated CRUD methods for each model.
63
64```ts
65// src/modules/loyalty/service.ts
66import { MedusaService } from "@medusajs/framework/utils"
67import LoyaltyAccount from "./models/loyalty-account"
68
69class LoyaltyModuleService extends MedusaService({
70 LoyaltyAccount,
71}) {
72 async addPoints(accountId: string, points: number) {
73 const account = await this.retrieveLoyaltyAccount(accountId)
74 return await this.updateLoyaltyAccounts({
75 id: account.id,
76 points_balance: account.points_balance + points,
77 })
78 }
79}
80
81export default LoyaltyModuleService
82```
83
84## Workflow Rules
85
86- When creating a workflow or step, always use Medusa's Workflow SDK (`@medusajs/framework/workflows-sdk`) to define it.
87- When creating a feature in an API route, scheduled job, or subscriber, always create a workflow for it rather than inlining the logic in the handler.
88- When creating a workflow, always create a step for each discrete unit of work in it.
89- In workflows, use `transform` for any data transformation between steps — don't manipulate step output directly in the workflow function body.
90- In workflows, use `when` to define conditional branches instead of a plain `if` around step calls.
91- Don't use `await` when calling steps inside a workflow — step invocation returns a special reference the workflow engine resolves, and `await`-ing it breaks the workflow's ability to orchestrate compensation and retries.
92- In workflows, don't make the workflow function itself `async` — the function body only describes the step graph, it doesn't execute imperatively.
93- Don't add typing to a compensation function's input — the compensation function receives whatever the step's invoke function returned, and Medusa infers this automatically.
94- Only use steps in a workflow — don't call services, Query, or other side-effecting code directly inside the workflow function; put that logic in a step.
95
96```ts
97// src/workflows/redeem-loyalty-points.ts
98import {
99 createStep,
100 createWorkflow,
101 StepResponse,
102 transform,
103 when,
104 WorkflowResponse,
105} from "@medusajs/framework/workflows-sdk"
106import { MedusaError } from "@medusajs/framework/utils"
107import { LOYALTY_MODULE } from "../modules/loyalty"
108import LoyaltyModuleService from "../modules/loyalty/service"
109
110type RedeemPointsInput = {
111 accountId: string
112 points: number
113}
114
115const deductPointsStep = createStep(
116 "deduct-points-step",
117 async (input: RedeemPointsInput, { container }) => {
118 const loyaltyService: LoyaltyModuleService = container.resolve(LOYALTY_MODULE)
119 const account = await loyaltyService.retrieveLoyaltyAccount(input.accountId)
120
121 if (account.points_balance < input.points) {
122 throw new MedusaError(
123 MedusaError.Types.INVALID_DATA,
124 "Insufficient points balance"
125 )
126 }
127
128 const previousBalance = account.points_balance
129 const updated = await loyaltyService.updateLoyaltyAccounts({
130 id: account.id,
131 points_balance: previousBalance - input.points,
132 })
133
134 return new StepResponse(updated, { accountId: account.id, previousBalance })
135 },
136 async (compensationInput, { container }) => {
137 if (!compensationInput) return
138 const loyaltyService: LoyaltyModuleService = container.resolve(LOYALTY_MODULE)
139 await loyaltyService.updateLoyaltyAccounts({
140 id: compensationInput.accountId,
141 points_balance: compensationInput.previousBalance,
142 })
143 }
144)
145
146export const redeemLoyaltyPointsWorkflow = createWorkflow(
147 "redeem-loyalty-points",
148 (input: RedeemPointsInput) => {
149 const account = deductPointsStep(input)
150
151 const tierDowngrade = when(account, (acc) => acc.points_balance === 0)
152 .then(() => {
153 return transform({ account }, (data) => ({
154 ...data.account,
155 tier: "bronze" as const,
156 }))
157 })
158
159 return new WorkflowResponse(account)
160 }
161)
162```
163
164## API Routes and Reading Data
165
166- Expose workflows through API routes under `src/api/`; the route handler should validate input, call the workflow, and shape the HTTP response — nothing more.
167- Always use Query to retrieve data for reads (list/detail endpoints) instead of resolving a module service directly.
168
169```ts
170// src/api/store/loyalty/[id]/route.ts
171import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
172
173export async function GET(req: MedusaRequest, res: MedusaResponse) {
174 const query = req.scope.resolve("query")
175
176 const { data: accounts } = await query.graph({
177 entity: "loyalty_account",
178 fields: ["id", "points_balance", "tier"],
179 filters: { id: req.params.id },
180 })
181
182 res.json({ loyalty_account: accounts[0] })
183}
184
185export async function POST(req: MedusaRequest, res: MedusaResponse) {
186 const { result } = await redeemLoyaltyPointsWorkflow(req.scope).run({
187 input: req.validatedBody as { accountId: string; points: number },
188 })
189
190 res.json({ loyalty_account: result })
191}
192```
193
194## Subscribers and Scheduled Jobs
195
196- Subscribers and scheduled jobs should call a workflow, exactly like API routes — they are just a different trigger for the same business logic.
197
198```ts
199// src/subscribers/order-placed.ts
200import type { SubscriberArgs, SubscriberConfig } from "@medusajs/framework"
201import { redeemLoyaltyPointsWorkflow } from "../workflows/redeem-loyalty-points"
202
203export default async function orderPlacedHandler({ event, container }: SubscriberArgs<{ id: string }>) {
204 await redeemLoyaltyPointsWorkflow(container).run({
205 input: { accountId: event.data.id, points: 0 },
206 })
207}
208
209export const config: SubscriberConfig = { event: "order.placed" }
210```
211
212## Admin Customization Rules
213
214- When sending requests from admin customizations (widgets, custom pages), always use Medusa's JS SDK (`@medusajs/js-sdk`) rather than raw `fetch`.
215- Use TailwindCSS for styling admin customizations, matching the conventions of Medusa Admin's own UI.
216
217```tsx
218// src/admin/widgets/loyalty-widget.tsx
219import { defineWidgetConfig } from "@medusajs/admin-sdk"
220import { useQuery } from "@tanstack/react-query"
221import { sdk } from "../lib/sdk"
222
223const LoyaltyWidget = ({ data }: { data: { id: string } }) => {
224 const { data: result } = useQuery({
225 queryFn: () => sdk.admin.customer.retrieve(data.id),
226 queryKey: ["customer", data.id],
227 })
228 return <p className="text-ui-fg-subtle">{result?.customer.email}</p>
229}
230
231export const config = defineWidgetConfig({ zone: "customer.details.after" })
232export default LoyaltyWidget
233```
234
235## Common Mistakes
236
237- Calling a module service directly from an API route handler instead of going through a workflow — this skips retries, compensation, and the standard event/observability hooks workflows provide.
238- Throwing a plain `Error` instead of `MedusaError`, which loses the mapped HTTP status code and structured error type on the API response.
239- Awaiting a step call inside a workflow function, which breaks the workflow engine's ability to build the step graph.
240- Reading data by resolving a module service instead of using Query, which misses cross-module joins and links that Query resolves automatically.
241- Naming a data model field or `model.define` name in camelCase instead of snake_case, causing inconsistency with the rest of the schema.
242
243## Additional Resources
244
245- Medusa Documentation: https://docs.medusajs.com/llms-full.txt