Skip to main content
A workflow is the orchestration layer: it coordinates a business operation across one or more modules as a series of steps, with automatic rollback (compensation) if any step fails. Every mutation in Mercur runs inside a workflow — this is the single most important rule in the architecture.
All mutations go through a workflow. API routes, subscribers, and scheduled jobs never write to the database directly — they run a workflow. That is what gives every mutation validation, event emission, and rollback.
Workflows are a Medusa framework primitive. This page focuses on the constraints and conventions that trip people (and agents) up.

The composition function is not normal JavaScript

The function you pass to createWorkflow is a composition function. It runs once at build time to wire steps together — it does not execute your business logic at request time. Because of that, it has hard constraints:
Inside a createWorkflow composition function you must not:
  • use async / await
  • use arrow functions for the composition body (use a named function)
  • use if / else, for, while, or try/catch
  • use new Date(), Math.random(), or any non-deterministic call
  • access properties of a step’s output directly (e.g. result.id)
These run at composition time, not execution time, so they either do nothing useful or break replay/rollback.
Anything that looks like normal logic goes into a step (for side effects) or a transform (for shaping data between steps):
src/workflows/create-brands.ts
import {
  createWorkflow,
  transform,
  WorkflowResponse,
} from "@medusajs/framework/workflows-sdk"

import { createBrandsStep } from "./steps"

export const createBrandsWorkflow = createWorkflow(
  "create-brands",
  function (input: CreateBrandsWorkflowInput) {
    // ✅ shape data with transform, not inline expressions
    const toCreate = transform(input, ({ brands }) =>
      brands.map((b) => ({ ...b, is_active: b.is_active ?? true }))
    )

    const brands = createBrandsStep(toCreate)

    return new WorkflowResponse(brands)
  }
)
Need a conditional or a computed value? Use transform to derive data, when to run a step conditionally, and put date/random/id generation inside a step. Never branch in the composition body itself.

One mutation per step + compensation

A step is the unit of work and the unit of rollback. The rule: each step performs a single mutation and defines how to undo it. createStep takes an invoke function and a compensation function; the invoke returns a StepResponse whose second argument is the data the compensation needs.
src/workflows/steps/create-brands.ts
import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk"
import BrandModuleService from "../../modules/brand/service"
import { BRAND_MODULE } from "../../modules/brand"

export const createBrandsStep = createStep(
  "create-brands",
  async (data: CreateBrandInput[], { container }) => {
    const service = container.resolve<BrandModuleService>(BRAND_MODULE)
    const brands = await service.createBrands(data)
    // 2nd arg → passed to the compensation function below
    return new StepResponse(brands, brands.map((b) => b.id))
  },
  async (ids: string[] | undefined, { container }) => {
    if (!ids?.length) {
      return
    }
    const service = container.resolve<BrandModuleService>(BRAND_MODULE)
    await service.deleteBrands(ids)
  }
)
If a later step in the workflow throws, Medusa runs the compensation functions of the already-completed steps in reverse — the createBrands above is undone by deleteBrands. Splitting mutations one-per-step is what makes this reliable: a step that does two writes can only half-compensate.

Reuse built-in steps

Don’t hand-roll what the framework already ships. Medusa’s core-flows exports composable steps you should reuse instead of writing your own:
NeedBuilt-in step
Create/remove module linkscreateRemoteLinkStep / dismissRemoteLinkStep
Emit a domain eventemitEventStep
Call another workflow as a stepotherWorkflow.runAsStep({ input })
Composing built-in steps + a sub-workflow
import { emitEventStep } from "@medusajs/medusa/core-flows"

export const createBrandsWorkflow = createWorkflow(
  "create-brands",
  function (input: CreateBrandsWorkflowInput) {
    const brands = createBrandsStep(input.brands)

    // reuse a whole workflow as one step
    notifyOwnersWorkflow.runAsStep({ input: { brands } })

    // reuse the built-in event step
    emitEventStep({ eventName: "brand.created", data: { ids: brands } })

    return new WorkflowResponse(brands)
  }
)
Prefer runAsStep over duplicating logic. If two workflows need the same sequence, extract it into its own workflow and call it as a step from both — you get one place to maintain and correct compensation for free.

Hooks — let others extend your workflow

Expose extension points with createHook so consumers can inject behaviour (validation, side effects) without forking the workflow. Add a validate hook before the mutation and a brandsCreated hook after it:
const validate = createHook("validate", { input })

const brands = createBrandsStep(input.brands)

const brandsCreated = createHook("brandsCreated", {
  brands,
  additional_data: input.additional_data,
})

return new WorkflowResponse(brands, {
  hooks: [validate, brandsCreated],
})
Consumers register a handler on the hook to run custom logic at that point. This is the sanctioned way to extend a workflow — see Extend a workflow.

The query engine

Reads inside a step (and anywhere else) go through Query — the graph engine that resolves data across modules and links. Resolve it from the container and call query.graph:
Reading across modules inside a step
import { ContainerRegistrationKeys } from "@medusajs/framework/utils"

const query = container.resolve(ContainerRegistrationKeys.QUERY)

const { data: brands } = await query.graph({
  entity: "brand",
  fields: ["id", "name", "products.*"], // follows the product ↔ brand link
  filters: { is_active: true },
})
Query is for reads. Never try to mutate through it, and never resolve another module’s service inside a step to read its data — go through Query so module isolation and links are respected.

Checklist for a workflow

  • Composition function is a named function, with no async/await, if, loops, try/catch, new Date(), or step-output property access.
  • Data shaping between steps uses transform; conditional steps use when.
  • Each step does exactly one mutation and defines a compensation function.
  • StepResponse passes the compensation the data it needs to undo the work.
  • Built-in steps (createRemoteLinkStep, emitEventStep) and runAsStep are reused instead of reimplemented.
  • Reads use query.graph; no cross-module service calls.
  • Extension points are exposed as hooks, not by forking.