Every commerce operation worth anything touches several systems. Placing an order reserves inventory, charges a card, writes a record and notifies a warehouse. Four operations, four chances to fail, and no database transaction that spans them all — the card charge already happened at a company you do not control.
Workflows are Medusa's answer: a sequence of steps, each of which knows how to undo itself.
The problem, concretely
Without compensation, this is what a failure looks like:
- Reserve 3 units of inventory. ✅
- Charge the customer $240. ✅
- Create the order record. ❌ Database timeout.
You now have a customer charged $240, three units of stock reserved, and no order. Nobody knows. The customer emails in four days.
With a workflow, step three's failure triggers step two's compensation (refund) and step one's (release), in that order. The customer sees an error, the data is consistent, and you sleep.
A step
import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"
type Input = {
variant_id: string
quantity: number
location_id: string
}
export const reserveInventoryStep = createStep(
"reserve-inventory",
async ({ variant_id, quantity, location_id }: Input, { container }) => {
const inventory = container.resolve("inventory")
const reservation = await inventory.createReservationItems([
{ inventory_item_id: variant_id, location_id, quantity },
])
// First argument: what the step returns.
// Second: what the compensation function receives.
return new StepResponse(reservation[0], reservation[0].id)
},
// The compensation function.
async (reservationId: string, { container }) => {
if (!reservationId) return
const inventory = container.resolve("inventory")
await inventory.deleteReservationItems(reservationId)
}
)The shape to internalise: StepResponse(output, compensationInput). The second argument is the only thing the compensation function receives, so it must carry everything needed to undo the work — usually an id.
A workflow
import { createWorkflow, WorkflowResponse, transform } from "@medusajs/framework/workflows-sdk"
import { reserveInventoryStep } from "./steps/reserve-inventory"
import { chargePaymentStep } from "./steps/charge-payment"
import { createOrderRecordStep } from "./steps/create-order-record"
type Input = {
cart_id: string
variant_id: string
quantity: number
location_id: string
amount: number
}
export const placeCustomOrderWorkflow = createWorkflow(
"place-custom-order",
(input: Input) => {
const reservation = reserveInventoryStep({
variant_id: input.variant_id,
quantity: input.quantity,
location_id: input.location_id,
})
const payment = chargePaymentStep({
cart_id: input.cart_id,
amount: input.amount,
})
const order = createOrderRecordStep({
cart_id: input.cart_id,
payment_id: payment.id,
reservation_id: reservation.id,
})
return new WorkflowResponse(order)
}
)If createOrderRecordStep throws, the payment is refunded and the reservation released, in that order, automatically.
The rule that trips everyone up
The workflow body runs once, at definition time, to build a graph. It does not run per invocation.
So this does not work:
// ✗ Broken — `total` is a reference to a future value, not a number.
const order = createOrderStep({ total: input.amount * 1.2 })At definition time input.amount is a placeholder. Multiplying it produces nonsense. Use transform, which defers the computation to runtime:
// ✓ Correct
const totals = transform({ input }, ({ input }) => ({
total: input.amount * 1.2,
}))
const order = createOrderStep({ total: totals.total })Same for conditionals — use when rather than a native if:
import { when } from "@medusajs/framework/workflows-sdk"
when({ input }, ({ input }) => input.requires_approval)
.then(() => {
createApprovalRequestStep({ order_id: input.cart_id })
})Nearly every "my workflow behaves strangely" bug is one of these two.
Running one
From an API route:
import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
import { placeCustomOrderWorkflow } from "../../../workflows/place-custom-order"
export async function POST(req: MedusaRequest, res: MedusaResponse) {
const { result } = await placeCustomOrderWorkflow(req.scope).run({
input: req.body as never,
})
res.json({ order: result })
}Workflows also run from subscribers and scheduled jobs. The invocation is always the same shape.
Composing workflows
Medusa's own core flows are workflows, and you can run them as steps inside yours:
import { createProductsWorkflow } from "@medusajs/medusa/core-flows"
const created = createProductsWorkflow.runAsStep({ input: payload })Compensation composes too — if your workflow fails after the nested one succeeded, the nested workflow's compensations run. This is why importers should be workflows: a failed import rolls itself back instead of leaving half a catalog.
Where they earn their keep
| Use case | Why a workflow |
|---|---|
| Order placement | Multiple external systems, all reversible |
| Data import | Partial imports are worse than none |
| Subscription billing | Charge, extend, notify — each undoable |
| Marketplace payouts | Money moves; compensation is mandatory |
| ERP sync | Remote failure must not corrupt local state |
If an operation touches one module and cannot half-fail, a service method is fine. The moment there are two external calls, use a workflow.
Practical guidance
Make steps idempotent. Retries happen. A step that double-charges on retry is a bug waiting for a bad network day.
Compensation must be defensive. It may run against a partially completed step. Check the id exists before deleting it.
Keep steps small. One external effect per step. Fat steps cannot be compensated precisely.
Name steps in the imperative. reserve-inventory, not inventory-handler. The name appears in logs when things fail.
Long-running and async steps
Not every step completes within a request. A step awaiting a supplier confirmation, a manual approval or a webhook can be marked async, which pauses the workflow until something resolves it:
import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"
export const awaitSupplierConfirmationStep = createStep(
{ name: "await-supplier-confirmation", async: true, timeout: 60 * 60 * 24 },
async (input: { order_id: string }, { container }) => {
await container.resolve("supplier").requestConfirmation(input.order_id)
// No StepResponse: the workflow pauses here until something resolves it.
},
)An API route or subscriber then resolves it when the confirmation arrives, and the workflow continues from that point. This requires the Redis workflow engine — the in-memory default cannot hold a paused workflow across a restart. See production deployment.
Debugging a workflow
Three techniques, in the order they are usually needed:
Read the step names in the logs. Each step logs on start and finish under the name you gave it, which is the argument for imperative names like reserve-inventory over inventory-handler.
Log inside transform, not the workflow body. The body runs once at definition time, so a console.log there prints a reference object exactly once at boot. Inside transform it prints real values per run.
Force a failure deliberately. Throw from the last step in a test and assert the world is unchanged. Compensation is the code path that never runs in development, which is exactly why it hides bugs — see testing.
Workflows are the part of Medusa that separates a store which recovers from failures from one that accumulates them. We are happy to review yours.
Frequently asked questions
What is the difference between a workflow and a service method in Medusa?
A service method belongs to a single module and does one thing. A workflow orchestrates steps across modules and external systems, with rollback when a later step fails. Single module and no external calls means a service method; anything crossing boundaries should be a workflow.
How does compensation work in Medusa workflows?
Each step may define a compensation function that receives the second argument of its `StepResponse`. If any later step throws, Medusa runs the compensations of completed steps in reverse order, undoing the work. It is a saga pattern, built in.
Why does my workflow show undefined values at definition time?
Because the workflow body runs once to build a graph, not per invocation. Values passed between steps are references resolved at runtime. To compute anything from them, use `transform`; to branch, use `when` rather than a native `if`.
Can I run a Medusa workflow from a subscriber?
Yes, and it is a common pattern — a subscriber reacts to an event and invokes a workflow to do the work. The subscriber gets the container, so `myWorkflow(container).run({ input })` works exactly as it does in an API route.
Are Medusa workflows durable across restarts?
Steps can be marked async and paused for external confirmation, which supports long-running flows. Ordinary in-process workflows do not survive a crash mid-execution, which is exactly why compensation exists — a failed run leaves consistent data rather than a resumable one.
Can I use Medusa's built-in workflows?
Yes. Core flows like `createProductsWorkflow` and `createCartWorkflow` are exported from `@medusajs/medusa/core-flows` and can be run as steps inside your own, inheriting their compensation. Prefer composing them over reimplementing core behaviour.
Medusa Project Structure: Where Every Kind of Code Belongs
Medusa loads code by convention. A directory-by-directory guide to what goes where, and the decision tree for placing any new piece of logic.
Medusa Events and Subscribers: Reacting Without Coupling
How Medusa's event bus works, when a subscriber is the right tool instead of a workflow step, and the retry semantics you need to design around.
The Medusa Container: Dependency Injection Without the Ceremony
Everything in Medusa is resolved from a container. What is registered, how scoping works per request, and how to avoid the two mistakes that cause leaks.



