Lesson 1: Build Custom Features with Medusa
Learning Objectives
By the end of this lesson, you will:
- Understand the Module → Workflow → API Route architecture
- Create a Brand Module with data model and service
- Implement createBrandWorkflow with rollback logic
- Expose POST /admin/brands API route with validation
- Test your custom feature using cURL
Time: 45-60 minutes
Architecture Overview: The Three-Layer Pattern
Before we start coding, let's understand why Medusa uses this layered architecture.
The Pattern
Every custom feature in Medusa follows this flow:
┌─────────────────────────────────────────────────┐
│ 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 │
└─────────────────────────────────────────────────┘Why This Pattern?
Separation of Concerns: Each layer has one responsibility
- API routes handle HTTP concerns (validation, serialization)
- Workflows handle business logic (orchestration, rollback)
- Modules handle data (CRUD, database)
Reusability: Workflows can be called from:
- Multiple API routes
- Other workflows
- Scheduled jobs
- Event subscribers
Testability: Each layer can be tested independently
Consistency: All features follow the same pattern
Documentation: Learn more about Medusa Architecture
What We're Building
In this lesson, we'll build a brands feature that allows admin users to create brands via an API endpoint.
Features:
- Create a
brandtable in the database - Provide methods to manage brands (create, retrieve, update, delete)
- Expose POST /admin/brands endpoint to create brands
- Include validation and error handling
- Add rollback logic if errors occur
By the end, you'll be able to:
curl -X POST 'http://localhost:9000/admin/brands' \
-H 'Authorization: Bearer {token}' \
--data '{ "name": "Acme" }'And get back:
{
"brand": {
"id": "brand_123",
"name": "Acme",
"created_at": "2024-01-16T...",
"updated_at": "2024-01-16T..."
}
}Let's start!
Part 1: Create the Brand Module
What is a Module?
A Module is a reusable package of functionality for a single domain. Think of it as a mini-application within Medusa that:
- Defines data models (tables in the database)
- Provides a service with CRUD methods
- Is isolated from other modules (no direct dependencies)
Medusa comes with built-in modules like:
- Product Module: Manages products, variants, options
- Cart Module: Manages shopping carts
- Customer Module: Manages customers
We're creating a Brand Module for managing brands.
Documentation: Modules Guide
Step 1.1: Create Module Directory
Create the directory structure for the Brand Module:
mkdir -p src/modules/brand/modelsWhy this structure?
- Modules MUST be in
src/modules/ - Data models MUST be in a
models/subdirectory - Medusa auto-discovers modules in this structure
Step 1.2: Create Brand Data Model
A data model represents a table in the database. We use Medusa's Data Model Language (DML) to define it.
Create src/modules/brand/models/brand.ts:
import { model } from "@medusajs/framework/utils"
export const Brand = model.define("brand", {
id: model.id().primaryKey(),
name: model.text(),
})Let's break this down:
model.define("brand", { ... }):- First arg: Table name in database (use snake-case)
- Second arg: Schema definition (columns)
id: model.id().primaryKey():- Creates a primary key column
- Auto-generates unique IDs
name: model.text():- Creates a text column for the brand name
What about timestamps?
Medusa automatically adds created_at, updated_at, and deleted_at columns!
What about linkable()?
Don't add .linkable() manually - Medusa adds it automatically. This is a common mistake!
Documentation: Data Models Guide
Step 1.3: Create Module Service
The service is the interface to your module's functionality. It provides methods to manage your data models.
Create src/modules/brand/service.ts:
import { MedusaService } from "@medusajs/framework/utils"
import { Brand } from "./models/brand"
class BrandModuleService extends MedusaService({
Brand,
}) {
// Methods are auto-generated! No code needed here.
}
export default BrandModuleServiceWhat's happening here?
MedusaService({ Brand }) generates these methods automatically:
createBrands(data)- Create one or more brandsretrieveBrand(id, config)- Get a brand by IDlistBrands(filters, config)- List brands with filtersupdateBrands(id, data)- Update a branddeleteBrands(id)- Delete a brandsoftDeleteBrands(id)- Soft delete (sets deleted_at)restoreBrands(id)- Restore soft-deleted brandlistAndCountBrands(filters, config)- List with total count
You get all of these for free!
Can you add custom methods? Yes! Add them inside the class body. But for basic CRUD, the generated methods are sufficient.
Documentation: Service Factory Reference
Step 1.4: Export Module Definition
Every module must export a definition that tells Medusa:
- The module's name
- The module's main service
Create src/modules/brand/index.ts:
import { Module } from "@medusajs/framework/utils"
import BrandModuleService from "./service"
export const BRAND_MODULE = "brand"
export default Module(BRAND_MODULE, {
service: BrandModuleService,
})Key points:
Module name MUST be camelCase: "brand" ✓, "brand-module" ✗
- Using dashes will cause runtime errors!
Export
BRAND_MODULEconstant: Makes it easy to reference reliably elsewhereModule()creates the definition: Registers the service with Medusa
Step 1.5: Register Module in Config
Medusa needs to know about your custom module. Add it to medusa-config.ts:
module.exports = defineConfig({
// ... existing config
modules: [
{
resolve: "./src/modules/brand",
},
],
})What if I already have a modules array? Add your module to the existing array:
modules: [
{
resolve: "./src/modules/existing",
},
{
resolve: "./src/modules/brand", // Add this
},
],Step 1.6: Generate and Run Migrations
A migration is a file that defines database changes. It ensures your module is reusable and makes team collaboration smooth.
Run these commands:
npx medusa db:generate brand
npx medusa db:migrateWhat do these do?
db:generate brand: Creates a migration file for the Brand Module- Looks at your data models
- Generates SQL to create the
brandtable - Saves it in
src/migrations/
db:migrate: Runs all pending migrations- Executes the SQL against your database
- Creates the
brandtable with columns:id,name,created_at,updated_at,deleted_at
Documentation: Migrations Guide
Checkpoint 1.1: Verify Module Creation
Before proceeding, let's verify the module is working.
Verification Questions
Answer these to test your understanding:
What does
<details> <summary>Click to reveal answer</summary> It generates CRUD methods for your data models automatically. </details>MedusaService()do?Why is the module name "brand" and not "brand-module"?
<details> <summary>Click to reveal answer</summary> Module names must be camelCase. Dashes cause runtime resolution errors. </details>What happens if you forget to run migrations?
<details> <summary>Click to reveal answer</summary> The `brand` table won't exist in the database, so service methods will fail. </details>
Implementation Check
Run these commands and share the output:
Check migrations succeeded:
npx medusa db:migrateExpected: "No pending migrations" or "Migrations complete"
Check build succeeds:
npm run buildExpected: No TypeScript errors
Show me your files:
src/modules/brand/models/brand.tssrc/modules/brand/service.tssrc/modules/brand/index.ts
Common Issues
"Cannot find module 'brand'"
- Cause: Module not registered in
medusa-config.ts - Fix: Add
{ resolve: "./src/modules/brand" }to modules array
"Module name must be camelCase"
- Cause: Used dashes in module name
- Fix: Use "brand" not "brand-module" in
BRAND_MODULE
"Table brand already exists"
- Cause: Migration already run or table manually created
- Fix: Drop the table or use a different name
Build errors
- Check all imports are correct
- Ensure TypeScript is happy with your code
- Share the error message for help debugging
Testing Checklist
- Migration succeeded without errors
- Build succeeds (
npm run build) - Module registered in
medusa-config.ts - Service exports properly
Part 2: Create the Brand Workflow
What is a Workflow?
A Workflow orchestrates multiple operations that need to complete together. If any operation fails, the workflow automatically rolls back all previous operations.
Why workflows?
Imagine you're creating a brand AND uploading its logo to S3:
Without Workflow (Fragile):
// Create brand
const brand = await brandService.createBrands({ name: "Acme" })
// Upload logo
await s3.upload(brand.id, logo) // What if this fails?
// Now you have a brand in DB but no logo!
// Manual cleanup required...With Workflow (Robust):
const workflow = createWorkflow("create-brand-with-logo", function (input) {
const brand = createBrandStep(input)
const upload = uploadLogoStep({ brandId: brand.id, logo: input.logo })
return new WorkflowResponse(brand)
})
// If upload fails, workflow automatically:
// 1. Calls uploadLogoStep compensation (cleanup S3)
// 2. Calls createBrandStep compensation (delete brand)
// 3. Returns error
// No orphaned data!Key Benefits:
- Automatic rollback: Compensation functions undo changes
- Transaction safety: All or nothing
- Retry logic: Can retry failed steps
- Composability: Workflows can call other workflows
Documentation: Workflows Guide
Step 2.1: Create Brand Step
A step is the atomic unit of work in a workflow. Each step has:
- A step function (performs the action)
- A compensation function (undoes the action on error)
Create src/workflows/steps/create-brand.ts:
import {
createStep,
StepResponse,
} from "@medusajs/framework/workflows-sdk"
import { BRAND_MODULE } from "../modules/brand"
import BrandModuleService from "../modules/brand/service"
export type CreateBrandStepInput = {
name: string
}
export const createBrandStep = createStep(
"create-brand-step",
async (input: CreateBrandStepInput, { container }) => {
const brandModuleService: BrandModuleService = container.resolve(
BRAND_MODULE
)
const brand = await brandModuleService.createBrands(input)
return new StepResponse(brand, brand.id)
},
async (brandId, { container }) => {
if (!brandId) {
return
}
const brandModuleService: BrandModuleService = container.resolve(
BRAND_MODULE
)
await brandModuleService.deleteBrands(brandId)
}
)Let's break this down:
1. Step Function (2nd parameter):
async (input: CreateBrandStepInput, { container }) => {
// Resolve the Brand Module service from Medusa container
const brandModuleService = container.resolve(BRAND_MODULE)
// Create the brand using the service
const brand = await brandModuleService.createBrands(input)
// Return StepResponse(data, compensationData)
return new StepResponse(brand, brand.id)
}input: Data passed to the stepcontainer: Medusa container - registry of all services, modules, toolscontainer.resolve(): Gets a registered service by nameStepResponse(data, compensationData):data: Returned to the workflow (the brand object)compensationData: Passed to compensation function (brand ID)
2. Compensation Function (3rd parameter):
async (brandId, { container }) => {
if (!brandId) {
return
}
const brandModuleService: BrandModuleService = container.resolve(
BRAND_MODULE
)
await brandModuleService.deleteBrands(brandId)
}- Receives the
compensationDatafrom StepResponse (brand ID) - Undoes what the step did (deletes the brand)
- Called automatically if an error occurs later in the workflow
Key Concept: The Medusa Container
The Medusa container is a dependency injection container that holds:
- Core modules (Product, Cart, Customer, etc.)
- Custom modules (Brand)
- Services (logger, database, etc.)
- Framework tools (Link, Query, etc.)
You access them via container.resolve():
const brandService = container.resolve("brand")
const logger = container.resolve("logger")
const link = container.resolve("link")Documentation: Workflow Steps | Medusa Container
Step 2.2: Create Brand Workflow
Now we compose the step into a workflow:
Create the workflow in src/workflows/create-brand.ts:
import {
createWorkflow,
WorkflowResponse,
} from "@medusajs/framework/workflows-sdk"
import { createBrandStep } from "./steps/create-brand.ts"
type CreateBrandWorkflowInput = {
name: string
}
export const createBrandWorkflow = createWorkflow(
"create-brand",
function (input: CreateBrandWorkflowInput) {
const brand = createBrandStep(input)
return new WorkflowResponse(brand)
}
)CRITICAL: Workflow Constructor Rules
The workflow constructor function has strict constraints:
// ✅ CORRECT
createWorkflow("name", function (input) {
const result = myStep(input) // No await!
return new WorkflowResponse(result)
})
// ❌ WRONG - Will break!
createWorkflow("name", async function (input) { // No async!
const result = await myStep(input) // No await!
if (input.condition) { ... } // No conditionals!
return new WorkflowResponse(result)
})Why these rules?
Workflows are declarative, not imperative. The constructor function:
- Runs at load time, not execution time
- Defines the graph of steps, not the execution
- Cannot have runtime logic (conditionals, loops)
For runtime logic, use:
when()- Conditional step executiontransform()- Data transformationparallelize()- Parallel execution
Common Mistake: Using async or await
// ❌ WRONG
const brand = await createBrandStep(input) // No await!
// ✅ CORRECT
const brand = createBrandStep(input) // Step returns immediatelyDocumentation: Workflows
Checkpoint 1.2: Verify Workflow
Verification Questions
Why can't you use
<details> <summary>Click to reveal answer</summary> The workflow constructor runs at load time to define the step graph, not at execution time. Steps are executed later by the workflow engine. </details>awaitin the workflow constructor?What does the compensation function do?
<details> <summary>Click to reveal answer</summary> It undoes what the step did if an error occurs later in the workflow, maintaining data consistency. </details>Why pass
<details> <summary>Click to reveal answer</summary> This data is passed to the compensation function so it knows which brand to delete if rollback is needed. </details>brand.idas the second parameter toStepResponse?
Implementation Check
Check build succeeds:
npm run buildExpected: No TypeScript errors
Show me your file:
src/workflows/create-brand.ts
Common Issues
"Async function not allowed"
- Cause: Used
asynckeyword in workflow constructor - Fix: Remove
async:// ❌ Wrong createWorkflow("name", async (input) => { ... }) // ✅ Correct createWorkflow("name", function (input) { ... })
"Cannot use await"
- Cause: Used
awaitto call step - Fix: Remove
await:// ❌ Wrong const brand = await createBrandStep(input) // ✅ Correct const brand = createBrandStep(input)
"Arrow functions not allowed"
- Cause: Used arrow function for workflow constructor
- Fix: Use
functionkeyword:// ❌ Wrong createWorkflow("name", (input) => { ... }) // ✅ Correct createWorkflow("name", function (input) { ... })
Testing Checklist
- Build succeeds (
npm run build) - No TypeScript errors
- Workflow uses
function, not arrow function - No
asynckeyword in workflow constructor - No
awaitwhen calling steps
Part 3: Create the API Route
What is an API Route?
An API Route is a REST endpoint that exposes your features to clients:
- Admin dashboard
- Storefront
- Mobile apps
- Third-party integrations
Key Principle: Routes are THIN
- Validate input
- Execute workflow
- Return response
All business logic belongs in workflows!
Documentation: API Routes Guide
Step 3.1: Create Validation Schema
We use Zod to validate request bodies. Create src/api/admin/brands/validators.ts:
import { z } from "@medusajs/framework/zod"
export const PostAdminCreateBrand = z.object({
name: z.string(),
})
export type PostAdminCreateBrandType = z.infer<typeof PostAdminCreateBrand>What's happening?
- Zod schema: Defines expected request body shape
z.string(): Name must be a stringz.infer: Extracts TypeScript type from schema
Why separate file?
- Keeps route file clean
- Makes schemas reusable
- Follows Medusa conventions
Documentation: API Validation Guide
Step 3.2: Create the API Route
The route path is determined by file location. For /admin/brands, create src/api/admin/brands/route.ts:
import {
MedusaRequest,
MedusaResponse,
} from "@medusajs/framework/http"
import { createBrandWorkflow } from "../../../workflows/create-brand"
import { PostAdminCreateBrandType } from "./validators"
export const POST = async (
req: MedusaRequest<PostAdminCreateBrandType>,
res: MedusaResponse
) => {
const { result } = await createBrandWorkflow(req.scope)
.run({
input: req.validatedBody,
})
res.json({ brand: result })
}Let's break this down:
1. Route Handler Export:
export const POST = async (req, res) => { ... }- Export function named after HTTP method (POST, GET, DELETE)
- Medusa automatically registers this as
POST /admin/brands
2. Request Type:
req: MedusaRequest<PostAdminCreateBrandType>MedusaRequest<T>: Type-safe request objectTis the validated body type- Access validated body via
req.validatedBody
3. Execute Workflow:
const { result } = await createBrandWorkflow(req.scope).run({
input: req.validatedBody,
})req.scope: The Medusa container.run(): Executes the workflowinput: Data passed to workflowresult: Data returned by workflow
4. Return Response:
res.json({ brand: result })- Returns JSON response to client
Path Convention:
File path: src/api/admin/brands/route.ts
Route path: POST /admin/brands
File path: src/api/admin/brands/[id]/route.ts
Route path: POST /admin/brands/:id
File path: src/api/store/products/route.ts
Route path: GET /store/productsDocumentation: Route Parameters
Step 3.3: Add Validation Middleware
Middlewares are functions that run before the route handler. They're useful for:
- Validation
- Authentication
- Custom parsing
Medusa provides validateAndTransformBody to validate request bodies using Zod schemas.
Create or update src/api/middlewares.ts:
import {
defineMiddlewares,
validateAndTransformBody,
} from "@medusajs/framework/http"
import { PostAdminCreateBrand } from "./admin/brands/validators"
export default defineMiddlewares({
routes: [
{
matcher: "/admin/brands",
method: "POST",
middlewares: [
validateAndTransformBody(PostAdminCreateBrand),
],
},
],
})What's happening?
1. Define Middlewares:
export default defineMiddlewares({ routes: [...] })- Must export default from
src/api/middlewares.ts - Medusa auto-loads this file
2. Route Configuration:
{
matcher: "/admin/brands", // Route path
method: "POST", // HTTP method
middlewares: [...] // Middlewares to apply
}3. Validation Middleware:
validateAndTransformBody(PostAdminCreateBrand)- Validates request body against Zod schema
- Returns 400 error if validation fails
- Populates
req.validatedBodyif validation succeeds
Common Mistake: Typo in filename
- MUST be
middlewares.ts(plural) - NOT
middleware.ts(singular) - Typo causes middleware to be ignored silently!
Documentation: Middlewares Guide | Validation Middleware
Checkpoint 1.3: Test the API Route
Verification Questions
Why is business logic in workflows, not routes?
<details> <summary>Click to reveal answer</summary> Routes are entry points. Workflows can be reused from multiple routes, scheduled jobs, event subscribers. Keeps logic centralized and testable. </details>What happens if validation fails?
<details> <summary>Click to reveal answer</summary> The `validateAndTransformBody` middleware returns a 400 error with details about what failed. The route handler never runs. </details>Why pass
<details> <summary>Click to reveal answer</summary> `req.scope` is the Medusa container. The workflow needs it to resolve services and modules. </details>req.scopeto the workflow?
Implementation Check
Check build succeeds:
npm run buildShow me your files:
src/api/admin/brands/validators.tssrc/api/admin/brands/route.tssrc/api/middlewares.ts
Test the API
Now let's test the complete feature!
Step 1: Start the development server
npm run devStep 2: Get admin authentication token
Since /admin/brands requires authentication, get a token first:
curl -X POST 'http://localhost:9000/auth/user/emailpass' \
-H 'Content-Type: application/json' \
--data-raw '{
"email": "admin@medusa-test.com",
"password": "supersecret"
}'Replace with your admin email/password.
Don't have an admin user? Create one:
npx medusa user -e admin@test.com -p supersecretStep 3: Create a brand
Using the token from step 2:
curl -X POST 'http://localhost:9000/admin/brands' \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer {token}' \
--data '{
"name": "Acme"
}'Expected Response:
{
"brand": {
"id": "brand_01HQXYZ...",
"name": "Acme",
"created_at": "2024-01-16T10:30:00.000Z",
"updated_at": "2024-01-16T10:30:00.000Z"
}
}Common Issues
401 Unauthorized
- Cause: Token expired or invalid credentials
- Fix: Get fresh token from
/auth/user/emailpass
Empty array returned []
- Cause: Middleware file typo - probably named
middleware.tsinstead ofmiddlewares.ts - Fix: Rename to
src/api/middlewares.ts(plural)
400 Validation error
- Cause: Request body doesn't match Zod schema
- Fix: Ensure you're sending
{ "name": "Acme" }with correct JSON
500 Server error
- Check server logs for details
- Common causes:
- Module not registered in config
- Migration not run
- Workflow syntax error
Testing Checklist
- Dev server running
- Authentication token obtained
- Brand created successfully via cURL
- Response contains brand with ID, name, timestamps
Lesson 1 Complete! 🎉
What You Built
Congratulations! You just built a complete custom feature in Medusa:
- ✅ Brand Module: Data model + auto-generated service
- ✅ createBrandWorkflow: Business logic with rollback
- ✅ POST /admin/brands: API endpoint with validation
- ✅ Tested: Created a brand via cURL
What You Learned
Architecture:
- Module → Workflow → API Route pattern
- Why each layer exists and what it's responsible for
- How they connect together
Modules:
- Data models define database tables
- Services provide CRUD operations
- Modules are isolated and reusable
Workflows:
- Orchestrate multi-step operations
- Provide automatic rollback via compensation functions
- Ensure data consistency
API Routes:
- Expose features to clients
- Validate input via middlewares
- Execute workflows (keep routes thin!)
Architecture Reinforcement
Before moving to Lesson 2, reflect on these questions:
1. Why can't I call brandModuleService directly from the API route?
Think about it, then expand:
<details> <summary>Answer</summary>While you could do:
export const POST = async (req, res) => {
const brandService = req.scope.resolve("brand")
const brand = await brandService.createBrands(req.body)
res.json({ brand })
}Problems:
- No rollback if subsequent operations fail
- Can't reuse logic elsewhere (scheduled jobs, other routes)
- Hard to test
- Violates separation of concerns
Workflows solve this by:
- Providing automatic rollback
- Being reusable from anywhere
- Having clear interfaces
- Being independently testable</details>
2. What happens if there's an error creating the brand?
<details> <summary>Answer</summary>The workflow's compensation function (createBrandStep's 3rd parameter) is called automatically, which deletes the brand. This ensures no orphaned data.
3. Where would I add business validation (e.g., "brand name must be unique")?
<details> <summary>Answer</summary>In a workflow step, NOT the API route!
export const validateBrandNameStep = createStep(
"validate-brand-name",
async ({ name }, { container }) => {
const brandService = container.resolve("brand")
const existing = await brandService.listBrands({ name })
if (existing.length > 0) {
throw new Error("Brand name must be unique")
}
return new StepResponse({ validated: true })
}
)
// Then in workflow:
export const createBrandWorkflow = createWorkflow(
"create-brand",
function (input) {
validateBrandNameStep(input)
const brand = createBrandStep(input)
return new WorkflowResponse(brand)
}
)</details>Commit Your Work
Save your progress:
git add .
git commit -m "Complete Lesson 1: Brand Module, Workflow, and API Route"Ready for Lesson 2?
In Lesson 2: Extend Medusa, you'll learn how to:
- Link brands to products using Module Links (maintain module isolation)
- Extend core workflows using Workflow Hooks (add custom logic to Medusa's workflows)
- Query linked data across modules using Query
You'll be able to:
- Create a product with a brand:
POST /admin/productswithadditional_data: { brand_id: "..." } - Retrieve a product's brand:
GET /admin/products/:id?fields=+brand.* - List all brands with their products:
GET /admin/brandsreturning linked products
Documentation: Module Links | Workflow Hooks | Query Guide
When you're ready, let me know and we'll start Lesson 2!