Architecture Deep Dive: Module → Workflow → API Route Pattern
This is the fundamental three-layer pattern in Medusa for building features. Understanding this pattern is critical to building maintainable, scalable applications with Medusa.
The Three-Layer Pattern
┌─────────────────────────────────────────────────┐
│ API Route (HTTP Interface Layer) │
│ - Accepts HTTP requests │
│ - Validates input │
│ - Executes workflow │
│ - Returns HTTP response │
│ - No business logic │
└─────────────────┬───────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────┐
│ Workflow (Business Logic Orchestration Layer) │
│ - Coordinates multiple steps │
│ - Handles rollback via compensation │
│ - Manages transactions │
│ - No HTTP concerns │
└─────────────────┬───────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────┐
│ Module (Data Layer) │
│ - Defines data models │
│ - Provides CRUD operations │
│ - Isolated from other modules │
│ - No business logic │
└─────────────────────────────────────────────────┘Why This Pattern?
1. Separation of Concerns
Each layer has ONE responsibility:
- API Route: Handle HTTP (request parsing, response formatting)
- Workflow: Orchestrate business logic (coordination, rollback)
- Module: Manage data (persistence, retrieval)
Why this matters: When you need to change how data is stored (module), you don't touch HTTP logic (route). When you change business rules (workflow), you don't touch data access (module).
2. Reusability
Workflows can be called from multiple places:
import { createBrandWorkflow } from "../../workflows/create-brand"
// From HTTP API route
export const POST = async (req, res) => {
const { result } = await createBrandWorkflow(req.scope)
.run({ input: req.validatedBody })
res.json({ brand: result })
}
// From another workflow or subscriber
async function mySubscriber(data, { container }) {
const { result } = await createBrandWorkflow(container)
.run({ input: { name: data.brandName } })
return result
}
// From scheduled job
export const importBrands = async (container, brands) => {
for (const brand of brands) {
await createBrandWorkflow(container)
.run({ input: brand })
}
}Why this matters: You write the business logic once, use it everywhere. No code duplication.
3. Testability
Each layer can be tested independently:
// Test module in isolation
test("creates brand", async () => {
const brandService = container.resolve("brand")
const [brand] = await brandService.createBrands([{ name: "Nike" }])
expect(brand.name).toBe("Nike")
})
// Test workflow in isolation
test("workflow creates brand and sends notification", async () => {
const { result } = await createBrandWorkflow(container)
.run({ input: { name: "Nike" } })
expect(result.brand.name).toBe("Nike")
expect(mockNotificationService.send).toHaveBeenCalled()
})Why this matters: You can test each layer without spinning up the entire application. Tests run faster and are more reliable.
4. Rollback and Transactions
Workflows provide automatic rollback through compensation functions:
// If any step fails, all previous steps are rolled back
createWorkflow("create-brand-with-s3-upload", function (input) {
const brand = createBrandStep(input) // Step 1
const logo = uploadLogoToS3Step(input.logo) // Step 2
const notification = sendSlackNotificationStep(brand) // Step 3
return new WorkflowResponse({ brand, logo })
})What happens if step 3 fails?
- Step 3 fails (Slack API down)
- Medusa calls step 2's compensation: Delete logo from S3
- Medusa calls step 1's compensation: Delete brand from database
- Entire operation rolled back - database is clean
Why this matters: No orphaned data. No manual cleanup. No inconsistent state.
Anti-Pattern: Direct Service Calls from Routes
❌ WRONG
// API route that directly calls services (BAD!)
export const POST = async (req: MedusaRequest, res: MedusaResponse) => {
const brandService = req.scope.resolve("brand")
const s3Service = req.scope.resolve("s3")
const slackService = req.scope.resolve("slack")
let brand
let logoUrl
try {
// Create brand
brand = await brandService.createBrands([req.validatedBody])
// Upload logo
logoUrl = await s3Service.upload(req.file)
// Send notification
await slackService.notify(`Brand ${brand.name} created!`)
res.json({ brand })
} catch (error) {
// Manual rollback - error-prone!
if (brand) {
await brandService.deleteBrands([brand.id])
}
if (logoUrl) {
await s3Service.delete(logoUrl)
}
throw error
}
}Problems:
- ❌ Business logic in HTTP layer - not reusable
- ❌ Manual rollback - error-prone and hard to maintain
- ❌ Can't test business logic without HTTP
- ❌ Multiple concerns mixed (HTTP, business logic, error handling)
- ❌ Partial failures leave data in inconsistent state if rollback fails
✅ CORRECT
// Step 1: Define workflow steps with compensation
const createBrandStep = createStep(
"create-brand",
async (input, { container }) => {
const brandService = container.resolve("brand")
const [brand] = await brandService.createBrands([input])
return new StepResponse(brand, brand.id)
},
async (brandId, { container }) => {
if (!brandId) return
const brandService = container.resolve("brand")
await brandService.deleteBrands([brandId])
}
)
const uploadLogoStep = createStep(
"upload-logo-to-s3",
async (input, { container }) => {
const s3Service = container.resolve("s3")
const logoUrl = await s3Service.upload(input.logo)
return new StepResponse(logoUrl, logoUrl)
},
async (logoUrl, { container }) => {
if (!logoUrl) return
const s3Service = container.resolve("s3")
await s3Service.delete(logoUrl)
}
)
const sendSlackNotificationStep = createStep(
"send-slack-notification",
async (brand, { container }) => {
const slackService = container.resolve("slack")
await slackService.notify(`Brand ${brand.name} created!`)
return new StepResponse("sent")
}
)
// Step 2: Compose workflow
export const createBrandWorkflow = createWorkflow(
"create-brand-with-s3-upload",
function (input) {
const brand = createBrandStep(input)
const logoUrl = uploadLogoStep({ logo: input.logo })
sendSlackNotificationStep(brand)
return new WorkflowResponse({
brand: transform({ brand }, ({ brand }) => brand),
})
}
)
// Step 3: Simple API route
import { createBrandWorkflow } from "../../workflows/create-brand"
export const POST = async (req: MedusaRequest, res: MedusaResponse) => {
const { result } = await createBrandWorkflow(req.scope)
.run({ input: req.validatedBody })
res.json({ brand: result.brand })
}Benefits:
- ✅ Business logic in workflow - reusable from HTTP, GraphQL, CLI, jobs
- ✅ Automatic rollback - Medusa handles compensation
- ✅ Each layer testable independently
- ✅ Clean separation of concerns
- ✅ All-or-nothing guarantee - either everything succeeds or everything rolls back
Real-World Example: Complex Workflow
Here's a real-world scenario: Creating a product with inventory, pricing, and warehouse allocation.
export const createProductWithInventoryWorkflow = createWorkflow(
"create-product-with-inventory",
function (input) {
// Step 1: Create product in Product Module
const product = createProductStep(input.product)
// Step 2: Create pricing in Pricing Module
const pricing = createPricingStep({
productId: product.id,
prices: input.prices,
})
// Step 3: Allocate inventory in Inventory Module
const inventory = allocateInventoryStep({
productId: product.id,
quantity: input.quantity,
warehouseId: input.warehouseId,
})
// Step 4: Link to collections in Product Module
const collections = linkCollectionsStep({
productId: product.id,
collectionIds: input.collectionIds,
})
// Step 5: Send notification to warehouse
sendWarehouseNotificationStep({
productId: product.id,
warehouseId: input.warehouseId,
})
return new WorkflowResponse({ product, pricing, inventory, collections })
}
)What happens if step 5 fails (notification service down)?
Medusa automatically executes compensations in reverse order:
- Step 4 compensation: Unlink collections
- Step 3 compensation: Deallocate inventory
- Step 2 compensation: Delete pricing
- Step 1 compensation: Delete product
Result: Database is clean. No orphaned data. No manual cleanup needed.
When to Use Each Layer
Module Layer - Use When You Need To:
- Define data models
- Store/retrieve data
- Perform CRUD operations
- Encapsulate domain logic around a single entity
DON'T put business logic here (e.g., "when product is created, send email").
Workflow Layer - Use When You Need To:
- Coordinate multiple steps
- Handle rollback scenarios
- Orchestrate cross-module operations
- Implement business processes
DON'T handle HTTP concerns here (e.g., parsing request body, setting status codes).
API Route Layer - Use When You Need To:
- Accept HTTP requests
- Validate input
- Execute workflows
- Format HTTP responses
DON'T put business logic here (e.g., direct service calls, manual rollback).
Key Principles
- Routes are thin: They only parse requests and return responses
- Workflows orchestrate: They coordinate steps but don't implement them
- Modules encapsulate: They own their data and provide CRUD operations
- Compensation is mandatory: Every step that creates/modifies data MUST have compensation
- Steps are atomic: Each step does ONE thing and does it well
Anti-Patterns to Avoid
❌ Anti-Pattern 1: Business Logic in Routes
// BAD - route contains business logic
export const POST = async (req, res) => {
const brand = await createBrand(req.body)
// Business rule in route layer
if (brand.name.startsWith("Nike")) {
await sendPremiumNotification(brand)
} else {
await sendStandardNotification(brand)
}
}Fix: Move business logic to workflow.
❌ Anti-Pattern 2: Workflows Directly Accessing Database
// BAD - workflow directly queries database
createWorkflow("create-brand", function (input) {
const result = someStepThatQueriesDatabase(input)
// Database access should be in modules, not workflows
})Fix: Use module services for all data access.
❌ Anti-Pattern 3: Modules with Business Logic
// BAD - module contains orchestration logic
class BrandService extends MedusaService(Brand) {
async createBrand(data) {
const brand = await this.createBrands([data])
await this.uploadLogoToS3(data.logo) // Orchestration!
await this.sendNotification(brand) // Orchestration!
return brand
}
}Fix: Modules provide CRUD operations only. Orchestration goes in workflows.
❌ Anti-Pattern 4: Missing Compensation Functions
// BAD - step with no compensation
const createBrandStep = createStep(
"create-brand",
async (input, { container }) => {
const brandService = container.resolve("brand")
const [brand] = await brandService.createBrands([input])
return new StepResponse(brand)
}
// Missing compensation! If later steps fail, brand remains in database
)Fix: Always provide compensation for steps that create/modify data.
Summary
The Module → Workflow → API Route pattern is fundamental to building maintainable, scalable Medusa applications:
- Modules: Data layer (CRUD operations)
- Workflows: Business logic orchestration (coordination + rollback)
- API Routes: HTTP interface (request/response handling)
Benefits:
- Separation of concerns
- Reusability (workflows callable from anywhere)
- Testability (test each layer independently)
- Automatic rollback (compensation functions)
- Maintainability (changes isolated to appropriate layer)
Key Rule: Each layer has ONE job. Don't mix concerns. Keep routes thin, workflows orchestrative, and modules focused on data.