How to Define Custom Steps in Medusa Workflows: A Complete Guide

To define custom steps in Medusa workflows, import createStep from @medusajs/framework/workflows-sdk, write an invoke function that returns a StepResponse, optionally add a compensation function for rollbacks, and export the resulting callable to use inside any workflow.

The Medusa framework's workflow engine, located in the @medusajs/framework/workflows-sdk package, treats steps as the fundamental unit of composable work. When you define custom steps in Medusa workflows, you create reusable, atomic operations that automatically support distributed transactions and rollback capabilities. This implementation relies on the core createStep utility found in packages/core/workflows-sdk/src/utils/composer/create-step.ts.

Anatomy of a Medusa Workflow Step

A step consists of three core components working together within the handler generated by createStep.

The Invoke Function

The invoke function receives the step's input data and a StepExecutionContext containing the dependency container. This is where your business logic executes. According to the source code in packages/core/workflows-sdk/src/utils/composer/create-step.ts, the function must return a StepResponse instance.

StepResponse and Compensation

The StepResponse class wraps your step's output. Its constructor accepts two arguments: the primary output (forwarded to the next step) and optional compensation data (forwarded to the rollback handler).

The compensation function runs automatically if the workflow rolls back. It receives the second argument passed to StepResponse and the execution context, allowing you to undo side effects like database writes or API calls.

Defining a Minimal Custom Step

Start with a pure step that transforms data without side effects. Since no persistent state changes occur, the compensation function remains empty.

import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk";

export const doubleNumberStep = createStep(
  "double-number",
  async (input: { value: number }, { container }) => {
    const result = input.value * 2;
    return new StepResponse(result);
  },
  async (output, { container }) => {
    // No compensation needed for pure calculations
  }
);

Use this step inside a workflow by calling it within createWorkflow:

import { createWorkflow, WorkflowResponse } from "@medusajs/framework/workflows-sdk";
import { doubleNumberStep } from "./steps/double-number";

export const demoWorkflow = createWorkflow(
  "demo-workflow",
  (input: { start: number }) => {
    const doubled = doubleNumberStep({ value: input.start });
    return new WorkflowResponse(doubled);
  }
);

Implementing Compensation Logic for Database Transactions

Real-world steps often persist data that must be rolled back on failure. The Medusa loyalty plugin demonstrates this pattern in packages/plugins/loyalty/src/workflows/store-credit/steps/credit-account.ts.

This step credits store-credit accounts and passes the created transaction IDs to its compensation function:

import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk";
import { IStoreCreditModuleService, PluginModule } from "../../../types";

export type CreditAccountStepInput = ModuleCreditAccount[];

export const creditAccountStep = createStep(
  "credit-account",
  async (input: CreditAccountStepInput, { container }) => {
    const module = container.resolve<IStoreCreditModuleService>(
      PluginModule.STORE_CREDIT
    );
    const transactions = await module.creditAccounts(input);
    return new StepResponse(transactions, transactions.map(t => t.id));
  },
  async (ids, { container }) => {
    if (!ids?.length) return;
    const module = container.resolve<IStoreCreditModuleService>(
      PluginModule.STORE_CREDIT
    );
    await module.deleteTransactions(ids);
  }
);

The corresponding workflow in packages/plugins/loyalty/src/workflows/store-credit/workflows/credit-accounts.ts composes this step:

import { createWorkflow, WorkflowResponse } from "@medusajs/framework/workflows-sdk";
import { creditAccountStep } from "../steps/credit-account";

export const creditAccountsWorkflow = createWorkflow(
  "credit-accounts",
  (input: ModuleCreditAccount[]) => {
    return new WorkflowResponse(creditAccountStep(input));
  }
);

Configuring Conditional and Async Execution

Beyond basic invocation, Medusa steps support conditional logic and asynchronous execution patterns.

Conditional Step Execution

Use the .if() method to guard step execution with a predicate function. The conditional logic is handled by wrapConditionalStep in packages/core/workflows-sdk/src/utils/composer/create-step.ts:

const conditionalStep = createStep(
  "conditionally-run",
  async (input, { container }) => {
    return new StepResponse("executed");
  }
);

// Inside a workflow:
const result = conditionalStep.if(
  {},
  () => Math.random() > 0.5
);

Async Step Configuration

Mark steps as asynchronous by passing a TransactionStepsDefinition configuration object. Set async: true to let the orchestration engine automatically mark the step complete when the promise resolves, or compensateAsync: true for async compensation functions.

Summary

  • Import createStep from @medusajs/framework/workflows-sdk to register steps with the orchestration layer and generate unique UUIDs for each step instance.
  • Return StepResponse from your invoke function to pass data forward and supply rollback information to compensation handlers.
  • Implement compensation functions for any step that creates, updates, or deletes persistent data, using the second argument from StepResponse to receive rollback identifiers.
  • Reference the source at packages/core/workflows-sdk/src/utils/composer/create-step.ts to understand UUID generation and handler wiring.
  • Use conditional execution via step.if() when steps should only run based on runtime predicates evaluated by the workflow engine.
  • Export step callables as named exports so workflows can import and invoke them within createWorkflow.

Frequently Asked Questions

How do you handle errors in a custom step?

Medusa automatically catches errors thrown in the invoke function and triggers the compensation functions for all previously completed steps in the workflow. Throw standard errors in your invoke function to initiate rollback proceedings. The orchestration engine manages the transaction boundaries according to the handler logic in packages/core/workflows-sdk/src/utils/composer/create-step-handler.ts.

What data can you access from the container in a step?

The StepExecutionContext provides access to Medusa's dependency injection container, which resolves any registered module services or custom services. For example, you can resolve IStoreCreditModuleService or database repositories to perform CRUD operations. The container is the same instance used throughout the workflow execution, ensuring consistent transaction scopes.

Can you define a step without a compensation function?

Yes, the compensation function is optional. For pure computation steps or read-only operations, omit the third argument to createStep or pass an empty async function. The workflow engine only calls compensation for steps that successfully completed before a failure occurred, and only if compensation data was provided via StepResponse.

How do you compose multiple custom steps in a single workflow?

Chain steps by passing the output of one step as input to another using the WorkflowData object returned by each step call. Medusa's workflow builder tracks these dependencies automatically. For example: const resultA = stepA(input); const resultB = stepB(resultA);. The engine ensures sequential execution based on these data dependencies.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →