Most confusion around Medusa's architecture comes from one wrong assumption: that modules and workflows are two names for the same kind of thing. They're not. Getting this distinction right is the difference between Medusa customization that stays maintainable and customization that turns into a tangle of logic nobody can safely change.

Modules own state, and only their own state

A Medusa module is a self-contained unit that owns a specific piece of commerce data, orders, pricing, inventory, or something fully custom like a license-key pool, and exposes a service interface for anything else that needs to read or write that data. The module's own database tables are its business. Nothing outside the module reaches into them directly; every interaction goes through the module's service.

That boundary is what keeps a growing set of customizations from turning into a web of direct database dependencies. A custom fraud-scoring module and a custom license-key module can both exist in the same backend without either one knowing the other's internal schema, as long as they communicate through service calls or events instead of reaching into each other's tables.

Workflows coordinate across modules, with retry and compensation built in

A workflow is a different kind of thing entirely: an orchestration layer that calls into one or more modules' services, in a defined sequence, with built-in support for retrying a failed step and for running compensation logic to undo a step's side effects if a later step fails. Placing an order, for instance, might involve a workflow that calls the inventory module to reserve stock, the payment module to capture a charge, and a notification module to send a confirmation, in that order, with a defined answer for what happens if any one of those steps fails partway through.

2
distinct responsibilities in Medusa's architecture: modules own state, workflows own orchestration, deliberately kept separate

Where this goes wrong in practice

The most common mistake is building orchestration logic inside a module, calling out to other modules directly from within a module's service method, instead of pulling that coordination into a workflow. It works at first. It gets fragile the moment a failure partway through needs to be handled correctly, because the module was never built with retry and compensation semantics, those live in the workflow layer on purpose. The second most common mistake is the opposite: putting state-owning logic inside a workflow step instead of delegating it to a module, which scatters a single concept's data-handling logic across multiple workflow definitions instead of keeping it in one place.

Customizing Medusa and not sure where new logic belongs?

Tell us what you're building. We'll tell you honestly whether it's a module, a workflow, or a step inside an existing one, before you write code that's hard to unwind.

Talk to an engineer →

A worked example

Increddy's license-key reservation is a module (it owns the key pool and the assignment state), triggered by a workflow that reacts to order and payment events and calls the module's reservation method as one step among several. The retry and idempotency logic for "what happens if this event fires twice" lives at the workflow and module boundary deliberately, covered in more depth in how we made Increddy's key delivery idempotent.

The rule

Ask "does this own new state, or does it coordinate across state that already exists" before writing a line of custom Medusa code. New state, owned data, a bounded concept: build a module. Coordination across one or more modules, with a sequence that needs to be retried or unwound correctly on failure: build a workflow. Keeping that boundary clean is what makes a Medusa backend stay maintainable as the amount of custom logic grows. We build custom modules and workflows like this under Medusa development engagements.