Subchapter 2.3
architecture/module-workflow-route.mdMarkdown13 KBView on GitHub
This is the fundamental three-layer pattern in Medusa for building features. Understanding this pattern is critical to building maintainable, scalable applications with Medusa.
┌─────────────────────────────────────────────────┐
│ 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 │
└─────────────────────────────────────────────────┘Each layer has ONE responsibility:
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).
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.
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.
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?
Why this matters: No orphaned data. No manual cleanup. No inconsistent state.
// 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:
// 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:
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:
Result: Database is clean. No orphaned data. No manual cleanup needed.
DON’T put business logic here (e.g., “when product is created, send email”).
DON’T handle HTTP concerns here (e.g., parsing request body, setting status codes).
DON’T put business logic here (e.g., direct service calls, manual rollback).
// 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.
// 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.
// 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.
// 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.
The Module → Workflow → API Route pattern is fundamental to building maintainable, scalable Medusa applications:
Benefits:
Key Rule: Each layer has ONE job. Don’t mix concerns. Keep routes thin, workflows orchestrative, and modules focused on data.