Skill 14 · Building With Medusa
Subchapter 14.13
reference/workflows.mdMarkdown16 KBView on GitHub
Workflows are the standard way to perform mutations (create, update, delete) in modules in Medusa. If you have built a custom module and need to perform mutations on models in the module, you should create a workflow.
IMPORTANT FOR CLAUDE CODE: When implementing workflows, use the TodoWrite tool to track your progress through these steps. This ensures you don’t miss any critical steps and provides visibility to the user.
Create these tasks in your todo list:
File Organization:
src/workflows/steps/[step-name].tssrc/workflows/[workflow-name].ts// src/workflows/steps/create-my-model.ts
import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"
type Input = {
my_key: string
}
// Note: a step should only do one mutation this ensures rollback mechanisms work
// For workflows that retry build your steps to be idempotent
export const createMyModelStep = createStep(
"create-my-model",
async (input: Input, { container }) => {
const myModule = container.resolve("my")
const [newMy] = await myModule.createMyModels({
...input,
})
return new StepResponse(
newMy,
newMy.id // explicit compensation input - otherwise defaults to step's output
)
},
// Optional compensation function
async (id, { container }) => {
const myModule = container.resolve("my")
await myModule.deleteMyModels(id)
}
)
// src/workflows/create-my-model.ts
import { createWorkflow, WorkflowResponse } from "@medusajs/framework/workflows-sdk"
import { createMyModelStep } from "./steps/create-my-model"
type Input = {
my_key: string
}
const createMyModel = createWorkflow(
"create-my-model",
// Note: See "Workflow Composition Rules" section below for important constraints
// The workflow function must be a regular synchronous function (not async/arrow)
// No direct variable manipulation, conditionals, or date creation - use transform/when instead
function (input: Input) {
const newMy = createMyModelStep(input)
return new WorkflowResponse({
newMy,
})
}
)
export default createMyModelThe workflow composition function runs at application load time and has important limitations:
async functionsfunction keyword)⚠️ CRITICAL: When using the same step multiple times in a workflow, you MUST rename each invocation AFTER the first invocation using .config() to avoid conflicts.
// ✅ CORRECT - Rename each step invocation with .config()
export const processCustomersWorkflow = createWorkflow(
"process-customers",
function (input) {
const customers = transform({ ids: input.customer_ids }, (input) => input.ids)
// First invocation - no need to rename
const customer1 = fetchCustomerStep(customers[0])
// Second invocation - different name
const customer2 = fetchCustomerStep(customers[1]).config({
name: "fetch-customer-2"
})
const result = transform({ customer1, customer2 }, (data) => ({
customers: [data.customer1, data.customer2]
}))
return new WorkflowResponse(result)
}
)
// ❌ WRONG - Calling the same step multiple times without renaming
export const processCustomersWorkflow = createWorkflow(
"process-customers",
function (input) {
const customers = transform({ ids: input.customer_ids }, (input) => input.ids)
// This will cause runtime errors - duplicate step names
const customer1 = fetchCustomerStep(customers[0])
const customer2 = fetchCustomerStep(customers[1]) // ❌ Conflict!
return new WorkflowResponse({ customers: [customer1, customer2] })
}
)Why this matters:
transform({ in }, ({ in }) => \Transformed: ${in}`)` insteadtransform()new Date() (will be fixed to load time) → Wrap in transform() for execution-time evaluationif/else statements → Use when(input, (input) => input.is_active).then(() => { /* steps */ }) instead? :) → Use transform() instead??) → Use transform() instead||) → Use transform() instead?.) → Use transform() instead!!) → Use transform() instead...) for destructuring or spreading properties → Use transform() to create new objects with desired properties// ❌ WRONG - Object spreading in workflow
const myWorkflow = createWorkflow(
"process-data",
function (input: WorkflowInput) {
const updatedData = {
...input.data,
newField: "value"
} // Won't work - spread operator not allowed
step1(updatedData)
})
// ✅ CORRECT - Use transform to create new objects
import { transform } from "@medusajs/framework/workflows-sdk"
const myWorkflow = createWorkflow(
"process-data",
function (input: WorkflowInput) {
const updatedData = transform(
{ input },
(data) => ({
...data.input.data,
newField: "value"
})
)
step1(updatedData)
})for/while loops → Use alternatives below based on your use caseWorkflow composition functions run at application load time to define the workflow structure, not to execute logic. Loops cannot be used directly in the composition function. Instead, use these patterns:
Alternative 1: Loop in Calling Code (Repeat entire workflow)
When you need to execute a workflow multiple times (e.g., once per item in an array), wrap the workflow execution in a loop in the code that calls the workflow:
// ❌ WRONG - Loop inside workflow composition
const myWorkflow = createWorkflow(
"hello-world",
function (input: WorkflowInput) {
for (const item of input.items) {
step1(item) // Won't work - loop runs at load time, not execution time
}
})
// ✅ CORRECT - Loop in calling code
// API route that calls the workflow
import {
MedusaRequest,
MedusaResponse,
} from "@medusajs/framework/http"
import myWorkflow from "../../workflows/my-workflow"
export async function POST(
req: MedusaRequest,
res: MedusaResponse
) {
const { items } = req.body
// Execute the workflow once for each item
for (const item of items) {
await myWorkflow(req.scope)
.run({ item })
}
res.status(200).send({ success: true })
}
// Workflow definition - processes a single item
const myWorkflow = createWorkflow(
"hello-world",
function (input: WorkflowInput) {
step1(input.item)
})Alternative 2: Use transform for Array Operations (Prepare step inputs)
When you need to iterate over an array to prepare inputs for a step, use transform() to map over the array:
// ❌ WRONG - Loop to build array
const myWorkflow = createWorkflow(
"hello-world",
function (input: WorkflowInput) {
const stepInputs = []
for (const item of input.items) {
stepInputs.push({ id: item.id }) // Won't work - loop runs at load time
}
step1(stepInputs)
})
// ✅ CORRECT - Use transform to map array
import { transform } from "@medusajs/framework/workflows-sdk"
const myWorkflow = createWorkflow(
"hello-world",
function (input: WorkflowInput) {
const stepInputs = transform(
{
input,
},
(data) => {
// This function runs at execution time
return data.input.items.map((item) => ({ id: item.id }))
}
)
step1(stepInputs)
})Why this matters:
transform()try-catch blocks → See error handling patterns in Medusa documentationBuffer.from() when processing resultsStepResponse⚠️ IMPORTANT: Before creating custom steps, check if Medusa provides a built-in step for your use case. Reusing built-in steps is preferred over creating custom ones.
Creating Links Between Modules:
⚠️ CRITICAL - Link Order (Direction): When creating links, the order of modules in createRemoteLinkStep MUST match the order in defineLink(). Mismatched order causes runtime errors.
// Link definition in src/links/review-product.ts
import { defineLink } from "@medusajs/framework/utils"
import ReviewModule from "../modules/review"
import ProductModule from "@medusajs/medusa/product"
// Order: review FIRST, then product
export default defineLink(
{
linkable: ReviewModule.linkable.review,
isList: true,
},
ProductModule.linkable.product
)// ✅ CORRECT - Order matches defineLink (review first, then product)
import { createRemoteLinkStep } from "@medusajs/medusa/core-flows"
import { createWorkflow, transform, WorkflowResponse } from "@medusajs/framework/workflows-sdk"
import { Modules } from "@medusajs/framework/utils"
import { REVIEW_MODULE } from "../modules/review"
export const createReviewWorkflow = createWorkflow(
"create-review",
function (input) {
const review = createReviewStep(input)
// Order MUST match defineLink: review first, then product
const linkData = transform({ review, input }, ({ review, input }) => [{
[REVIEW_MODULE]: {
review_id: review.id,
},
[Modules.PRODUCT]: {
product_id: input.product_id,
},
}])
createRemoteLinkStep(linkData)
return new WorkflowResponse({ review })
}
)
// ❌ WRONG - Order doesn't match defineLink (product first, then review)
const linkData = transform({ review, input }, ({ review, input }) => [{
[Modules.PRODUCT]: {
product_id: input.product_id,
},
[REVIEW_MODULE]: {
review_id: review.id,
},
}]) // Runtime error: link direction mismatch!// ❌ WRONG - Don't create custom link steps
const createReviewLinkStep = createStep(
"create-review-link",
async ({ reviewId, productId }, { container }) => {
const link = container.resolve("link")
await link.create({
product: { product_id: productId },
review: { review_id: reviewId },
})
// This duplicates functionality that createRemoteLinkStep provides
}
)Removing Links:
// ✅ CORRECT - Use Medusa's built-in dismissRemoteLinkStep
import { dismissRemoteLinkStep } from "@medusajs/medusa/core-flows"
export const deleteReviewWorkflow = createWorkflow(
"delete-review",
function (input) {
const linkData = transform({ input }, ({ input }) => [{
[Modules.PRODUCT]: { product_id: input.product_id },
review: { review_id: input.review_id },
}])
dismissRemoteLinkStep(linkData)
deleteReviewStep(input)
return new WorkflowResponse({ success: true })
}
)Querying Data in Workflows:
// ✅ CORRECT - Use Medusa's built-in useQueryGraphStep
import { useQueryGraphStep } from "@medusajs/medusa/core-flows"
import { createWorkflow, WorkflowResponse } from "@medusajs/framework/workflows-sdk"
export const getProductReviewsWorkflow = createWorkflow(
"get-product-reviews",
function (input) {
// Query product with reviews using built-in step
const { data: products } = useQueryGraphStep({
entity: "product",
fields: ["id", "title", "reviews.*"],
filters: {
id: input.product_id,
},
})
return new WorkflowResponse({ product: products[0] })
}
)
// ❌ WRONG - Don't create custom query steps
const queryProductStep = createStep(
"query-product",
async ({ productId }, { container }) => {
const query = container.resolve("query")
const { data } = await query.graph({
entity: "product",
fields: ["id", "title", "reviews.*"],
filters: { id: productId },
})
return new StepResponse(data[0])
}
)
// This duplicates functionality that useQueryGraphStep providesWhy reuse built-in steps:
Other common built-in steps to look for:
Check Medusa documentation or @medusajs/medusa/core-flows for available built-in steps before creating custom ones.
CRITICAL: All business logic and validation must be performed inside workflow steps, NOT in API routes.
// src/workflows/steps/delete-review.ts
export const deleteReviewStep = createStep(
"delete-review",
async ({ reviewId, customerId }: Input, { container }) => {
const reviewModule = container.resolve("review")
// Validation happens inside the step
const review = await reviewModule.retrieveReview(reviewId)
if (review.customer_id !== customerId) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"You can only delete your own reviews"
)
}
await reviewModule.deleteReviews(reviewId)
return new StepResponse({ id: reviewId }, reviewId)
},
async (reviewId, { container }) => {
// Compensation: restore the review if needed
}
)// src/api/store/reviews/[id]/route.ts
export async function DELETE(req: MedusaRequest, res: MedusaResponse) {
const { id } = req.params
const customerId = req.auth_context.actor_id
// ❌ WRONG: Don't validate business rules in the route
const reviewModule = req.scope.resolve("review")
const review = await reviewModule.retrieveReview(id)
if (review.customer_id !== customerId) {
throw new MedusaError(MedusaError.Types.NOT_ALLOWED, "Not your review")
}
// ❌ WRONG: Don't call workflows after manual validation
const { result } = await deleteReviewWorkflow(req.scope).run({
input: { reviewId: id }
})
}Why this matters:
Workflows have advanced options to define retries, async behavior, pausing for human confirmation, and much more. Ask MedusaDocs for more details if these are relevant to your use case.