# Lesson 2: Extend Medusa Core Features
## Learning Objectives
By the end of this lesson, you will:
- **Link** brands to products using Module Links
- **Extend** core workflows using Workflow Hooks
- **Configure** additional_data for custom parameters
- **Query** linked records across modules using Query
**Time**: 45-60 minutes
**Prerequisites**: Completed Lesson 1 (Brand Module, createBrandWorkflow, POST /admin/brands)
## What We're Building
In Lesson 1, you created a brands system. Now we'll integrate it with Medusa's core Product Module:
**New capabilities**:
- Associate a brand with a product when creating it
- Retrieve a product with its brand details
- List all brands with their associated products
- Maintain module isolation (no direct dependencies!)
**By the end**, you'll be able to:
```bash
# Create product with brand
curl -X POST 'http://localhost:9000/admin/products' \
--data '{
"title": "Acme Widget",
"additional_data": { "brand_id": "brand_123" }
}'
# Get product with brand
curl 'http://localhost:9000/admin/products/prod_123?fields=+brand.*'
# Get brand with products
curl 'http://localhost:9000/admin/brands/brand_123'
```
---
## Architecture Overview: Extending Without Breaking
### The Challenge
You want to add brands to products. In other platforms, you might:
```typescript
// ❌ Anti-pattern: Modify core Product Module
// src/modules/product/models/product.ts (DON'T DO THIS!)
export const Product = model.define("product", {
id: model.id().primaryKey(),
title: model.text(),
brand_id: model.text(), // Adding this breaks module isolation!
})
```
**Problems**:
- Core modules shouldn't know about custom modules
- Breaks when Medusa updates
- Can't reuse Brand Module elsewhere
- Violates single responsibility
### The Medusa Solution
Medusa provides three tools to extend core features safely:
1. **Module Links**: Connect data models across modules while maintaining isolation
2. **Workflow Hooks**: Inject custom logic into core workflows
3. **Additional Data**: Pass custom parameters through core API routes
```
┌─────────────────────────────────────────────────┐
│ Product Module (Core) │
│ - Knows nothing about brands │
│ - Remains reusable and isolated │
└─────────────────────────────────────────────────┘
↕ (Module Link)
┌─────────────────────────────────────────────────┐
│ Brand Module (Custom) │
│ - Knows nothing about products │
│ - Remains reusable and isolated │
└─────────────────────────────────────────────────┘
Both connected, both isolated!
```
**Documentation**: [Module Links](https://docs.medusajs.com/learn/fundamentals/module-links) | [Workflow Hooks](https://docs.medusajs.com/learn/fundamentals/workflows/workflow-hooks) | [Additional Data](https://docs.medusajs.com/learn/fundamentals/api-routes/additional-data)
---
## Part 1: Define Module Link
### What is a Module Link?
A **Module Link** creates a relationship between data models of different modules while maintaining module isolation.
**Key properties**:
- Neither module imports from the other
- Link is managed separately in `src/links/`
- Both modules remain reusable
- Relationship is defined declaratively
**Think of it like a junction table**:
- In SQL: A `product_brand` table with `product_id` and `brand_id` columns
- In Medusa: A link definition that creates this automatically
**Documentation**: [Module Links Guide](https://docs.medusajs.com/learn/fundamentals/module-links)
### Step 2.1: Define the Link
Create `src/links/product-brand.ts`:
```typescript
import BrandModule from "../modules/brand"
import ProductModule from "@medusajs/medusa/product"
import { defineLink } from "@medusajs/framework/utils"
export default defineLink(
{
linkable: ProductModule.linkable.product,
isList: true,
},
BrandModule.linkable.brand
)
```
**Let's break this down**:
**1. Import Module Definitions**:
```typescript
import BrandModule from "../modules/brand"
import ProductModule from "@medusajs/medusa/product"
```
- Custom modules: Import from `../modules/[name]`
- Core modules: Import from `@medusajs/medusa/[module-name]`
**2. Access Linkable Property**:
```typescript
ProductModule.linkable.product
BrandModule.linkable.brand
```
- Every module exports a `linkable` property
- Contains link configurations for each data model
- Property name is snake-case model name
**3. Define the Link**:
```typescript
defineLink(
{ linkable: ProductModule.linkable.product, isList: true },
BrandModule.linkable.brand
)
```
**Parameters**:
- First: Product (with `isList: true` - many products per brand)
- Second: Brand
**The `isList` property**:
```
Brand ─── (1 to many) ─── Products
One brand can have many products: isList: true for Product
Each product has one brand: isList: false (default) for Brand
```
**Order matters!** The link configuration order affects how you create links later.
### Step 2.2: Sync Link to Database
Module links are stored in a database table. Run:
```bash
npx medusa db:migrate
```
This:
- Creates a `link_product_brand` table (or similar)
- Stores relationships between product IDs and brand IDs
- Enables querying across modules
---
## Checkpoint 2.1: Verify Module Link
### Verification Questions
1. **Why use Module Links instead of adding `brand_id` to Product model?**
Click to reveal answer
Module Links maintain isolation - Product Module doesn't know about brands, Brand Module doesn't know about products. Both remain reusable.
2. **What does `isList: true` mean?**
Click to reveal answer
Many records of that model can be linked to the other model. One brand can have many products.
3. **What happens if you forget to run migrations?**
Click to reveal answer
The link table won't exist, so creating links will fail.
### Implementation Check
1. **Check migrations succeeded**:
```bash
npx medusa db:migrate
```
Expected: Migration runs successfully
2. **Check build succeeds**:
```bash
npm run build
```
3. **Show me your file**:
- `src/links/product-brand.ts`
### Common Issues
**"Link not found"**
- Check file is in `src/links/` directory
- Ensure proper import paths
**"Migration failed"**
- Check database is running
- Review migration output for errors
### Testing Checklist
- [ ] Link file created in `src/links/`
- [ ] Migrations ran successfully
- [ ] Build succeeds
---
## Part 2: Extend Create Product Workflow
### What are Workflow Hooks?
**Workflow Hooks** are predefined points in core workflows where you can inject custom logic.
**Example**: Medusa's `createProductsWorkflow` has hooks:
- `productsCreated` - Runs after products are created
- `productsUpdated` - Runs after products are updated
- `productsDeleted` - Runs after products are deleted
You can "consume" (listen to) these hooks and perform custom actions.
**Why hooks?**
- Extend core workflows without modifying them
- Keep customizations separate and maintainable
- Workflows remain upgradeable
**Documentation**: [Workflow Hooks Guide](https://docs.medusajs.com/learn/fundamentals/workflows/workflow-hooks)
### Step 2.2: Consume productsCreated Hook
When a product is created, we want to link it to a brand (if brand_id was provided).
Create `src/workflows/hooks/created-product.ts`:
```typescript
import { createProductsWorkflow } from "@medusajs/medusa/core-flows"
import { StepResponse } from "@medusajs/framework/workflows-sdk"
import { Modules } from "@medusajs/framework/utils"
import { LinkDefinition } from "@medusajs/framework/types"
import { BRAND_MODULE } from "../../modules/brand"
import BrandModuleService from "../../modules/brand/service"
createProductsWorkflow.hooks.productsCreated(
async ({ products, additional_data }, { container }) => {
if (!additional_data?.brand_id) {
return new StepResponse([], [])
}
const brandModuleService: BrandModuleService = container.resolve(
BRAND_MODULE
)
// Verify brand exists (throws error if not)
await brandModuleService.retrieveBrand(additional_data.brand_id as string)
// Create links between products and brand
const link = container.resolve("link")
const links: LinkDefinition[] = []
for (const product of products) {
links.push({
[Modules.PRODUCT]: {
product_id: product.id,
},
[BRAND_MODULE]: {
brand_id: additional_data.brand_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)
}
)
```
**Let's break this down**:
**1. Hook Consumption**:
```typescript
createProductsWorkflow.hooks.productsCreated(
async ({ products, additional_data }, { container }) => { ... },
async (links, { container }) => { ... }
)
```
- First parameter: Hook step function
- Second parameter: Compensation function (for rollback)
**2. Hook Input**:
```typescript
{ products, additional_data }
```
- `products`: Array of created products (from workflow)
- `additional_data`: Custom data from API request body
**3. Verify Brand Exists**:
```typescript
await brandModuleService.retrieveBrand(additional_data.brand_id)
```
- Throws error if brand doesn't exist
- Prevents linking to non-existent brands
**4. Create Links**:
```typescript
const link = container.resolve("link")
const links = [{
[Modules.PRODUCT]: { product_id: product.id },
[BRAND_MODULE]: { brand_id: additional_data.brand_id },
}]
await link.create(links)
```
**Link object structure**:
- Keys: Module names (in the order defined in `defineLink`)
- Values: Objects with `{model}_id` properties
**Order matters!** Must match the order in `defineLink`:
```typescript
// In defineLink:
defineLink(ProductModule.linkable.product, BrandModule.linkable.brand)
// In link.create:
{
[Modules.PRODUCT]: { product_id: "..." }, // First
[BRAND_MODULE]: { brand_id: "..." }, // Second
}
```
**5. Compensation Function**:
```typescript
async (links, { container }) => {
const link = container.resolve("link")
await link.dismiss(links)
}
```
- Removes links if an error occurs later
- Maintains data consistency
**Documentation**: [Hook Consumption](https://docs.medusajs.com/learn/fundamentals/workflows/workflow-hooks) | [Link Creation](https://docs.medusajs.com/learn/fundamentals/module-links/link)
### Step 2.3: Configure additional_data
To pass `brand_id` through the create product API route, configure `additional_data` validation.
Update or create `src/api/middlewares.ts`:
```typescript
import {
defineMiddlewares,
validateAndTransformBody, // If already importing
} from "@medusajs/framework/http"
import { z } from "@medusajs/framework/zod"
export default defineMiddlewares({
routes: [
// ... existing routes ...
{
matcher: "/admin/products",
method: ["POST"],
additionalDataValidator: {
brand_id: z.string().optional(),
},
},
],
})
```
**What's happening?**
**`additionalDataValidator`**:
- Configures validation for `additional_data` request body parameter
- Uses Zod schemas for each property
- Properties are passed to workflow hooks
**Why optional?**
- Not all products need a brand
- Allows creating products without brands
**Documentation**: [Additional Data Validation](https://docs.medusajs.com/learn/fundamentals/api-routes/additional-data)
---
## Checkpoint 2.2: Test Creating Product with Brand
### Verification Questions
1. **Why verify the brand exists before creating the link?**
Click to reveal answer
Prevents linking to non-existent brands. If brand doesn't exist, the error is caught early and the workflow rolls back the product creation.
2. **What happens if you don't add a compensation function to the hook?**
Click to reveal answer
If an error occurs after linking, the links won't be removed - you'll have orphaned links in the database.
3. **Why is `brand_id` optional in additionalDataValidator?**
Click to reveal answer
Not all products need a brand. Making it optional allows flexibility.
### Implementation Check
1. **Build succeeds**:
```bash
npm run build
```
2. **Show me your files**:
- `src/workflows/hooks/created-product.ts`
- `src/api/middlewares.ts`
### Test Creating Product with Brand
**Step 1: Create a brand** (if you haven't already):
```bash
curl -X POST 'http://localhost:9000/admin/brands' \
-H 'Authorization: Bearer {token}' \
--data '{ "name": "Acme" }'
```
Save the brand ID from the response.
**Step 2: Get a shipping profile ID** (required for products):
```bash
curl 'http://localhost:9000/admin/shipping-profiles' \
-H 'Authorization: Bearer {token}'
```
**Step 3: Create product with brand**:
```bash
curl -X POST 'http://localhost:9000/admin/products' \
-H 'Authorization: Bearer {token}' \
--data '{
"title": "Acme Widget",
"options": [
{ "title": "Default", "values": ["Default Value"] }
],
"shipping_profile_id": "{shipping_profile_id}",
"additional_data": {
"brand_id": "{brand_id}"
}
}'
```
**Note on prices**: If you're adding prices to your product variants, remember that Medusa stores prices as-is (not in cents). For example, a $19.99 product should have `"amount": 19.99`, not `1999`.
**Expected**: Product created successfully
**Check logs**: You should see "Linked brand to products" or similar
### Common Issues
**"Brand not found"**
- Brand ID is wrong or brand doesn't exist
- Create brand first
**"Hook not running"**
- Check file is in `src/workflows/hooks/`
- Restart dev server
- Check for TypeScript errors
**"Invalid additional_data"**
- Check `additionalDataValidator` is configured correctly
- Ensure `brand_id` is a string
### Testing Checklist
- [ ] Product created with brand_id in additional_data
- [ ] No errors in server logs
- [ ] Product creation succeeds
---
## Part 3: Query Linked Records
### Querying with `fields` Parameter
Medusa's core API routes accept a `fields` query parameter to retrieve linked data.
**Get product with brand**:
```bash
curl 'http://localhost:9000/admin/products/{product_id}?fields=+brand.*' \
-H 'Authorization: Bearer {token}'
```
**Response**:
```json
{
"product": {
"id": "prod_123",
"title": "Acme Widget",
"brand": {
"id": "brand_123",
"name": "Acme",
"created_at": "...",
"updated_at": "..."
}
}
}
```
**The `+brand.*` syntax**:
- `+` = Add to default fields (don't replace)
- `brand` = Linked model name (singular)
- `.*` = All properties of brand
**Documentation**: [Fields Parameter Guide](https://docs.medusajs.com/api/store#select-fields-and-relations)
### Querying with Query.graph()
For custom API routes, use **Query** to retrieve linked records.
**Example**: Create an endpoint to get brands with their products.
Update `src/api/admin/brands/route.ts`:
```typescript
import {
MedusaRequest,
MedusaResponse,
} from "@medusajs/framework/http"
import { createBrandWorkflow } from "../../../workflows/create-brand"
import { PostAdminCreateBrandType } from "./validators"
// Keep existing POST handler...
export const GET = async (
req: MedusaRequest,
res: MedusaResponse
) => {
const query = req.scope.resolve("query")
const { data: brands } = await query.graph({
entity: "brand",
fields: ["*", "products.*"],
})
res.json({ brands })
}
```
**What's happening?**
**1. Resolve Query**:
```typescript
const query = req.scope.resolve("query")
```
- Query is a Medusa framework tool for cross-module queries
- Registered in the container
**2. Query Linked Data**:
```typescript
await query.graph({
entity: "brand",
fields: ["*", "products.*"],
})
```
**Parameters**:
- `entity`: Data model name (as defined in `model.define`)
- `fields`: Array of properties and relations to retrieve
- `"*"` = All properties of brand
- `"products.*"` = All properties of linked products (plural!)
**Why "products" (plural)?**
Because brands are linked to a list of products (`isList: true` in the link definition).
**Documentation**: [Query Guide](https://docs.medusajs.com/learn/fundamentals/query)
---
## Checkpoint 2.3: Test Querying
### Verification Questions
1. **Why use `products.*` (plural) instead of `product.*` (singular)?**
Click to reveal answer
Brands are linked to multiple products (isList: true), so the property name is plural.
2. **What's the difference between `fields` parameter and Query.graph()?**
Click to reveal answer
- `fields` parameter: Use with existing Medusa API routes
- Query.graph(): Use in custom API routes
Both retrieve linked data, different use cases.
### Implementation Check
1. **Build succeeds**:
```bash
npm run build
```
2. **Show me your file**:
- `src/api/admin/brands/route.ts`
### Test Querying
**Test 1: Get product with brand**:
```bash
curl 'http://localhost:9000/admin/products/{product_id}?fields=+brand.*' \
-H 'Authorization: Bearer {token}'
```
**Expected**: Product with `brand` property
**Test 2: Get brands with products**:
```bash
curl 'http://localhost:9000/admin/brands' \
-H 'Authorization: Bearer {token}'
```
**Expected Response**:
```json
{
"brands": [
{
"id": "brand_123",
"name": "Acme",
"products": [
{
"id": "prod_123",
"title": "Acme Widget",
...
}
]
}
]
}
```
### Common Issues
**"Cannot query products"**
- Check link is defined correctly
- Ensure migrations ran
- Verify products are actually linked to brand
**"Brand property missing on product"**
- Forgot `?fields=+brand.*` in query
- Link not created when product was created
### Testing Checklist
- [ ] Product retrieved with brand details
- [ ] Brands retrieved with products
- [ ] Both queries return expected data
---
## Lesson 2 Complete! 🎉
### What You Built
Amazing work! You've extended Medusa's core functionality:
- ✅ **Module Link**: Connected brands to products (maintaining isolation)
- ✅ **Workflow Hook**: Extended createProductsWorkflow to link brands
- ✅ **Additional Data**: Configured validation for custom parameters
- ✅ **Querying**: Retrieved linked data across modules
### What You Learned
**Module Links**:
- Create relationships without breaking module isolation
- Neither module depends on the other
- Links are managed separately
**Workflow Hooks**:
- Extend core workflows without modifying them
- Inject custom logic at predefined points
- Include rollback logic for data consistency
**Additional Data**:
- Pass custom parameters through core API routes
- Validate with Zod schemas
- Accessible in workflow hooks
**Query**:
- Retrieve data across modules
- Use `fields` parameter in core routes
- Use Query.graph() in custom routes
### Architecture Reinforcement
**1. Why not just add `brand_id` column to products table?**
Answer
**Problems with direct column**:
```typescript
// ❌ This breaks module isolation
export const Product = model.define("product", {
brand_id: model.text(),
})
```
- Product Module now knows about brands
- Can't reuse Brand Module elsewhere
- Breaks when Medusa updates Product Module
- Violates single responsibility
**Module Links solve this**:
- Product Module: No knowledge of brands
- Brand Module: No knowledge of products
- Link: Separate concern, easy to maintain
- Both modules remain reusable
**2. How does additional_data reach the workflow hook?**
Answer
Flow:
1. Client sends request: `{ additional_data: { brand_id: "..." } }`
2. Middleware validates: `additionalDataValidator: { brand_id: z.string() }`
3. API route executes workflow
4. Workflow passes to hooks: `{ products, additional_data }`
5. Your hook consumes: `if (additional_data?.brand_id) { ... }`
**3. What happens if brand creation fails after linking?**
Answer
The compensation function runs:
```typescript
async (links, { container }) => {
const link = container.resolve("link")
await link.dismiss(links) // Removes the links
}
```
This ensures no orphaned links exist.
### Commit Your Work
```bash
git add .
git commit -m "Complete Lesson 2: Module Links, Workflow Hooks, and Query"
```
---
## Ready for Lesson 3?
In **Lesson 3: Customize Admin Dashboard**, you'll learn how to:
- **Create widgets** to display brand on product detail page
- **Build UI routes** for managing brands
- **Use React Query** for data fetching
- **Integrate Medusa UI** components
You'll build:
- A widget showing brand name on product pages
- A brands management page with a data table
- Full CRUD operations in the admin dashboard
**Documentation**: [Admin Widgets](https://docs.medusajs.com/learn/fundamentals/admin/widgets) | [Admin UI Routes](https://docs.medusajs.com/learn/fundamentals/admin/ui-routes) | [Medusa UI Components](https://docs.medusajs.com/ui)
When you're ready, let me know and we'll start Lesson 3!