Subchapter 2.13
lessons/lesson-1-custom-features.mdMarkdown30 KBView on GitHub
By the end of this lesson, you will:
Time: 45-60 minutes
Before we start coding, let’s understand why Medusa uses this layered architecture.
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 │
└─────────────────────────────────────────────────┘Separation of Concerns: Each layer has one responsibility
Reusability: Workflows can be called from:
Testability: Each layer can be tested independently
Consistency: All features follow the same pattern
Documentation: Learn more about Medusa Architecture (opens in a new tab)
In this lesson, we’ll build a brands feature that allows admin users to create brands via an API endpoint.
Features:
brand table in the databaseBy 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!
A Module is a reusable package of functionality for a single domain. Think of it as a mini-application within Medusa that:
Medusa comes with built-in modules like:
We’re creating a Brand Module for managing brands.
Documentation: Modules Guide (opens in a new tab)
Create the directory structure for the Brand Module:
mkdir -p src/modules/brand/modelsWhy this structure?
src/modules/models/ subdirectoryA 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", { ... }):
id: model.id().primaryKey():
name: model.text():
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 (opens in a new tab)
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 countYou 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 (opens in a new tab)
Every module must export a definition that tells Medusa:
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” ✗
Export BRAND_MODULE constant: Makes it easy to reference reliably elsewhere
Module() creates the definition: Registers the service with Medusa
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
},
],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
brand tablesrc/migrations/db:migrate: Runs all pending migrations
brand table with columns: id, name, created_at, updated_at, deleted_atDocumentation: Migrations Guide (opens in a new tab)
Before proceeding, let’s verify the module is working.
Answer these to test your understanding:
What does MedusaService() do?
Why is the module name “brand” and not “brand-module”?
What happens if you forget to run migrations?
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“Cannot find module ‘brand’”
medusa-config.ts{ resolve: "./src/modules/brand" } to modules array“Module name must be camelCase”
BRAND_MODULE“Table brand already exists”
Build errors
npm run build)medusa-config.tsA 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:
Documentation: Workflows Guide (opens in a new tab)
A step is the atomic unit of work in a workflow. Each step has:
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)
}compensationData from StepResponse (brand ID)Key Concept: The Medusa Container
The Medusa container is a dependency injection container that holds:
You access them via container.resolve():
const brandService = container.resolve("brand")
const logger = container.resolve("logger")
const link = container.resolve("link")Documentation: Workflow Steps (opens in a new tab) | Medusa Container (opens in a new tab)
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:
For runtime logic, use:
when() - Conditional step executiontransform() - Data transformationparallelize() - Parallel executionCommon Mistake: Using async or await
// ❌ WRONG
const brand = await createBrandStep(input) // No await!
// ✅ CORRECT
const brand = createBrandStep(input) // Step returns immediatelyDocumentation: Workflows (opens in a new tab)
Why can’t you use await in the workflow constructor?
What does the compensation function do?
Why pass brand.id as the second parameter to StepResponse?
Check build succeeds:
npm run buildExpected: No TypeScript errors
Show me your file:
src/workflows/create-brand.ts“Async function not allowed”
async keyword in workflow constructorasync:
// ❌ Wrong
createWorkflow("name", async (input) => { ... })
// ✅ Correct
createWorkflow("name", function (input) { ... })“Cannot use await”
await to call stepawait:
// ❌ Wrong
const brand = await createBrandStep(input)
// ✅ Correct
const brand = createBrandStep(input)“Arrow functions not allowed”
function keyword:
// ❌ Wrong
createWorkflow("name", (input) => { ... })
// ✅ Correct
createWorkflow("name", function (input) { ... })npm run build)function, not arrow functionasync keyword in workflow constructorawait when calling stepsAn API Route is a REST endpoint that exposes your features to clients:
Key Principle: Routes are THIN
All business logic belongs in workflows!
Documentation: API Routes Guide (opens in a new tab)
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?
z.string(): Name must be a stringz.infer: Extracts TypeScript type from schemaWhy separate file?
Documentation: API Validation Guide (opens in a new tab)
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) => { ... }POST /admin/brands2. Request Type:
req: MedusaRequest<PostAdminCreateBrandType>MedusaRequest<T>: Type-safe request objectT is the validated body typereq.validatedBody3. 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 workflow4. Return Response:
res.json({ brand: result })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 (opens in a new tab)
Middlewares are functions that run before the route handler. They’re useful for:
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: [...] })src/api/middlewares.ts2. Route Configuration:
{
matcher: "/admin/brands", // Route path
method: "POST", // HTTP method
middlewares: [...] // Middlewares to apply
}3. Validation Middleware:
validateAndTransformBody(PostAdminCreateBrand)req.validatedBody if validation succeedsCommon Mistake: Typo in filename
middlewares.ts (plural)middleware.ts (singular)Why is business logic in workflows, not routes?
What happens if validation fails?
Why pass req.scope to the workflow?
Check build succeeds:
npm run buildShow me your files:
src/api/admin/brands/validators.tssrc/api/admin/brands/route.tssrc/api/middlewares.tsNow 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"
}
}401 Unauthorized
/auth/user/emailpassEmpty array returned []
middleware.ts instead of middlewares.tssrc/api/middlewares.ts (plural)400 Validation error
{ "name": "Acme" } with correct JSON500 Server error
Congratulations! You just built a complete custom feature in Medusa:
Architecture:
Modules:
Workflows:
API Routes:
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:
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:
Workflows solve this by:
2. What happens if there’s an error creating the brand?
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”)?
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)
}
)Save your progress:
git add .
git commit -m "Complete Lesson 1: Brand Module, Workflow, and API Route"In Lesson 2: Extend Medusa, you’ll learn how to:
You’ll be able to:
POST /admin/products with additional_data: { brand_id: "..." }GET /admin/products/:id?fields=+brand.*GET /admin/brands returning linked productsDocumentation: Module Links (opens in a new tab) | Workflow Hooks (opens in a new tab) | Query Guide (opens in a new tab)
When you’re ready, let me know and we’ll start Lesson 2!
This file