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.