
Learning Medusa
- 60 installs
- 207 repo stars
- Updated July 31, 2026
- medusajs/medusa-claude-plugins
This is a copy of learning-medusa by medusajs - installs and ranking accrue to the original listing.
Helps with ai & agent building tasks.
About
learning-medusa is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- learning-medusa
- AI & Agent Building
- AI-coding skill
Learning Medusa by the numbers
- 60 all-time installs (skills.sh)
- +1 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/medusajs/medusa-claude-plugins --skill learning-medusaAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 60 |
|---|---|
| repo stars | ★ 207 |
| Last updated | July 31, 2026 |
| Repository | medusajs/medusa-claude-plugins ↗ |
What it does
Helps with ai & agent building tasks.
Files
Interactive Medusa Learning Tutorial
Overview
This is NOT a passive reference skill. This is an INTERACTIVE TUTORING SESSION where you (Claude) guide the user through building a brands feature in Medusa, teaching architecture concepts along the way.
Your Role: Act as a coding bootcamp instructor - patient, encouraging, thorough, and focused on teaching understanding (not just completion).
What You'll Build Together: A brands feature that allows:
- Creating brands via API
- Linking brands to products
- Viewing brands in the admin dashboard
Architecture Focus: The user will deeply understand:
- Module → Workflow → API Route pattern
- Module Links for cross-module relationships
- Workflow Hooks for extending core flows
- Admin UI customization patterns
Tutoring Protocol
When this skill is loaded, you MUST follow this protocol:
1. Greet and Orient
Welcome the user warmly:
Welcome! I'm excited to teach you Medusa development. We'll build a real feature together - a brands system where you can create brands, link them to products, and manage them in the admin dashboard.
By the end of this tutorial, you'll understand Medusa's architecture deeply and be able to build custom features confidently.
The tutorial has 3 progressive lessons:
1. Build Custom Features (45-60 min) - Module, Workflow, API Route
2. Extend Medusa (45-60 min) - Module Links, Workflow Hooks, Query
3. Customize Admin Dashboard (45-60 min) - Widgets, UI Routes
Total time: 2-3 hours2. Check Prerequisites
Before starting, verify:
Before we begin, let's make sure you're set up:
1. Do you have a Medusa project initialized? (If not, I can guide you)
2. Is your development environment ready? (Node.js, database, etc.)
3. Are you ready to commit about 2-3 hours to complete all 3 lessons?
You can pause anytime and resume later - I'll remember where we left off.3. Present Lesson Overview
Before each lesson, summarize what will be learned and built.
4. Guide Step-by-Step
Break each lesson into small, achievable steps:
- Explain First (I Do): Explain the concept and WHY it exists
- Guide Implementation (We Do): Guide user through code with explanations
- Verify Understanding (You Do): Ask questions and test together
5. Verify at Checkpoints
After each major component (module, workflow, API route, etc.): 1. Ask Verification Questions: Test conceptual understanding 2. Review Code: Ask user to share their implementation 3. Test Together: Guide user through testing (commands, cURL, browser) 4. Diagnose Errors: If errors occur, debug together - load troubleshooting guide 5. Proceed Only When Confirmed: Don't move forward until step works
6. Teach Architecture
For every component, explain:
- What it is (definition)
- Why it exists (architectural purpose)
- How it fits in the bigger picture
Use diagrams (ASCII art) liberally.
7. Handle Errors as Teaching Opportunities
When user encounters errors:
- DON'T skip it or say "we'll come back to this"
- DO treat it as a valuable learning moment
- Load relevant troubleshooting guide
- Debug together, asking diagnostic questions
- Explain WHY the error occurred (builds deeper understanding)
8. Answer Questions with MCP
When user asks questions you don't have answers for: 1. Recognize the Gap: "That's a great question! Let me look up the latest information for you." 2. Query MedusaDocs MCP: Use the MedusaDocs MCP server to search 3. Synthesize: Don't just dump docs - explain in context of their learning 4. Continue Teaching: Tie the answer back to the tutorial
Three-Lesson Structure
Lesson 1: Build Custom Features (45-60 min)
Goal: Create Brand Module → createBrandWorkflow → POST /admin/brands API route
Architecture Focus:
- Module → Workflow → API Route pattern
- Why this layered approach? (separation of concerns, reusability, testability)
- Module isolation principles
- Workflows provide rollback and orchestration
Steps: 1. Create Brand Module (data model, service, migrations)
- Load
lessons/lesson-1-custom-features.md - Checkpoint: Module creation verified (
checkpoints/checkpoint-module.md)
2. Create createBrandStep (with compensation function) 3. Create createBrandWorkflow
- Checkpoint: Workflow verified (
checkpoints/checkpoint-workflow.md)
4. Create POST /admin/brands API route 5. Create validation schema + middleware
- Checkpoint: API route tested with cURL, brand created (
checkpoints/checkpoint-api-route.md)
Architecture Deep Dive: Load architecture/module-workflow-route.md when explaining the pattern
Lesson 2: Extend Medusa (45-60 min)
Goal: Link brands to products → Consume productsCreated hook → Query linked data
Architecture Focus:
- Module links maintain isolation while creating relationships
- Workflow hooks allow extending core flows without forking
- Query enables cross-module data retrieval
Steps: 1. Define brand-product module link (with sync)
- Load
lessons/lesson-2-extend-medusa.md - Checkpoint: Link defined, migrations synced (
checkpoints/checkpoint-module-links.md)
2. Consume productsCreated hook to link brand to product 3. Extend POST /admin/products to accept brand_id in additional_data
- Checkpoint: Product created with brand_id (
checkpoints/checkpoint-workflow-hooks.md)
4. Create GET /admin/brands to query brands with products
- Checkpoint: Brands retrieved with linked products (
checkpoints/checkpoint-querying.md)
Architecture Deep Dives:
- Load
architecture/module-isolation.mdwhen explaining links - Load
architecture/workflow-orchestration.mdwhen explaining hooks
Lesson 3: Customize Admin Dashboard (45-60 min)
Goal: Create product brand widget → Create brands UI route
Architecture Focus:
- Admin widgets vs UI routes (when to use each)
- React Query patterns (separate display/modal queries)
- SDK integration for custom routes
Steps: 1. Initialize JS SDK 2. Create product brand widget (show brand on product page)
- Load
lessons/lesson-3-admin-dashboard.md - Checkpoint: Widget visible on product page (
checkpoints/checkpoint-widget.md)
3. Create GET /admin/brands API route with pagination 4. Create brands UI route with DataTable
- Checkpoint: Brands list page functional with pagination (
checkpoints/checkpoint-ui-route.md)
Architecture Deep Dive: Load architecture/admin-integration.md when explaining admin UI
Checkpoint Verification Pattern
After each major component, follow this pattern:
Step 1: Ask Verification Questions
Test conceptual understanding, not just "did it work":
- "What does [X] do?"
- "Why do we use [Y] instead of [Z]?"
- "What would happen if [condition]?"
Step 2: Review Code
Ask user to share their code:
Can you share your [file path] so I can review it?Review for:
- Correct implementation
- Following best practices
- Type safety
- Proper imports
Step 3: Test Together
Guide user through testing:
Let's test this together:
1. Run: [command]
2. Expected output: [description]
3. Share what you seeStep 4: Diagnose Errors
If errors occur: 1. Ask for full error message 2. Load troubleshooting/common-errors.md 3. Ask diagnostic questions:
- "What command did you run?"
- "Can you show me your [related file]?"
- "Did you [prerequisite step]?"
4. Explain root cause 5. Guide fix step-by-step 6. Re-test until working
Step 5: Proceed Only When Confirmed
Don't move forward until:
- [ ] Verification questions answered correctly
- [ ] Code reviewed and correct
- [ ] Tests passing
- [ ] User confirms understanding
Error Handling During Tutorial
When User Encounters Errors
CRITICAL: NEVER skip errors or say "we'll handle this later"
Follow this process:
1. Acknowledge: "Error messages are great teachers! Let's figure this out together."
2. Gather Information:
- Full error message
- Command that was run
- Relevant code files
- What user expected vs what happened
3. Load Troubleshooting: Load troubleshooting/common-errors.md and search for matching error
4. Diagnose Together:
- Ask diagnostic questions
- Review related code
- Check prerequisites
5. Explain Root Cause: "This error occurred because [reason]. Here's what's happening under the hood..."
6. Guide Fix: Step-by-step solution with explanation
7. Verify Fix: Re-test until working
8. Reinforce Learning: "What did we learn from this error?"
Common Error Categories
Load the appropriate troubleshooting section:
- Module Errors: "Cannot find module", "Module name must be camelCase"
- Workflow Errors: "Async function not allowed", "Cannot use await"
- API Route Errors: "401 Unauthorized", "Empty array returned"
- Admin UI Errors: "Cannot find @tanstack/react-query", "Widget not showing"
- Database Errors: "Table already exists", "Migration failed"
Architecture Teaching Strategy
Use the "I Do → We Do → You Do" pattern for each concept:
I Do (Explain)
Before implementing, explain:
What: "A Module is a reusable package of functionality for a single domain."
Why: "Modules are isolated to prevent side effects. If the Brand Module breaks, it won't crash the Product Module."
How: "Modules fit into the architecture like this: [diagram]. They're registered in medusa-config.ts and resolved via dependency injection."
Diagram Example:
┌─────────────────────────────────────────────────┐
│ API Route (HTTP Interface) │
│ - Accepts requests │
│ - Validates input │
│ - Executes workflow │
│ - Returns response │
└─────────────────┬───────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────┐
│ Workflow (Business Logic Orchestration) │
│ - Coordinates steps │
│ - Handles rollback │
│ - Manages transactions │
└─────────────────┬───────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────┐
│ Module (Data Layer) │
│ - Defines data models │
│ - Provides CRUD operations │
│ - Isolated from other modules │
└─────────────────────────────────────────────────┘We Do (Guide)
Guide user through implementation:
Let's create the Brand Module together. I'll explain each step as we go.
**Step 1**: Create the module directory
Run: mkdir -p src/modules/brand/models
This creates the structure Medusa expects. Modules must be in src/modules, and data models must be in a models/ subdirectory.
**Step 2**: Create the data model
Create src/modules/brand/models/brand.ts:
[code with inline comments explaining each part]
Notice how we:
- Use model.define() from the DML
- First arg is table name (snake-case)
- Auto-generates timestampsYou Do (Verify)
Verify understanding through:
Conceptual Questions:
- "Why is the module name 'brand' and not 'brand-module'?"
- "What would happen if you forgot to run migrations?"
Implementation Check:
- "Run npm run build and share any errors"
- "Show me your service.ts file"
Testing:
- "Let's test the module by [test steps]"
Pedagogical Principles
1. Progressive Disclosure
Start simple, add complexity gradually:
- Lesson 1: Simple single-step workflow, basic API route
- Lesson 2: Multi-step scenarios, complex relationships
- Lesson 3: Frontend integration, full-stack picture
2. Active Recall
After each lesson, ask:
- "Can you explain [concept] in your own words?"
- "Why do we use [X] instead of [Y]?"
- "What's the difference between [A] and [B]?"
3. Spaced Repetition
Reinforce concepts across lessons:
- Lesson 1: Introduce Module concept
- Lesson 2: Reinforce Module while teaching Links
- Lesson 3: Briefly mention Module when creating admin
4. Error as Learning
Treat errors as valuable teaching moments:
- Explain WHY the error occurred
- Show the underlying mechanism that failed
- Connect to broader architecture concepts
- "This teaches us that..."
5. Learning by Doing
Build first, understand second:
- Get something working quickly
- Then explain why it works
- Builds momentum and confidence
Session Management
Saving Progress
After each lesson:
Great work completing Lesson [N]! Let's commit your progress:
git add .
git commit -m "Complete Lesson [N]: [description]"
This saves your work. Ready for Lesson [N+1]?Resuming
If user says they're resuming:
Welcome back! Where did we leave off?
Looking at your code, I can see you've completed:
- [✓] Lesson 1
- [ ] Lesson 2
- [ ] Lesson 3
Let's pick up with Lesson 2. Here's a quick refresher on what we built in Lesson 1...Skipping Ahead
If user wants to skip:
I understand you want to jump to Lesson [N]. However, each lesson builds on the previous one:
- Lesson 1 creates the Brand Module (needed for Lesson 2)
- Lesson 2 links brands to products (needed for Lesson 3)
- Lesson 3 displays brands in admin (uses everything from Lessons 1-2)
I recommend completing them in order. But if you've already done some work, show me what you have and I can assess if we can skip ahead.Slowing Down
If user is struggling:
I notice you're encountering a few challenges. That's completely normal - Medusa has a learning curve!
Let's slow down and break this into smaller steps:
[Break current step into 2-3 smaller sub-steps]
Take your time. Understanding is more important than speed.Using MedusaDocs MCP Server
When user asks questions during the tutorial that you don't have answers for, use the MedusaDocs MCP server.
When to Use MCP
- User asks about specific method signatures beyond what's in the tutorial
- User wants to know about advanced configurations
- User asks about features not covered in the tutorial
- User encounters errors not in troubleshooting guide
- User wants more details on a specific concept
How to Use MCP
1. Recognize the Gap: "That's a great question! Let me look up the latest information for you."
2. Query MCP: Use the ask_medusa_question tool from MedusaDocs MCP server
3. Synthesize: Don't just dump the docs - explain in context of their learning:
According to the latest Medusa documentation, [answer].
In the context of what we're building, this means [practical explanation].
For our brands feature, you could use this to [specific application].4. Continue Teaching: Tie the answer back to the tutorial and keep momentum
Example MCP Usage
User: "Can I use TypeScript decorators in my module?"
You: "Great question! Let me check the latest Medusa documentation on that."
[Query MCP: "TypeScript decorators in Medusa modules"]
You: "According to the docs, Medusa modules don't use decorators - they use functional patterns instead. Here's why: [explanation from docs + your teaching context]
This actually relates to what we're building because [connection to tutorial].
Ready to continue with the workflow?"Summary
As Claude, you are a patient, thorough coding bootcamp instructor teaching Medusa development. Your goals:
1. Interactive: Guide step-by-step, verifying at checkpoints 2. Architecture-Focused: Teach WHY, not just WHAT 3. Error-Friendly: Treat errors as teaching opportunities 4. Hands-On: Build a real feature together 5. Progressive: Start simple, build complexity gradually 6. Adaptive: Use MCP to answer questions beyond tutorial scope 7. Supportive: Encourage, explain, and ensure understanding
Remember: Understanding > Completion. Better to go slower and ensure deep learning than rush through and leave gaps.
Good luck, and happy teaching!
Architecture Deep Dive: Admin Dashboard Integration
The Medusa Admin dashboard is a React application that connects to your backend API. Understanding how to extend it with widgets and UI routes is essential for building complete features.
Admin Dashboard Architecture
┌─────────────────────────────────────────────────┐
│ Admin Dashboard (React + Vite) │
│ Running at: http://localhost:9000/app │
│ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ Widgets │ │ UI Routes │ │
│ │ (Inject) │ │ (New Pages) │ │
│ └───────┬──────┘ └───────┬──────┘ │
│ │ │ │
│ └────────┬──────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ JS SDK │ │
│ └──────┬───────┘ │
└──────────────────┼─────────────────────────────┘
│ HTTP Requests
▼
┌─────────────────────────────────────────────────┐
│ Backend API (Node.js) │
│ Running at: http://localhost:9000 │
│ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ API Routes │ │ Workflows │ │
│ └──────┬───────┘ └───────┬──────┘ │
│ │ │ │
│ └──────────┬─────────┘ │
│ ▼ │
│ ┌──────────────┐ │
│ │ Modules │ │
│ └──────────────┘ │
└─────────────────────────────────────────────────┘Widgets vs. UI Routes
Widgets: Extend Existing Pages
What: React components injected into existing admin pages at predefined zones
When to use:
- Adding information to existing pages
- Displaying related data
- Extending core entities (products, orders, customers)
Examples:
- Show brand on product detail page
- Show reviews on product detail page
- Show shipping status on order detail page
// Widget Example
import { defineWidgetConfig } from "@medusajs/admin-sdk"
const ProductBrandWidget = ({ data: product }) => {
return <Container>Brand: {product.brand?.name}</Container>
}
export const config = defineWidgetConfig({
zone: "product.details.before", // Where to inject
})
export default ProductBrandWidgetUI Routes: Create New Pages
What: Completely new pages in the admin dashboard
When to use:
- Managing custom entities
- Custom dashboards or reports
- Standalone administrative interfaces
Examples:
- Brands management page
- Reviews management page
- Custom analytics dashboard
// UI Route Example
import { defineRouteConfig } from "@medusajs/admin-sdk"
const BrandsPage = () => {
return (
<Container>
<Heading>Brands</Heading>
<DataTable data={brands} />
</Container>
)
}
export const config = defineRouteConfig({
label: "Brands",
icon: TagSolid,
})
export default BrandsPageKey Differences
| Aspect | Widgets | UI Routes |
|---|---|---|
| Purpose | Extend existing pages | Create new pages |
| Location | Injected into zones | New URLs |
| Navigation | No sidebar entry | Sidebar menu item |
| File path | src/admin/widgets/ | src/admin/routes/ |
| Configuration | defineWidgetConfig() | defineRouteConfig() |
| Props | Receive page entity | No special props |
Widget Integration Patterns
Pattern 1: Display Widget (Read-Only)
Show information from linked entities:
// Product Brand Widget - Display Only
const ProductBrandWidget = ({ data: product }: DetailWidgetProps<AdminProduct>) => {
const { data: queryResult } = useQuery({
queryFn: () => sdk.admin.product.retrieve(product.id, {
fields: "+brand.*", // Include brand relation
}),
queryKey: ["product", product.id, "brand"],
})
const brand = (queryResult?.product as ProductWithBrand)?.brand
return (
<Container className="divide-y p-0">
<div className="flex items-center justify-between px-6 py-4">
<Heading level="h2">Brand</Heading>
</div>
<div className="px-6 py-4">
<Text>{brand?.name || "-"}</Text>
</div>
</Container>
)
}
export const config = defineWidgetConfig({
zone: "product.details.before",
})Key points:
- Uses
DetailWidgetProps<T>for type safety - Receives entity as
dataprop - Uses React Query for data fetching
- Uses
fieldsparameter to include relations
Pattern 2: Interactive Widget (with Actions)
Widget with buttons and user actions:
// Product Brand Widget - With Actions
const ProductBrandWidget = ({ data: product }: DetailWidgetProps<AdminProduct>) => {
const [isEditing, setIsEditing] = useState(false)
const { data: queryResult } = useQuery({
queryFn: () => sdk.admin.product.retrieve(product.id, {
fields: "+brand.*",
}),
queryKey: ["product", product.id, "brand"],
})
const updateMutation = useMutation({
mutationFn: (brandId: string) => {
return sdk.client.fetch(`/admin/products/${product.id}/brand`, {
method: "POST",
body: JSON.stringify({ brand_id: brandId }),
})
},
onSuccess: () => {
queryClient.invalidateQueries(["product", product.id, "brand"])
setIsEditing(false)
},
})
if (isEditing) {
return (
<Container>
<BrandSelector
onSelect={(brandId) => updateMutation.mutate(brandId)}
onCancel={() => setIsEditing(false)}
/>
</Container>
)
}
return (
<Container>
<div className="flex items-center justify-between">
<Heading level="h2">Brand</Heading>
<Button onClick={() => setIsEditing(true)}>Edit</Button>
</div>
<Text>{brand?.name || "-"}</Text>
</Container>
)
}Key points:
- Local state for edit mode
- Uses
useMutationfor updates - Invalidates query cache after mutation
- Separate UI for view/edit modes
Pattern 3: Widget with Modal
Complex forms in a modal:
// Product Brand Widget - With Modal
const ProductBrandWidget = ({ data: product }: DetailWidgetProps<AdminProduct>) => {
const [modalOpen, setModalOpen] = useState(false)
const { data: queryResult } = useQuery({
queryFn: () => sdk.admin.product.retrieve(product.id, {
fields: "+brand.*",
}),
queryKey: ["product", product.id, "brand"],
})
return (
<>
<Container>
<div className="flex items-center justify-between">
<Heading level="h2">Brand</Heading>
<Button onClick={() => setModalOpen(true)}>Change Brand</Button>
</div>
<Text>{brand?.name || "-"}</Text>
</Container>
{modalOpen && (
<ChangeBrandModal
product={product}
currentBrand={brand}
onClose={() => setModalOpen(false)}
/>
)}
</>
)
}UI Route Integration Patterns
Pattern 1: List Page with DataTable
Most common pattern for management pages:
// Brands List Page
import { defineRouteConfig } from "@medusajs/admin-sdk"
import { TagSolid } from "@medusajs/icons"
import { Container, Heading, DataTable, useDataTable } from "@medusajs/ui"
import { useQuery } from "@tanstack/react-query"
import { sdk } from "../../lib/sdk"
const BrandsPage = () => {
const [pagination, setPagination] = useState({
pageSize: 15,
pageIndex: 0,
})
const { data, isLoading } = useQuery({
queryFn: () => sdk.client.fetch(`/admin/brands`, {
query: {
limit: pagination.pageSize,
offset: pagination.pageIndex * pagination.pageSize,
},
}),
queryKey: ["brands", pagination.pageSize, pagination.pageIndex],
})
const table = useDataTable({
columns: [
{ accessor: "id", header: "ID" },
{ accessor: "name", header: "Name" },
{ accessor: "products", header: "Products", cell: (props) => props.getValue()?.length || 0 },
],
data: data?.brands || [],
rowCount: data?.count || 0,
isLoading,
pagination: {
state: pagination,
onPaginationChange: setPagination,
},
})
return (
<Container>
<DataTable instance={table}>
<DataTable.Toolbar>
<Heading>Brands</Heading>
</DataTable.Toolbar>
<DataTable.Table />
<DataTable.Pagination />
</DataTable>
</Container>
)
}
export const config = defineRouteConfig({
label: "Brands",
icon: TagSolid,
})
export default BrandsPageKey points:
- Uses
useDataTablehook for table management - Pagination state managed locally (or in URL for production)
- Uses
sdk.client.fetch()for custom API endpoints - DataTable components for consistent UI
Pattern 2: Detail Page
For viewing/editing individual records:
// Brand Detail Page - src/admin/routes/brands/[id]/page.tsx
import { useParams } from "react-router-dom"
const BrandDetailPage = () => {
const { id } = useParams()
const { data: brand, isLoading } = useQuery({
queryFn: () => sdk.client.fetch(`/admin/brands/${id}`),
queryKey: ["brand", id],
})
if (isLoading) return <Loading />
return (
<Container>
<Heading>{brand.name}</Heading>
<Section title="Details">
<LabeledInput label="Name" value={brand.name} />
<LabeledInput label="Created" value={brand.created_at} />
</Section>
<Section title="Products">
<ProductsList products={brand.products} />
</Section>
</Container>
)
}
export default BrandDetailPageFile structure for nested routes:
src/admin/routes/brands/
├── page.tsx → /app/brands (list)
└── [id]/
└── page.tsx → /app/brands/:id (detail)Pattern 3: Create/Edit Form
For creating or editing records:
// Create Brand Page - src/admin/routes/brands/create/page.tsx
const CreateBrandPage = () => {
const navigate = useNavigate()
const createMutation = useMutation({
mutationFn: (data: { name: string }) => {
return sdk.client.fetch(`/admin/brands`, {
method: "POST",
body: JSON.stringify(data),
})
},
onSuccess: (result) => {
toast.success("Brand created successfully")
navigate(`/brands/${result.brand.id}`)
},
onError: (error) => {
toast.error(`Failed to create brand: ${error.message}`)
},
})
return (
<Container>
<Heading>Create Brand</Heading>
<Form onSubmit={(data) => createMutation.mutate(data)}>
<Input name="name" label="Name" required />
<Button type="submit" isLoading={createMutation.isLoading}>
Create
</Button>
</Form>
</Container>
)
}
export default CreateBrandPageReact Query Patterns
Pattern 1: Separate Queries for Display and Modal
Problem: Widget uses one query, modal uses a different query
Solution: Separate query keys
// In widget - lightweight query for display
const { data: product } = useQuery({
queryFn: () => sdk.admin.product.retrieve(productId, {
fields: "id,title,brand.name", // Only what we need
}),
queryKey: ["product", productId, "widget"], // Different key
})
// In modal - full query for editing
const { data: fullProduct } = useQuery({
queryFn: () => sdk.admin.product.retrieve(productId, {
fields: "*,brand.*,variants.*", // Everything
}),
queryKey: ["product", productId, "modal"], // Different key
enabled: modalOpen, // Only fetch when modal opens
})Pattern 2: Optimistic Updates
Update UI immediately, revert on error:
const updateMutation = useMutation({
mutationFn: (updates) => sdk.client.fetch(`/admin/brands/${brandId}`, {
method: "POST",
body: JSON.stringify(updates),
}),
onMutate: async (updates) => {
// Cancel outgoing queries
await queryClient.cancelQueries(["brand", brandId])
// Snapshot previous value
const previous = queryClient.getQueryData(["brand", brandId])
// Optimistically update
queryClient.setQueryData(["brand", brandId], (old) => ({
...old,
...updates,
}))
return { previous }
},
onError: (err, updates, context) => {
// Revert on error
queryClient.setQueryData(["brand", brandId], context.previous)
},
onSettled: () => {
// Refetch to sync
queryClient.invalidateQueries(["brand", brandId])
},
})Pattern 3: Invalidation After Mutations
Refresh queries after data changes:
const createBrandMutation = useMutation({
mutationFn: (data) => sdk.client.fetch(`/admin/brands`, {
method: "POST",
body: JSON.stringify(data),
}),
onSuccess: () => {
// Invalidate brands list to refetch
queryClient.invalidateQueries(["brands"])
// Also invalidate if product pages show brand
queryClient.invalidateQueries(["products"])
},
})SDK Integration for Custom Routes
SDK Client Fetch Pattern
For custom API endpoints, use sdk.client.fetch():
// Standard Medusa entities - use built-in methods
const product = await sdk.admin.product.retrieve(id)
const products = await sdk.admin.product.list()
// Custom entities - use client.fetch()
const brand = await sdk.client.fetch(`/admin/brands/${id}`)
const brands = await sdk.client.fetch(`/admin/brands`)
// Custom actions - use client.fetch() with method
const result = await sdk.client.fetch(`/admin/brands/${id}/approve`, {
method: "POST",
body: JSON.stringify({ approved: true }),
})SDK Configuration
Initialize once in src/admin/lib/sdk.ts:
import Medusa from "@medusajs/js-sdk"
export const sdk = new Medusa({
baseUrl: import.meta.env.VITE_BACKEND_URL || "/",
debug: import.meta.env.DEV,
auth: {
type: "session", // Important for admin!
},
})Key points:
- Use
import.meta.env(Vite environment variables) - Default to "/" for same-origin requests
- Use "session" auth type for admin
- Enable debug in development
Medusa UI Components
Always use Medusa UI components for consistent styling:
import {
Container,
Heading,
Text,
Button,
Input,
DataTable,
useDataTable,
createDataTableColumnHelper,
} from "@medusajs/ui"
import { TagSolid, PlusSolid } from "@medusajs/icons"Common components:
- Container: Page/section wrapper
- Heading: Page titles
- Text: Body text
- Button: Actions
- Input: Form fields
- DataTable: Tables with pagination/sorting
- IconButton: Icon-only buttons
- Badge: Status indicators
- Toast: Notifications
Zone Reference
Common widget zones:
Product Pages:
product.details.beforeproduct.details.afterproduct.details.side.beforeproduct.details.side.after
Order Pages:
order.details.beforeorder.details.after
Customer Pages:
customer.details.beforecustomer.details.after
Best Practices
1. Query Key Naming
Use consistent, hierarchical naming:
// Good - hierarchical, specific
["product", productId, "brand"]
["brands", limit, offset]
["brand", brandId, "products"]
// Bad - flat, ambiguous
["productBrand"]
["getBrands"]2. Loading States
Always handle loading and error states:
const { data, isLoading, error } = useQuery({ ... })
if (isLoading) return <Spinner />
if (error) return <ErrorMessage error={error} />
return <Content data={data} />3. Type Safety
Type your queries and mutations:
type Brand = {
id: string
name: string
products?: Product[]
}
const { data } = useQuery<{ brands: Brand[] }>({
queryFn: () => sdk.client.fetch(`/admin/brands`),
queryKey: ["brands"],
})
// Now data.brands is typed correctly4. Separate Display and Modal Queries
Don't reuse the same query for different use cases:
// Display query - lightweight
const displayQuery = useQuery({
queryKey: ["entity", id, "display"],
queryFn: () => fetch(`/api/entity/${id}?fields=id,name`),
})
// Modal query - comprehensive
const modalQuery = useQuery({
queryKey: ["entity", id, "modal"],
queryFn: () => fetch(`/api/entity/${id}?fields=*`),
enabled: modalOpen,
})Summary
Admin dashboard integration extends Medusa's UI:
Widgets:
- ✅ Extend existing pages
- ✅ Inject at predefined zones
- ✅ Receive page entity as props
- ✅ Use for related information
UI Routes:
- ✅ Create new pages
- ✅ Add sidebar navigation
- ✅ Use for custom entities
- ✅ Full page control
Key Technologies:
- React Query: Data fetching and caching
- JS SDK: Backend API communication
- Medusa UI: Consistent styling
- Vite: Build tool and dev server
Remember: Admin is a separate React app that communicates with backend via HTTP. Use SDK for API calls, React Query for state management, and Medusa UI for consistent design.
Architecture Deep Dive: Module Isolation
Module isolation is a core principle in Medusa's architecture. Understanding why modules must be isolated and how to work within this constraint is essential for building scalable applications.
What is Module Isolation?
Module isolation means that modules do NOT directly depend on each other's code. They cannot import types, services, or entities from other modules.
❌ WRONG - Direct dependency between modules
┌──────────────┐
│Brand Module │
│ │───imports───▶ ┌──────────────┐
│import Product│ │Product Module│
│from "../product" │ │
└──────────────┘ └──────────────┘
✅ CORRECT - Modules are isolated
┌──────────────┐ ┌──────────────┐
│Brand Module │ │Product Module│
│ │ │ │
│ Isolated │ │ Isolated │
└──────┬───────┘ └──────┬───────┘
│ │
└──────────┬──────────────────┘
▼
┌─────────────┐
│ Link Layer │
│ (Medusa) │
└─────────────┘Why Module Isolation Matters
1. No Circular Dependencies
Without isolation, modules can create circular dependency chains:
❌ Without isolation - Circular dependencies possible
Brand Module ──imports──▶ Product Module
▲ │
│ │
└────────imports─────────┘
Result: Build fails, runtime errors, maintenance nightmareWith isolation, circular dependencies are impossible:
✅ With isolation - No circular dependencies
Brand Module ←─────Link Layer─────▶ Product Module
(Isolated) (Isolated)
Result: Clean architecture, predictable builds2. Independent Development and Testing
Isolated modules can be developed and tested independently:
// Test Brand Module WITHOUT needing Product Module
describe("Brand Module", () => {
it("creates brand", async () => {
const brandService = container.resolve("brand")
const [brand] = await brandService.createBrands([{ name: "Nike" }])
expect(brand.name).toBe("Nike")
})
})
// Test Product Module WITHOUT needing Brand Module
describe("Product Module", () => {
it("creates product", async () => {
const productService = container.resolve("product")
const [product] = await productService.createProducts([{ title: "Shoe" }])
expect(product.title).toBe("Shoe")
})
})Why this matters: You can test Brand Module even if Product Module is broken. Tests are faster and more reliable.
3. Module Extraction and Reusability
Isolated modules can be extracted into separate packages and reused:
Project A: E-commerce Platform
├── @mycompany/brand-module ◀─┐
├── @mycompany/product-module │ Can be extracted
├── @mycompany/review-module │ into npm packages
└── ... │
│
Project B: Marketplace Platform │
├── @mycompany/brand-module ◀─┘ Reused!
├── different-product-module
└── ...Why this matters: Write once, use in multiple projects. Build a library of reusable modules.
4. Versioning and Independent Updates
Isolated modules can be versioned and updated independently:
Brand Module v1.0.0 ──────▶ Brand Module v2.0.0
│ │
│ │ Breaking changes allowed
│ │ because no direct dependencies
▼ ▼
Link Layer ──────────────▶ Link Layer
(Interface stays stable) (Interface stays stable)Why this matters: Update Brand Module without breaking Product Module. Deploy modules independently.
How Module Links Work
Since modules can't import from each other, Medusa provides a link layer to manage relationships:
// ❌ WRONG - Cannot do this!
// In Product Module
import { Brand } from "../brand/models/brand"
interface Product {
brand: Brand // Direct reference to Brand entity
}// ✅ CORRECT - Use Module Links
// Define link (separate from both modules)
export default defineLink(
{
linkable: ProductModule.linkable.product,
isList: true,
},
BrandModule.linkable.brand
)Link Layer Data Flow
1. Application defines link
defineLink(Product, Brand)
│
▼
2. Medusa creates link table in database
┌──────────────────┐
│ link_brand_product│
├──────────────────┤
│ product_id │
│ brand_id │
└──────────────────┘
│
▼
3. Query layer handles joins
query.graph({
entity: "brand",
fields: ["id", "name", "products.*"]
})
↓
SELECT brand.*, product.*
FROM brand
LEFT JOIN link_brand_product ON brand.id = link.brand_id
LEFT JOIN product ON link.product_id = product.idKey insight: Links are managed by Medusa's infrastructure, not by your modules. Modules remain isolated.
Working with Module Isolation
Pattern 1: Query for Linked Data
When you need data from multiple modules, use Query layer:
// In API route (not in module!)
export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
const query = req.scope.resolve("query")
const { data: brands } = await query.graph({
entity: "brand",
fields: ["id", "name", "products.*"],
})
res.json({ brands })
}Why this works: Query layer has access to all modules and links. It orchestrates cross-module queries.
Pattern 2: Workflow Hooks for Cross-Module Logic
When you need to react to events in other modules, use workflow hooks:
// In your application (not in Brand Module!)
import { createProductsWorkflow } from "@medusajs/medusa/core-flows"
createProductsWorkflow.hooks.productsCreated(
async ({ products, additional_data }, { container }) => {
const link = container.resolve("link")
const links = products
.filter((p) => additional_data?.brand_id)
.map((product) => ({
[Modules.BRAND]: { brand_id: additional_data.brand_id },
[Modules.PRODUCT]: { product_id: product.id },
}))
await link.create(links)
return new StepResponse(links, links)
},
async (links, { container }) => {
if (!links?.length) return
const link = container.resolve("link")
await link.dismiss(links)
}
)Why this works: Hook is in your application layer (not in either module). It coordinates between modules without creating dependencies.
Pattern 3: Shared Types via Interfaces
If you need to share types, use interfaces (not concrete types):
// shared/interfaces.ts (not in any module)
export interface IBrand {
id: string
name: string
}
export interface IProduct {
id: string
title: string
brand_id?: string // Reference by ID, not by entity
}
// In Brand Module - implements interface
export const Brand = model.define("brand", {
id: model.id().primaryKey(),
name: model.text(),
})
// Brand entity implements IBrand structurally
// In workflow - uses interface
async function processBrandProducts(brand: IBrand, products: IProduct[]) {
// Works with both modules without importing from them
}Why this works: Interfaces don't create runtime dependencies. Modules implement them structurally without imports.
Anti-Patterns to Avoid
❌ Anti-Pattern 1: Direct Module Imports
// In Product Module service
import { BrandService } from "../brand/service" // ❌ WRONG!
class ProductService extends MedusaService(Product) {
async createProductWithBrand(data) {
const brandService = new BrandService() // ❌ Direct dependency!
const brand = await brandService.getBrand(data.brand_id)
// ...
}
}Fix: Use dependency injection and link layer:
// In workflow (application layer)
createWorkflow("create-product-with-brand", function (input) {
const product = createProductStep(input.product)
const link = linkProductToBrandStep({
productId: product.id,
brandId: input.brand_id,
})
return new WorkflowResponse({ product, link })
})❌ Anti-Pattern 2: Shared Entity Types
// brand/models/brand.ts
export const Brand = model.define("brand", { ... })
// product/models/product.ts
import { Brand } from "../brand/models/brand" // ❌ WRONG!
export const Product = model.define("product", {
id: model.id(),
title: model.text(),
brand: Brand, // ❌ Direct entity reference!
})Fix: Use module links:
// product/models/product.ts - NO brand reference
export const Product = model.define("product", {
id: model.id(),
title: model.text(),
// No brand field! Relationship is in link layer
})
// links/brand-product.ts - Relationship defined separately
export default defineLink(
{ linkable: ProductModule.linkable.product, isList: true },
BrandModule.linkable.brand
)❌ Anti-Pattern 3: Cross-Module Transactions
// In Brand Module service
class BrandService extends MedusaService(Brand) {
async createBrandWithProducts(brandData, productData) {
const brand = await this.createBrands([brandData])
// ❌ WRONG! Brand Module shouldn't know about Product Module
const productService = this.container.resolve("product")
const products = await productService.createProducts(productData)
return { brand, products }
}
}Fix: Use workflow to orchestrate:
// In workflow (application layer)
export const createBrandWithProductsWorkflow = createWorkflow(
"create-brand-with-products",
function (input) {
const brand = createBrandStep(input.brand)
const products = createProductsStep(input.products)
const links = linkProductsToBrandStep({
brandId: brand.id,
productIds: products.map((p) => p.id),
})
return new WorkflowResponse({ brand, products, links })
}
)Real-World Example: Order with Custom Brand Requirements
Scenario: When an order is placed, you need to validate that all products are from approved brands.
❌ WRONG Approach - Breaking Module Isolation
// In Order Module (❌ WRONG!)
import { BrandService } from "../brand/service"
class OrderService extends MedusaService(Order) {
async createOrder(data) {
const brandService = new BrandService() // ❌ Direct dependency!
for (const item of data.items) {
const product = await this.getProduct(item.product_id)
const brand = await brandService.getBrand(product.brand_id)
if (!brand.is_approved) {
throw new Error("Brand not approved")
}
}
return this.createOrders([data])
}
}Problems:
- ❌ Order Module depends on Brand Module
- ❌ Order Module depends on Product Module
- ❌ Can't test Order Module without Brand Module
- ❌ Can't extract Order Module to separate package
✅ CORRECT Approach - Maintaining Module Isolation
// In workflow (application layer)
const validateBrandApprovalStep = createStep(
"validate-brand-approval",
async (input, { container }) => {
const query = container.resolve("query")
const { data: products } = await query.graph({
entity: "product",
fields: ["id", "brand.*"],
filters: { id: input.productIds },
})
for (const product of products) {
if (product.brand && !product.brand.is_approved) {
throw new Error(`Brand ${product.brand.name} is not approved`)
}
}
return new StepResponse(true)
}
)
export const createOrderWorkflow = createWorkflow(
"create-order",
function (input) {
const productIds = input.items.map((item) => item.product_id)
// Step 1: Validate brand approval
validateBrandApprovalStep({ productIds })
// Step 2: Create order (Order Module isolated)
const order = createOrderStep(input)
return new WorkflowResponse(order)
}
)Benefits:
- ✅ Order Module remains isolated
- ✅ Brand validation is in workflow (application layer)
- ✅ Each module can be tested independently
- ✅ Modules can be extracted to separate packages
Summary
Module isolation is fundamental to building scalable, maintainable Medusa applications:
Key Principles:
- ✅ Modules NEVER import from other modules
- ✅ Use link layer for relationships
- ✅ Use Query layer for cross-module reads
- ✅ Use workflow hooks for cross-module writes
- ✅ Keep business logic in workflows, not modules
Benefits:
- ✅ No circular dependencies
- ✅ Independent development and testing
- ✅ Module extraction and reusability
- ✅ Independent versioning and updates
Remember: Isolation is a feature, not a limitation. It enables scalability, testability, and maintainability at the cost of slightly more indirection.
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?
1. Step 3 fails (Slack API down) 2. Medusa calls step 2's compensation: Delete logo from S3 3. Medusa calls step 1's compensation: Delete brand from database 4. 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: 1. ❌ Business logic in HTTP layer - not reusable 2. ❌ Manual rollback - error-prone and hard to maintain 3. ❌ Can't test business logic without HTTP 4. ❌ Multiple concerns mixed (HTTP, business logic, error handling) 5. ❌ 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: 1. ✅ Business logic in workflow - reusable from HTTP, GraphQL, CLI, jobs 2. ✅ Automatic rollback - Medusa handles compensation 3. ✅ Each layer testable independently 4. ✅ Clean separation of concerns 5. ✅ 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: 1. Step 4 compensation: Unlink collections 2. Step 3 compensation: Deallocate inventory 3. Step 2 compensation: Delete pricing 4. 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
1. Routes are thin: They only parse requests and return responses 2. Workflows orchestrate: They coordinate steps but don't implement them 3. Modules encapsulate: They own their data and provide CRUD operations 4. Compensation is mandatory: Every step that creates/modifies data MUST have compensation 5. 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.
Architecture Deep Dive: Workflow Orchestration
Workflows are Medusa's orchestration layer - they coordinate steps, manage transactions, and provide automatic rollback. Understanding workflow orchestration is essential for building robust, reliable applications.
What is Workflow Orchestration?
Workflow orchestration means coordinating multiple operations into a cohesive business process with automatic rollback capabilities.
Simple Operation (No Orchestration)
┌─────────────┐
│ Action │ ← Single operation, no coordination
└─────────────┘
Orchestrated Workflow
┌─────────────────────────────────────────────────┐
│ Workflow (Orchestrator) │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Step 1 │→→→│ Step 2 │→→→│ Step 3 │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌────────┐ ┌────────┐ ┌────────┐ │
│ │Rollback│ │Rollback│ │Rollback│ │
│ │Step 1 │◀◀◀◀◀│Step 2 │◀◀◀◀◀│Step 3 │ │
│ └────────┘ └────────┘ └────────┘ │
└─────────────────────────────────────────────────┘Why Workflows Instead of Direct Service Calls?
❌ Problem: Direct Service Calls
// Without workflows - Manual coordination and rollback
async function createBrandWithLogo(brandData, logoFile) {
let brand
let logoUrl
try {
// Step 1: Create brand
const brandService = container.resolve("brand")
brand = await brandService.createBrands([brandData])
// Step 2: Upload logo
const s3Service = container.resolve("s3Service")
logoUrl = await s3Service.upload(logoFile)
// Step 3: Update brand with logo URL
await brandService.updateBrands([{
id: brand.id,
logo_url: logoUrl,
}])
return { brand, logoUrl }
} catch (error) {
// Manual rollback - Error-prone!
if (brand) {
try {
await brandService.deleteBrands([brand.id])
} catch (rollbackError) {
// What if rollback fails? Data is now inconsistent!
console.error("Rollback failed:", rollbackError)
}
}
if (logoUrl) {
try {
await s3Service.delete(logoUrl)
} catch (rollbackError) {
// Orphaned file in S3!
console.error("S3 cleanup failed:", rollbackError)
}
}
throw error
}
}Problems: 1. ❌ Manual rollback logic - easy to make mistakes 2. ❌ No guaranteed cleanup - rollback can fail 3. ❌ Code duplication - same pattern repeated everywhere 4. ❌ Hard to test - must test success and all failure scenarios 5. ❌ Hard to extend - adding new steps requires updating rollback logic
✅ Solution: Workflow Orchestration
// With workflows - Automatic coordination and rollback
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",
async (input, { container }) => {
const s3Service = container.resolve("s3Service")
const logoUrl = await s3Service.upload(input.logo)
return new StepResponse(logoUrl, logoUrl)
},
async (logoUrl, { container }) => {
if (!logoUrl) return
const s3Service = container.resolve("s3Service")
await s3Service.delete(logoUrl)
}
)
const updateBrandLogoStep = createStep(
"update-brand-logo",
async (input, { container }) => {
const brandService = container.resolve("brand")
await brandService.updateBrands([{
id: input.brandId,
logo_url: input.logoUrl,
}])
return new StepResponse("updated", { brandId: input.brandId, previousLogoUrl: null })
},
async (compensationData, { container }) => {
const brandService = container.resolve("brand")
await brandService.updateBrands([{
id: compensationData.brandId,
logo_url: compensationData.previousLogoUrl,
}])
}
)
export const createBrandWithLogoWorkflow = createWorkflow(
"create-brand-with-logo",
function (input) {
const brand = createBrandStep(input)
const logoUrl = uploadLogoStep({ logo: input.logo })
updateBrandLogoStep({
brandId: brand.id,
logoUrl: logoUrl,
})
return new WorkflowResponse({ brand, logoUrl })
}
)
// Use it
const { result } = await createBrandWithLogoWorkflow(container)
.run({ input: { name: "Nike", logo: file } })Benefits: 1. ✅ Automatic rollback - Medusa handles compensation 2. ✅ Guaranteed cleanup - all or nothing 3. ✅ No code duplication - compensation defined once per step 4. ✅ Easy to test - test steps independently 5. ✅ Easy to extend - add new steps, compensation happens automatically
Workflow Architecture
Declarative vs. Imperative
Key insight: Workflows are DECLARATIVE, not IMPERATIVE.
// ❌ WRONG - Imperative (trying to execute)
createWorkflow("wrong", async function (input) {
const result = await someStep(input) // ❌ Using await!
return result
})
// ✅ CORRECT - Declarative (defining flow)
createWorkflow("correct", function (input) {
const result = someStep(input) // ✅ No await! Just defining flow
return new WorkflowResponse(result)
})Why?
Workflows define what happens, not how it happens:
Workflow Definition (What) Workflow Execution (How)
┌────────────────────┐ ┌──────────────────────┐
│ function (input) { │ │ Engine executes: │
│ step1(input) │──────▶ │ 1. Calls step1 │
│ step2(step1) │ │ 2. Waits for result │
│ step3(step2) │ │ 3. Calls step2 │
│ return response │ │ 4. Waits for result │
│ } │ │ 5. Calls step3 │
└────────────────────┘ │ 6. Returns response │
└──────────────────────┘You define the flow synchronously. The engine executes it asynchronously.
Step Composition Patterns
Pattern 1: Sequential Steps
Each step depends on the previous step's output:
createWorkflow("sequential", function (input) {
const brand = createBrandStep(input.brand)
const product = createProductStep({
title: input.productTitle,
brand_id: brand.id, // Uses output from previous step
})
const inventory = allocateInventoryStep({
product_id: product.id, // Uses output from previous step
quantity: input.quantity,
})
return new WorkflowResponse({ brand, product, inventory })
})Execution order: step1 → step2 → step3 (sequential)
Rollback order (if step3 fails): compensate(step2) → compensate(step1)
Pattern 2: Conditional Steps
Use when() for conditional execution:
import { createWorkflow, when } from "@medusajs/framework/workflows-sdk"
createWorkflow("conditional", function (input) {
const brand = createBrandStep(input.brand)
// Only send notification if brand is premium
when({ brand }, ({ brand }) => {
return brand.is_premium
}).then(() => {
sendPremiumNotificationStep(brand)
})
return new WorkflowResponse(brand)
})Pattern 3: Transform Data
Use transform() to shape data between steps:
import { createWorkflow, transform } from "@medusajs/framework/workflows-sdk"
createWorkflow("transform-example", function (input) {
const brands = createMultipleBrandsStep(input.brands)
// Transform array of brands to just their IDs
const brandIds = transform({ brands }, ({ brands }) => {
return brands.map(b => b.id)
})
const products = createProductsStep({
products: input.products,
brand_ids: brandIds, // Use transformed data
})
return new WorkflowResponse({ brands, products })
})Compensation Function Patterns
Pattern 1: Simple Delete
Most common pattern - delete what was created:
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])
}
)Pattern 2: Restore Previous State
For updates, restore the previous value:
const updateBrandStep = createStep(
"update-brand",
async (input, { container }) => {
const brandService = container.resolve("brand")
// Get current brand to save its state
const [currentBrand] = await brandService.retrieveBrands([input.id])
// Update brand
const [updatedBrand] = await brandService.updateBrands([{
id: input.id,
name: input.name,
}])
// Return updated brand as result, current brand for compensation
return new StepResponse(updatedBrand, {
id: currentBrand.id,
previousName: currentBrand.name,
})
},
async (compensationData, { container }) => {
if (!compensationData) return
const brandService = container.resolve("brand")
// Restore previous name
await brandService.updateBrands([{
id: compensationData.id,
name: compensationData.previousName,
}])
}
)Pattern 3: No Compensation Needed
Read-only operations don't need compensation:
const getBrandStep = createStep(
"get-brand",
async (input, { container }) => {
const brandService = container.resolve("brand")
const [brand] = await brandService.retrieveBrands([input.id])
return new StepResponse(brand)
}
// No compensation function - read-only operation
)Pattern 4: External Service Compensation
Clean up external resources:
const uploadToS3Step = createStep(
"upload-to-s3",
async (input, { container }) => {
const s3Service = container.resolve("s3Service")
const result = await s3Service.upload(input.file)
return new StepResponse(result.url, {
url: result.url,
bucket: result.bucket,
key: result.key,
})
},
async (compensationData, { container }) => {
if (!compensationData) return
const s3Service = container.resolve("s3Service")
// Delete file from S3
await s3Service.deleteObject({
bucket: compensationData.bucket,
key: compensationData.key,
})
}
)Real-World Example: Complex Order Workflow
Here's a real-world scenario showing workflow orchestration:
export const createOrderWorkflow = createWorkflow(
"create-order",
function (input) {
// Step 1: Validate inventory (read-only, no compensation)
const inventoryCheck = validateInventoryStep(input.items)
// Step 2: Create order
const order = createOrderStep({
customer_id: input.customer_id,
items: input.items,
})
// Step 3: Reserve inventory (parallel with payment)
const reservation = reserveInventoryStep({
order_id: order.id,
items: input.items,
})
// Step 4: Process payment (parallel with inventory)
const payment = processPaymentStep({
order_id: order.id,
amount: input.amount,
payment_method: input.payment_method,
})
// Step 5: Send confirmation (only after payment succeeds)
when({ payment }, ({ payment }) => payment.status === "succeeded")
.then(() => {
sendOrderConfirmationStep({
order_id: order.id,
customer_email: input.customer_email,
})
})
// Step 6: Allocate to warehouse
const allocation = allocateToWarehouseStep({
order_id: order.id,
items: input.items,
warehouse_id: input.warehouse_id,
})
return new WorkflowResponse({ order, payment, reservation, allocation })
}
)What happens if payment fails (step 4)?
Medusa automatically executes compensations in reverse order:
1. allocateToWarehouseStep compensation: Deallocate (if it ran) 2. sendOrderConfirmationStep compensation: N/A (didn't run due to when()) 3. processPaymentStep compensation: Refund (if captured) or void authorization 4. reserveInventoryStep compensation: Release inventory reservation 5. createOrderStep compensation: Delete order or mark as cancelled 6. validateInventoryStep compensation: N/A (read-only)
Result: Clean database. No orphaned data. Customer not charged. Inventory not reserved.
Workflow Hooks
Hooks allow you to inject custom logic into existing workflows:
Why Hooks?
You want to extend Medusa's core workflows without forking the code.
// Core Medusa workflow
export const createProductsWorkflow = createWorkflow(
"create-products",
function (input) {
const products = createProductsStep(input)
// Hook point: productsCreated
// Your custom code runs here
return new WorkflowResponse(products)
}
)
// Your application - Subscribe to hook
createProductsWorkflow.hooks.productsCreated(
async ({ products, additional_data }, { container }) => {
// Your custom logic
const link = container.resolve("link")
if (additional_data?.brand_id) {
await link.create({
[Modules.BRAND]: { brand_id: additional_data.brand_id },
[Modules.PRODUCT]: { product_id: products[0].id },
})
}
return new StepResponse("done")
},
async (compensationData, { container }) => {
// Your custom compensation
if (compensationData?.linkId) {
const link = container.resolve("link")
await link.dismiss([compensationData.linkId])
}
}
)Benefits:
- ✅ Extends core functionality without modifying Medusa code
- ✅ Your logic participates in automatic rollback
- ✅ Upgrade safe - hooks continue to work across Medusa versions
- ✅ Multiple subscribers - multiple hooks can run at the same point
Anti-Patterns to Avoid
❌ Anti-Pattern 1: Using Async/Await in Workflow Function
// ❌ WRONG
createWorkflow("wrong", async function (input) {
const result = await someStep(input) // ❌ Async/await not allowed!
return result
})
// ✅ CORRECT
createWorkflow("correct", function (input) {
const result = someStep(input) // ✅ Synchronous definition
return new WorkflowResponse(result)
})Why: Workflows are declarative blueprints. Using async/await means executing during definition, which breaks the orchestration model.
❌ Anti-Pattern 2: Missing Compensation for State Changes
// ❌ WRONG - No compensation for state change
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! Brand remains if workflow fails
)
// ✅ CORRECT
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])
}
)❌ Anti-Pattern 3: Business Logic in Workflow Function
// ❌ WRONG - Logic in workflow function
createWorkflow("wrong", function (input) {
const brand = createBrandStep(input)
// ❌ Business logic in workflow function
if (brand.name.startsWith("Nike")) {
const premiumBrand = { ...brand, is_premium: true }
return new WorkflowResponse(premiumBrand)
}
return new WorkflowResponse(brand)
})
// ✅ CORRECT - Logic in steps
createWorkflow("correct", function (input) {
const brand = createBrandStep(input)
// Conditional step based on brand data
when({ brand }, ({ brand }) => brand.name.startsWith("Nike"))
.then(() => {
markAsPremiumStep(brand.id)
})
return new WorkflowResponse(brand)
})❌ Anti-Pattern 4: Direct Database Access in Workflows
// ❌ WRONG - Direct database access
createWorkflow("wrong", function (input) {
const brand = someStepThatDirectlyQueriesDB(input) // ❌ DB access outside module
return new WorkflowResponse(brand)
})
// ✅ CORRECT - Database access in steps, steps use modules
const createBrandStep = createStep(
"create-brand",
async (input, { container }) => {
// ✅ Use module service
const brandService = container.resolve("brand")
const [brand] = await brandService.createBrands([input])
return new StepResponse(brand, brand.id)
},
async (brandId, { container }) => {
const brandService = container.resolve("brand")
await brandService.deleteBrands([brandId])
}
)Summary
Workflow orchestration is essential for building robust Medusa applications:
Key Concepts:
- Workflows coordinate - They compose steps into business processes
- Steps execute - They perform atomic operations
- Compensation undoes - Automatic rollback on failure
- Declarative definition - Define flow, don't execute
- Hooks extend - Add custom logic to core workflows
Benefits:
- Automatic rollback (all or nothing)
- Guaranteed cleanup (no orphaned data)
- Code reusability (workflows callable from anywhere)
- Easy testing (test steps independently)
- Easy extension (add steps without rewriting)
Patterns:
- Sequential: step2 uses step1's output
- Parallel: independent steps run concurrently
- Conditional:
when()for branching logic - Transform: shape data between steps
Remember: Workflows are the orchestration layer. They coordinate (don't execute), compose (don't implement), and guarantee cleanup (automatic rollback).
Checkpoint 1.3: Brand API Route
This checkpoint verifies that you've successfully created the POST /admin/brands API route with validation and middleware.
Verification Questions
Before proceeding, test your understanding:
1. Why do we execute workflows from API routes instead of calling services directly? <details> <summary>Answer</summary>
Workflows provide orchestration, rollback, and transaction management. If you call services directly from routes, you have to manually handle rollback logic when errors occur. Workflows handle this automatically through compensation functions. This becomes crucial as your business logic grows more complex with multiple steps. </details>
2. What does `validateAndTransformBody` middleware do? <details> <summary>Answer</summary>
It validates incoming request body against a Zod schema BEFORE your route handler runs. If validation fails, it automatically returns a 400 error with validation details. If validation succeeds, it transforms the data according to the schema and passes the validated data to your handler. This ensures your handler only receives valid data. </details>
3. Why do we use `MedusaRequest` and `MedusaResponse` instead of Express types? <details> <summary>Answer</summary>
These are Medusa-specific types that extend Express types with additional properties like scope (for dependency injection) and queryConfig (for filtering/pagination). Using these types gives you type-safe access to Medusa-specific features. </details>
4. What is the `scope` object and how does it work? <details> <summary>Answer</summary>
scope is Medusa's dependency injection container scoped to the current request. You pass it to workflows when executing them (e.g., workflow(req.scope).run()), and use it to resolve services (e.g., scope.resolve("query")). Each request gets its own scope, ensuring proper isolation and allowing request-specific configuration. </details>
Implementation Check
Let me verify your implementation. Please share the following:
1. Schema File
Show me your src/api/admin/brands/validators.ts file.
Key things to check:
- [ ] Imports
zfrom "zod" - [ ] Defines
CreateBrandSchemawithz.object() - [ ] Has
namefield withz.string() - [ ] Exports schema as named export
2. Route File
Show me your src/api/admin/brands/route.ts file.
Key things to check:
- [ ] Imports types:
MedusaRequest,MedusaResponse - [ ] Imports workflow:
import { createBrandWorkflow } from "..." - [ ] Imports workflow input type:
CreateBrandWorkflowInput - [ ] Defines
POSTfunction (must be namedPOSTexactly) - [ ] Uses type:
MedusaRequest<CreateBrandWorkflowInput> - [ ] Executes workflow:
await createBrandWorkflow(req.scope).run({ input: ... }) - [ ] Extracts brand from result:
result.resultorresult.brand - [ ] Returns JSON:
res.json({ brand }) - [ ] Handles errors with try/catch
3. Middleware File
Show me your src/api/middlewares.ts file.
Key things to check:
- [ ] Imports
defineMiddlewares,validateAndTransformBody - [ ] Imports
CreateBrandSchema - [ ] Exports
default defineMiddlewares() - [ ] Has
routesarray - [ ] Route config has
matcher: "/admin/brands" - [ ] Route config has
method: "POST" - [ ] Route config has
middlewaresarray withvalidateAndTransformBody()
4. Server Running
Start your dev server:
npm run devExpected output: Server should start without errors. Check that there are no errors about missing routes or middleware.
Common Issues
Middleware not running / validation not working
Symptom: Invalid data passes through without validation errors
Cause: Middleware not configured correctly
Fix: 1. Check that matcher exactly matches your route: "/admin/brands" 2. Check that method is uppercase: "POST" 3. Ensure middlewares.ts is in the correct location: src/api/middlewares.ts 4. Restart dev server after middleware changes
"Empty array returned" or "brand is undefined"
Symptom: API returns empty response or undefined brand
Cause: Not extracting brand from workflow result correctly
Fix: Workflow results are nested:
const { result } = await workflow.run({ input: req.validatedBody })
const brand = result.result // Note: double .result
res.json({ brand })The first .result is the workflow execution result, the second .result is from WorkflowResponse(brand).
Route not found / 404 error
Symptom: cURL returns 404
Cause: File not in correct location or not named correctly
Fix: 1. Ensure file is at: src/api/admin/brands/route.ts 2. Ensure function is exported as POST (not default export) 3. Restart dev server 4. Check URL is correct: http://localhost:9000/admin/brands
"Workflow failed" with no specific error
Symptom: Generic workflow failure
Cause: Error in step execution (likely in createBrandStep)
Fix: 1. Check server logs for detailed error message 2. Verify brand service is accessible in the step 3. Verify database connection is working 4. Check that migrations ran successfully
TypeScript error: "Property 'validatedBody' does not exist"
Symptom: Build fails with TS error
Cause: Missing type for validated body
Fix: Use generic type parameter:
export const POST = async (
req: MedusaRequest<CreateBrandWorkflowInput>,
res: MedusaResponse
) => {
const input = req.validatedBody // TypeScript knows this is CreateBrandWorkflowInput
}Testing Checklist
Verify each of these steps:
- [ ] Server starts without errors
- [ ] POST request to
/admin/brandssucceeds - [ ] Response contains brand object with id and name
- [ ] Invalid request (missing name) returns 400 error
- [ ] Brand is actually saved (check with GET request or database query)
- [ ] Build succeeds:
npm run build
Manual Database Verification (Optional)
If you want to verify the brand was actually saved:
# Connect to your database
psql your_database_name
# Query brands table
SELECT * FROM brand;You should see the Nike brand you created.
Architecture Understanding
At this point, you should understand the full three-layer pattern:
┌─────────────────────────────────────────────────┐
│ API Route (HTTP Interface) │
│ - Validates input │
│ - Executes workflow │
│ - Returns response │
└─────────────────┬───────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────┐
│ Workflow (Business Logic Orchestration) │
│ - Coordinates steps │
│ - Handles rollback │
│ - Manages transactions │
└─────────────────┬───────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────┐
│ Module (Data Layer) │
│ - Provides CRUD operations │
│ - Isolated from other modules │
└─────────────────────────────────────────────────┘Why this matters:
- Separation of concerns: Each layer has a single responsibility
- Reusability: Workflow can be called from multiple routes (HTTP, GraphQL, CLI)
- Testability: Each layer can be tested independently
- Maintainability: Changes to one layer don't affect others
Next Steps
Once this checkpoint passes:
1. Lesson 1 Complete! You've built a complete feature from scratch:
- Brand Module (data layer)
- createBrandWorkflow (business logic with rollback)
- POST /admin/brands (HTTP interface with validation)
2. Commit your work:
git add .
git commit -m "Complete Lesson 1: Build custom brand feature"3. Next: Lesson 2 - Extend Medusa
- Link brands to products using Module Links
- Extend core workflows using Workflow Hooks
- Query linked data across modules
Ready for Lesson 2? This is where it gets really interesting - you'll learn how to extend Medusa's core functionality without forking the codebase.
Checkpoint 2.1: Module Links
This checkpoint verifies that you've successfully defined a module link between Brand and Product modules and synced it to the database.
Verification Questions
Before proceeding, test your understanding:
1. Why do we use module links instead of directly importing from another module? <details> <summary>Answer</summary>
Module links maintain module isolation - modules don't depend on each other's code. The Brand Module doesn't import Product entities, and Product Module doesn't import Brand entities. This prevents circular dependencies and allows modules to be developed, tested, and deployed independently. Links are managed by Medusa's linking layer, not by direct module-to-module references. </details>
2. What does `isList: true` mean in a link definition? <details> <summary>Answer</summary>
isList: true means "one brand can have many products". Without it (or with isList: false), the relationship would be one-to-one. In our case, we want one brand (e.g., "Nike") to link to multiple products (shoes, shirts, etc.), so we use isList: true. </details>
3. What is the purpose of `BrandModule.linkable.brand`? <details> <summary>Answer</summary>
linkable is a configuration object exported from each module that declares which entities can be linked to. BrandModule.linkable.brand tells Medusa "the Brand entity in the Brand Module can be used in links". </details>
4. Why do we put links in `src/links` directory and not inside a module? <details> <summary>Answer</summary>
Links are separate from modules to emphasize module independence. A link is a relationship managed by Medusa's linking layer, not by either module. Keeping links in a separate directory makes it clear that they're infrastructure concerns, not business logic. It also makes it easier to see all relationships in your application at a glance. </details>
Implementation Check
Let me verify your implementation. Please share the following:
1. Brand Module Linkable Configuration
Show me your src/modules/brand/index.ts file.
Key things to check:
- [ ] Exports module with
Module() - [ ] Module has
serviceproperty pointing to BrandService - [ ] Uses
Modules.BRANDconstant for module name (or string "brand")
2. Brand Module Constants
Show me if you created src/modules/brand/types/index.ts for module constants.
Key things to check:
- [ ] Exports
MODULE_NAME = "brand" - [ ] Exports
Modules.BRANDconstant (if using Modules enum)
Note: You can also define the constant directly in index.ts or use a string literal.
3. Link Definition File
Show me your src/links/brand-product.ts file.
Key things to check:
- [ ] Imports
defineLinkfrom "@medusajs/framework/utils" - [ ] Imports
Modulesfrom "@medusajs/framework/utils" (for ProductModule reference) - [ ] Imports
BrandModulefrom "../modules/brand" - [ ] Calls
defineLink()with two arguments - [ ] First argument configures product side:
{
linkable: ProductModule.linkable.product,
isList: true,
}- [ ] Second argument is
BrandModule.linkable.brand - [ ] File has default export:
export default defineLink(...)
4. Database Sync
Run the database sync command:
npx medusa db:sync-linksExpected output: Should show that link was created successfully without errors. You should see output mentioning the brand-product relationship.
5. Build Test
Run build to ensure no TypeScript errors:
npm run buildExpected output: Build should succeed without errors related to links or modules.
Common Issues
"Link sync failed" or "Cannot resolve module"
Symptom: db:sync-links command fails
Cause: Module not registered in medusa-config.ts, or server not recognizing the module
Fix: 1. Verify brand module is in medusa-config.ts modules array 2. Restart dev server: npm run dev 3. Try sync again: npx medusa db:sync-links
Testing Checklist
Verify each of these steps:
- [ ] Link file created in
src/links/directory - [ ]
db:sync-linkscommand succeeds - [ ] Build succeeds without TypeScript errors
- [ ] Dev server starts without link-related errors
Architecture Understanding
At this point, you should understand:
Module Isolation:
┌─────────────┐ ┌──────────────┐
│ Brand │ │ Product │
│ Module │ │ Module │
│ │ │ │
│ - No direct imports between modules │
│ - Each module is independent │
└─────────────┘ └──────────────┘
│ │
└────────┬────────────────┘
│
┌──────▼────────┐
│ Link Layer │
│ (Medusa) │
│ │
│ Manages │
│ relationships│
└───────────────┘Why module links matter:
- Flexibility: Modules can be added/removed without breaking others
- Testability: Test Brand Module without needing Product Module
- Scalability: Modules can be extracted into separate packages
- Versioning: Modules can evolve independently
Next Steps
Once this checkpoint passes:
1. Module Link defined between Brand and Product 2. Database synced with link relationship 3. Next: Consume Workflow Hooks (Part 2 of Lesson 2)
The link is now defined at the infrastructure level. Next, we'll make it functional by consuming the productsCreated workflow hook to automatically link brands to products when products are created.
Ready to continue? Let me know when all checks pass, and we'll move on to workflow hooks.
Checkpoint 1.1: Brand Module
This checkpoint verifies that you've successfully created the Brand Module with its data model, service, and migrations.
Verification Questions
Before proceeding, test your understanding:
1. What does `MedusaService()` do? <details> <summary>Answer</summary>
MedusaService() is a service factory provided by Medusa that generates a service with CRUD methods (create, update, retrieve, list, delete) for your data model. It saves you from writing boilerplate code. </details>
2. Why is the module name "brand" and not "brand-module"? <details> <summary>Answer</summary>
Medusa uses camelCase naming for modules and automatically adds "Module" as a suffix when resolving dependencies. So "brand" becomes "brandModule" internally. Using "brand-module" would result in "brandModuleModule". </details>
3. What would happen if you forgot to run migrations? <details> <summary>Answer</summary>
The brand table wouldn't exist in your database, and any attempt to create or retrieve brands would fail with database errors like "relation 'brand' does not exist". </details>
4. Why do we need to export both the service AND the module from index.ts? <details> <summary>Answer</summary>
Exporting the service makes it available for dependency injection in workflows and API routes. </details>
Implementation Check
Let me verify your implementation. Please share the following:
1. Directory Structure
Run this command and share the output:
ls -R src/modules/brandExpected structure:
src/modules/brand:
index.ts models service.ts
src/modules/brand/models:
brand.ts2. Data Model
Show me your src/modules/brand/models/brand.ts file.
Key things to check:
- [ ] Uses
model.define()with "brand" as first argument (lowercase, snake-case) - [ ] Has
id: model.id().primaryKey() - [ ] Has
name: model.text() - [ ] File is exported as default
3. Service
Show me your src/modules/brand/service.ts file.
Key things to check:
- [ ] Uses
MedusaService(Brand)(capital B for the model import) - [ ] File is exported as default
4. Module Definition
Show me your src/modules/brand/index.ts file.
Key things to check:
- [ ] Exports
BrandServicefrom service.ts - [ ] Uses
Module()with name "brand" (lowercase) - [ ] Exports module as default
5. Configuration
Show me the modules section of your medusa-config.ts.
Key things to check:
- [ ] Includes
resolve: "./modules/brand" - [ ] Has empty
options: {}
6. Migrations
Run this command and share the output:
npx medusa db:migrateExpected output: Should show migration succeeded without errors.
7. Build
Run this command and share any errors:
npm run buildExpected output: Build should succeed. If there are TypeScript errors, share them with me so we can debug together.
Common Issues
"Cannot find module 'brand'"
Symptom: Error when running build or starting server
Cause: Module not registered in medusa-config.ts
Fix: 1. Open medusa-config.ts 2. Add to modules array:
{
resolve: "./modules/brand",
options: {},
}3. Restart dev server
"Module name must be camelCase"
Symptom: Error about module naming convention
Cause: Used "brand-module" or "brandModule" as module name
Fix: Change module name to just "brand" in index.ts:
export default Module("brand", {
service: BrandService,
})Testing Checklist
Verify each of these steps:
- [ ] Migration succeeded without errors
- [ ] Build succeeds without TypeScript errors
- [ ] Module appears in
medusa-config.tsmodules array - [ ] File structure matches expected pattern
- [ ] Data model uses correct DML syntax
- [ ] Service uses MedusaService factory
- [ ] Module exports service
Next Steps
Once this checkpoint passes:
1. Brand Module is created and working 2. Next: Create the Brand Workflow (Part 2 of Lesson 1)
The module provides the data layer. Now we'll build the workflow to orchestrate brand creation with automatic rollback capabilities.
Ready to continue? Let me know when all checks pass, and we'll move on to creating the workflow.
Checkpoint 2.3: Querying Linked Records
This checkpoint verifies that you've successfully created a GET /admin/brands API route that queries brands with their linked products using Query.graph().
Verification Questions
Before proceeding, test your understanding:
1. *Why do we use `+brand.` in the fields parameter?** <details> <summary>Answer</summary>
The + means "include these fields IN ADDITION to the default fields". Without +, you would replace the default fields entirely. The .* means "include all fields from the brand relation". So +brand.* says "give me all default product fields PLUS all brand fields". </details>
2. What does `req.queryConfig` contain? <details> <summary>Answer</summary>
req.queryConfig contains pre-processed query parameters like fields, limit, offset, order, and filters. Middleware parses the query string and transforms it into this structured format. You can pass it directly to query.graph() to apply user-requested filtering and pagination without manually parsing the query string. </details>
3. Why return count, limit, and offset in the API response? <details> <summary>Answer</summary>
This follows REST pagination best practices. The frontend needs this metadata to:
- Show total count: "Showing 10 of 50 brands"
- Implement "Load More" or page navigation
- Calculate total pages:
Math.ceil(count / limit) - Request next page:
offset + limit
Without this metadata, the frontend can't build proper pagination UI. </details>
Implementation Check
Let me verify your implementation. Please share the following:
1. API Route File
Show me your src/api/admin/brands/route.ts file (the updated version with GET handler).
Key things to check:
- [ ] Defines
GETfunction (must be namedGETexactly) - [ ] Uses types:
MedusaRequest,MedusaResponse - [ ] Resolves Query service:
req.scope.resolve("query") - [ ] Calls
query.graph()with: entity: "brand"- Spreads
req.queryConfig - [ ] Destructures result:
{ data: brands, metadata: { count, take, skip } = {} } - [ ] Returns JSON with brands, count, limit (take), offset (skip)
2. Middleware Configuration
Show me the GET /admin/brands middleware configuration in src/api/middlewares.ts.
Key things to check:
- [ ] Imports
createFindParamsfrom "@medusajs/medusa/api/utils/validators" - [ ] Defines
GetBrandsSchema = createFindParams() - [ ] Route configuration:
- Matcher:
"/admin/brands" - Method:
"GET" - Uses
validateAndTransformQuery()with: - Schema:
GetBrandsSchema - Options:
defaultsarray includes brand fields and products relation - Options:
isList: true
Example:
validateAndTransformQuery(
GetBrandsSchema,
{
defaults: ["id", "name", "products.*"],
isList: true,
}
)Common Issues
"Empty array returned" even though brands exist
Symptom: API returns empty brands array
Causes and Fixes:
Cause 1: Entity name incorrect
- Fix: Use
entity: "brand"(lowercase, singular)
Cause 2: Middleware not configured with defaults
- Fix: Add
defaultsto middleware config
Cause 3: Module not registered properly
- Fix: Check
medusa-config.tshas brand module
"metadata is undefined"
Symptom: Error accessing count, take, skip
Cause: query.graph() doesn't return metadata (should always return it)
Fix: Use default values in destructuring:
const {
data: brands,
metadata: { count, take, skip } = {}
} = await query.graph({ ... })
res.json({
brands,
count: count || 0,
limit: take || 15,
offset: skip || 0,
})"products field not included" in response
Symptom: Brand objects don't have products array
Cause: Middleware defaults don't include products
Fix: Add to defaults in middleware:
validateAndTransformQuery(
GetBrandsSchema,
{
defaults: ["id", "name", "products.*"],
isList: true,
}
)"Validation error: invalid query parameter"
Symptom: 400 error when using query parameters
Cause: Middleware not configured or using wrong validator
Fix: Ensure you're using createFindParams():
import { createFindParams } from "@medusajs/medusa/api/utils/validators"
export const GetBrandsSchema = createFindParams()And using validateAndTransformQuery() (not validateAndTransformBody()):
validateAndTransformQuery(GetBrandsSchema, { ... })Products array empty even though links exist
Symptom: brands return but products array is empty
Causes and Fixes:
Cause 1: Link not created properly
- Fix: Check Checkpoint 2.2 - verify links exist in database
Cause 2: products.* not in defaults
- Fix: Add
"products.*"to defaults array
Cause 3: Link direction is backwards
- Fix: Review link definition in Checkpoint 2.1
"Cannot read property 'result' from undefined"
Symptom: Error accessing query result
Cause: Incorrect destructuring of query.graph() result
Fix: Use data for the result array:
const { data: brands } = await query.graph({ ... })
// NOT: const { result: brands }Architecture Understanding
At this point, you should understand:
Two ways to query linked data:
Method 1: Fields Parameter (Simple queries)
// In a service method
product = await productService.retrieve(id, {
fields: "+brand.*"
})Method 2: query.graph() (Complex queries)
// In API routes
const { data } = await query.graph({
entity: "brand",
fields: ["id", "name", "products.*"],
filters: { ... },
pagination: { ... }
})Query.graph() data flow:
Request: GET /admin/brands?limit=10&offset=0
│
▼
┌──────────────┐
│ Middleware │ ← Parses query string
│ validates │ Transforms to queryConfig
└──────┬───────┘
│ req.queryConfig = {
│ fields: ["id", "name", "products.*"],
│ take: 10,
│ skip: 0
│ }
▼
┌──────────────┐
│ Route Handler│
│ query.graph()│ ← Applies queryConfig
└──────┬───────┘
│
▼
┌──────────────┐
│ Database │
│ + Link │ ← Joins brand and product tables
│ Layer │
└──────┬───────┘
│
▼
Response: { brands: [...], count, limit, offset }Why this matters:
- Flexibility: Clients control what data they need
- Performance: Only fetch requested fields
- Pagination: Handle large datasets efficiently
- Consistency: Same query patterns across all entities
Next Steps
Once this checkpoint passes:
1. Lesson 2 Complete! You've extended Medusa's core functionality:
- Module Link defined (brand ↔ product relationship)
- Workflow Hook consuming productsCreated
- Query capability for linked records
2. Commit your work:
git add .
git commit -m "Complete Lesson 2: Extend Medusa with links and hooks"3. Next: Lesson 3 - Customize Admin Dashboard
- Create Widget to show brand on product page
- Create UI Route for brands management page
- Use React Query and Medusa UI components
Ready for Lesson 3? Now that the backend is complete, we'll build the admin UI to manage brands visually.
Checkpoint 3.2: Brands UI Route
This checkpoint verifies that you've successfully created a brands management page with a data table and pagination.
Verification Questions
Before proceeding, test your understanding:
1. How does the file path determine the URL of a UI route? <details> <summary>Answer</summary>
The file structure under src/admin/routes/ maps to URLs under /app/. For example:
src/admin/routes/brands/page.tsx→/app/brandssrc/admin/routes/settings/team/page.tsx→/app/settings/team
The file MUST be named page.tsx (not route.tsx or index.tsx). Nested folders create nested routes. </details>
2. Why do we use `sdk.client.fetch()` instead of `sdk.admin.brand.list()`? <details> <summary>Answer</summary>
sdk.admin.brand.list() doesn't exist because the /admin/brands API route is custom, and the JS SDK only has methods for core API routes. For custom API routes, use sdk.client.fetch() which makes a raw HTTP request to any endpoint. </details>
3. What is the purpose of `defineRouteConfig()` and what happens without it? <details> <summary>Answer</summary>
defineRouteConfig() adds the route to the admin sidebar navigation and customizes its appearance (label, icon). Without it, the route still exists and is accessible by URL, but users wouldn't see a navigation link. They'd have to type the URL manually or have a link from somewhere else. </details>
Implementation Check
Let me verify your implementation. Please share the following:
1. Backend API Route (with GET handler)
Show me your updated src/api/admin/brands/route.ts file with the GET handler.
Key things to check:
- [ ] Defines
GETfunction - [ ] Resolves query service
- [ ] Calls
query.graph()with: entity: "brand"- Spreads
req.queryConfig - [ ] Returns JSON with brands, count, limit, offset
Note: You should have already created this in Checkpoint 2.3. If not, create it now.
2. Backend Middleware Configuration
Show me the GET /admin/brands configuration in src/api/middlewares.ts.
Key things to check:
- [ ] Route matcher:
"/admin/brands" - [ ] Method:
"GET" - [ ] Uses
validateAndTransformQuery()with: GetBrandsSchema(fromcreateFindParams())- Options with
defaultsandisList: true
Note: You should have already created this in Checkpoint 2.3. If not, create it now.
3. UI Route File
Show me your src/admin/routes/brands/page.tsx file.
Key things to check:
- [ ] Imports
defineRouteConfigfrom "@medusajs/admin-sdk" - [ ] Imports icon (e.g.,
TagSolid) from "@medusajs/icons" - [ ] Imports UI components:
Container,Heading,DataTable, etc. from "@medusajs/ui" - [ ] Imports
useQueryfrom "@tanstack/react-query" - [ ] Imports
sdkfrom "../../lib/sdk" - [ ] Imports React hooks:
useState,useMemo - [ ] Defines
Brandtype with id, name, products - [ ] Defines
BrandsResponsetype with brands, count, limit, offset - [ ] Creates columns using
createDataTableColumnHelper<Brand>() - [ ] Defines at least 3 columns: id, name, products (showing count)
- [ ] Component has pagination state:
useState({ pageSize, pageIndex }) - [ ] Calculates offset from pagination state
- [ ] useQuery:
- Calls
sdk.client.fetch()with/admin/brandsand query params - Query key includes limit and offset
- Types response as
BrandsResponse - [ ] Uses
useDataTable()hook with columns, data, rowCount, pagination - [ ] Renders DataTable with Toolbar, Table, and Pagination
- [ ] Exports config with label and icon
- [ ] Default exports component
4. Test: Access UI Route
1. Ensure dev server is running: npm run dev 2. Open admin: http://localhost:9000/app 3. Look for "Brands" in the sidebar navigation
Expected: You should see a "Brands" menu item with the icon you chose.
5. Test: View Brands Page
1. Click the "Brands" menu item 2. View the brands table
Expected:
- Page displays with "Brands" heading
- Table shows columns: ID, Name, Products (count)
- Table shows all brands you've created
- Products column shows the number of products linked to each brand
6. Test: Product Count Accuracy
1. Look at the Products column for each brand 2. Verify the count matches the actual number of products linked
Expected: Count should be accurate (0 for brands with no products, 1+ for brands with products).
Common Issues
Route not showing in sidebar
Symptom: Can't find "Brands" in navigation
Causes and Fixes:
Cause 1: Config not exported
- Fix: Ensure you export config:
export const config = defineRouteConfig({
label: "Brands",
icon: TagSolid,
})Cause 2: File not named correctly
- Fix: Must be named
page.tsx(notroute.tsx)
Cause 3: File not in correct location
- Fix: Should be at
src/admin/routes/brands/page.tsx
"404 Not Found" when accessing /app/brands
Symptom: Clicking link results in 404
Cause: File structure incorrect
Fix: Ensure the structure is:
src/admin/routes/brands/page.tsxNOT:
src/admin/routes/brands.tsx ❌
src/admin/routes/brands/index.tsx ❌Table shows empty / no data
Symptom: Table renders but shows no brands
Causes and Fixes:
Cause 1: Backend API not working
- Fix: Test API directly:
curl http://localhost:9000/admin/brands - If API returns data, issue is in frontend
- If API returns empty, issue is in backend (see Checkpoint 2.3)
Cause 2: Query not fetching data
- Fix: Check browser DevTools Console for errors
- Check Network tab - is request being made?
Cause 3: Data structure mismatch
- Fix: Check that API returns
{ brands: [...] }format - Ensure useQuery is typed as
BrandsResponse
"Cannot read property 'length' of undefined"
Symptom: Runtime error accessing products
Cause: Trying to access products.length when products might be undefined
Fix: Use optional chaining in column definition:
columnHelper.accessor("products", {
header: "Products",
cell: ({ getValue }) => {
const products = getValue()
return products?.length || 0
},
})Pagination not working / always shows same data
Symptom: Clicking next page doesn't change data
Causes and Fixes:
Cause 1: offset not calculated correctly
- Fix: Ensure offset = pageIndex * pageSize
Cause 2: Query key doesn't include pagination
- Fix: Include offset in queryKey:
queryKey: ["brands", limit, offset]Cause 3: Backend not using offset parameter
- Fix: Verify middleware passes offset to query.graph()
"Cannot use sdk.client.fetch"
Symptom: TypeScript error or runtime error
Cause: SDK not initialized
Fix: 1. Ensure src/admin/lib/sdk.ts exists and exports sdk 2. Import correctly: import { sdk } from "../../lib/sdk" 3. Check the number of ../ matches your file structure
Table styling looks broken
Symptom: Table appears unstyled or layout is wrong
Cause: Not using DataTable components correctly
Fix: Use the full DataTable component structure:
<DataTable instance={table}>
<DataTable.Toolbar>
<Heading>Brands</Heading>
</DataTable.Toolbar>
<DataTable.Table />
<DataTable.Pagination />
</DataTable>"Cannot find module '@medusajs/icons'"
Symptom: Import error for icons
Cause: Package not installed
Fix: Icons are included with Medusa admin. Check import:
import { TagSolid } from "@medusajs/icons"If still not working, ensure admin dependencies are installed:
npm installProducts count shows 0 for all brands
Symptom: Table shows 0 products even though links exist
Causes and Fixes:
Cause 1: Backend not including products in response
- Fix: Check middleware defaults include
"products.*"
Cause 2: Links not created
- Fix: Verify links exist (see Checkpoint 2.2)
Cause 3: Column accessing wrong property
- Fix: Ensure column accessor matches API response structure
Route accessible by URL but not in sidebar
Symptom: Can access http://localhost:9000/app/brands but no sidebar link
Cause: Config not exported or exported incorrectly
Fix: Must export config as named export:
export const config = defineRouteConfig({ ... })NOT:
export default defineRouteConfig({ ... }) ❌Testing Checklist
Verify each of these steps:
- [ ] Backend GET /admin/brands API working (test with cURL)
- [ ] Route appears in sidebar navigation with icon
- [ ] Clicking "Brands" navigates to /app/brands
- [ ] Table displays with proper styling
- [ ] Table shows all brands with columns: ID, Name, Products
- [ ] Products column shows accurate count
- [ ] Pagination controls appear (if 15+ brands)
- [ ] Pagination works (can navigate pages)
- [ ] No console errors in browser DevTools
Architecture Understanding
At this point, you should understand:
UI Route structure:
File System URL Sidebar
src/admin/routes/brands/page.tsx → /app/brands → "Brands" link
↓
defineRouteConfig()
- label: "Brands"
- icon: TagSolidData flow for UI routes:
1. User clicks "Brands" in sidebar
│
▼
2. React Router navigates to /app/brands
│
▼
3. BrandsPage component renders
│
▼
4. useQuery fetches data
- sdk.client.fetch("/admin/brands")
- With limit & offset params
│
▼
5. Backend: GET /admin/brands
- Middleware validates query
- Route handler calls query.graph()
- Returns { brands, count, limit, offset }
│
▼
6. Frontend: DataTable renders
- Shows brands in table
- Pagination controls use count & limitComplete feature architecture (all 3 lessons):
┌─────────────────────────────────────────────────┐
│ Admin UI (Lesson 3) │
│ - Widget: Shows brand on product page │
│ - UI Route: Brands management page │
└─────────────────┬───────────────────────────────┘
│ HTTP Requests
▼
┌─────────────────────────────────────────────────┐
│ API Routes (Lesson 1 & 2) │
│ - POST /admin/brands (create) │
│ - GET /admin/brands (list with products) │
└─────────────────┬───────────────────────────────┘
│ Executes
▼
┌─────────────────────────────────────────────────┐
│ Workflows (Lesson 1 & 2) │
│ - createBrandWorkflow (with rollback) │
│ - productsCreated hook (auto-link) │
└─────────────────┬───────────────────────────────┘
│ Uses
▼
┌─────────────────────────────────────────────────┐
│ Modules & Links (Lesson 1 & 2) │
│ - Brand Module (data & service) │
│ - Module Link (brand ↔ product) │
└─────────────────────────────────────────────────┘Next Steps
Once this checkpoint passes:
1. Lesson 3 Complete! You've built a complete admin UI:
- SDK initialized for API calls
- Product Brand Widget on product pages
- Brands UI Route with data table and pagination
2. ALL LESSONS COMPLETE! 🎉 You've built a complete feature:
Backend:
- Brand Module (data model, service)
- createBrandWorkflow (with rollback)
- POST /admin/brands (create brand API)
- Module Link (brand ↔ product)
- Workflow Hook (auto-link on product creation)
- GET /admin/brands (list brands with products)
Frontend:
- Product Brand Widget (show brand on product page)
- Brands UI Route (manage brands with table)
3. Commit your work:
git add .
git commit -m "Complete Lesson 3: Admin dashboard customization"4. What's Next?
You now understand Medusa's architecture and can build custom features independently:
- Module → Workflow → API Route pattern
- Module Links for cross-module relationships
- Workflow Hooks for extending core functionality
- Admin customization with Widgets and UI Routes
Consider building:
- Categories Module (similar to Brand)
- Product Reviews feature
- Wishlists
- Custom shipping methods
- Inventory alerts
Learn more:
- Advanced Workflow Patterns
- Complex Admin Components
- Storefront Integration
- Testing your features
Congratulations! 🎊 You've completed the interactive Medusa learning tutorial. You're now ready to build production features with Medusa.
Checkpoint 2.2: Workflow Hooks
This checkpoint verifies that you've successfully consumed the productsCreated workflow hook to link brands to products and configured additional_data validation.
Verification Questions
Before proceeding, test your understanding:
1. What are workflow hooks and why are they useful? <details> <summary>Answer</summary>
Workflow hooks are injection points in Medusa's core workflows where you can add custom logic. They allow you to extend core functionality (like product creation) without forking Medusa's code. When a core workflow reaches a hook point, it executes all registered hook subscribers, allowing your custom code to run as part of the standard flow. </details>
2. Why do we need both a step function AND a compensation function in the hook? <details> <summary>Answer</summary>
Hook subscribers are treated as workflow steps, which means they need compensation for rollback. If product creation succeeds and the link is created, but a later step fails (e.g., inventory allocation), the compensation function removes the link to maintain data consistency. This ensures links are only persisted when the entire product creation succeeds. </details>
3. What is `additional_data` and why do we use it? <details> <summary>Answer</summary>
additional_data is a flexible object in Medusa's core workflows that allows you to pass custom data without modifying core workflow types. For product creation, we use it to pass brand_id from the API request to our hook subscriber. This is the standard pattern for extending core workflows with custom parameters. </details>
4. Why do we need to configure `additional_data` validation in middleware? <details> <summary>Answer</summary>
Without validation configuration, Medusa won't allow brand_id in the request body - it would be filtered out or cause validation errors. The additionalDataValidator in middleware tells Medusa "it's okay to accept brand_id in additional_data" and validates it against your schema before the request reaches the workflow. </details>
Implementation Check
Let me verify your implementation. Please share the following:
1. Hook Subscriber File
Show me your src/workflows/hooks/product-brand-link.ts file (or wherever you defined the hook).
Key things to check:
- [ ] Imports
createProductsWorkflowfrom "@medusajs/medusa/core-flows" - [ ] Imports
StepResponsefrom "@medusajs/framework/workflows-sdk" - [ ] Imports
ContainerRegistrationKeysfrom "@medusajs/framework/utils" - [ ] Calls
createProductsWorkflow.hooks.productsCreated() - [ ] Hook has async step function:
async ({ products, additional_data }, { container }) => { ... } - [ ] Hook has async compensation function:
async (links, { container }) => { ... } - [ ] Step function:
- Resolves link service:
container.resolve(ContainerRegistrationKeys.LINK) - Extracts brand_id from additional_data
- Creates links using
link.create() - Returns
new StepResponse(links, links) - [ ] Compensation function:
- Checks if links exist:
if (!links?.length) return - Resolves link service
- Dismisses links:
link.dismiss(links)
2. Middleware Configuration
Show me your src/api/middlewares.ts file (specifically the POST /admin/products configuration).
Key things to check:
- [ ] Imports
createFindParams,createOperatorMapfrom "@medusajs/medusa/api/utils/validators" - [ ] Defines
CreateProductSchemaor similar with Zod - [ ] Schema includes
additional_datafield:
additional_data: z.object({
brand_id: z.string().optional(),
}).optional()- [ ] Route configuration:
- Matcher:
"/admin/products" - Method:
"POST" - Uses
validateAndTransformBody()with schema andadditionalDataValidator - Example:
validateAndTransformBody(CreateProductSchema, {
additionalDataValidator: {
brand_id: z.string(),
},
})3. Test: Create Product with Brand
With dev server running, test creating a product with brand_id:
curl -X POST http://localhost:9000/admin/products \
-H "Content-Type: application/json" \
-d '{
"title": "Air Max 90",
"additional_data": {
"brand_id": "brand_..."
}
}'Replace `brand_...` with an actual brand ID from your database (use the Nike brand you created in Lesson 1).
Expected output: Product should be created successfully with a product ID.
Common Issues
"Hook not executing" / Link not created
Symptom: Product is created but link doesn't exist
Causes and Fixes:
Cause 1: Hook file not in the right location
- Fix: Ensure file is in
src/workflows/directory (Medusa auto-discovers hooks here) - Fix: Restart dev server after creating hook file
Cause 2: brand_id not passed in request
- Fix: Include
additional_data: { brand_id: "..." }in POST body
Cause 3: additional_data validation not configured
- Fix: Check middleware configuration (see below)
Cause 4: Hook has syntax errors
- Fix: Check server logs for errors
"Validation error: additional_data not allowed"
Symptom: 400 error when posting with additional_data
Cause: Middleware not configured to accept additional_data
Fix: In src/api/middlewares.ts, add configuration for POST /admin/products:
{
matcher: "/admin/products",
method: "POST",
middlewares: [
validateAndTransformBody(
CreateProductSchema,
{
additionalDataValidator: {
brand_id: z.string(),
},
}
),
],
}Testing Checklist
Verify each of these steps:
- [ ] Hook file created in
src/workflows/directory - [ ] Server starts without hook-related errors
- [ ] Middleware configured for POST /admin/products with additionalDataValidator
- [ ] Build succeeds:
npm run build
Architecture Understanding
At this point, you should understand:
How hooks extend core workflows:
Core Workflow: createProductsWorkflow
┌─────────────────────────────────────┐
│ 1. Validate input │
│ 2. Create products │
│ 3. → HOOK: productsCreated ← │ ← Your custom logic runs here
│ ↳ Your hook: Link to brand │
│ 4. Handle inventory │
│ 5. Publish events │
└─────────────────────────────────────┘Why this matters:
- No forking: You don't modify Medusa's code
- Upgrade safe: Your hooks continue to work when Medusa updates
- Composable: Multiple hooks can subscribe to the same point
- Rollback included: Your hook gets automatic compensation
Example: If inventory allocation (step 4) fails: 1. Medusa calls your hook's compensation function 2. Your hook removes the brand-product link 3. Medusa calls product creation compensation 4. Product is deleted from database 5. Everything is rolled back - all or nothing
Next Steps
Once this checkpoint passes:
1. Module Link defined 2. Workflow Hook consuming productsCreated 3. Next: Query Linked Records (Part 3 of Lesson 2)
The link is now created automatically when products are created. Next, we'll learn how to query linked data to retrieve brands with their products and vice versa.
Ready to continue? Let me know when all checks pass, and we'll move on to querying linked records.