Subchapter 2.2
architecture/module-isolation.mdMarkdown13 KBView on GitHub
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.
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) │
└─────────────┘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 buildsIsolated 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.
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.
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.
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
)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.
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.
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.
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.
// 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 })
})// 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
)// 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 })
}
)Scenario: When an order is placed, you need to validate that all products are from approved brands.
// 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:
// 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:
Module isolation is fundamental to building scalable, maintainable Medusa applications:
Key Principles:
Benefits:
Remember: Isolation is a feature, not a limitation. It enables scalability, testability, and maintainability at the cost of slightly more indirection.