All skills
medusajs avatar

/learning-medusa

@b34cca2 official
by Medusamedusajs/medusa-agent-skills225 stars
29

Load automatically when user asks to learn Medusa development (e.g., "teach me how to build with medusa", "guide me through medusa", "I want to learn medusa"). Interactive guided tutorial where Claude acts as a coding bootcamp instructor, teaching step-by-step with checkpoints and verification.

Use this Skill: https://skilld.dev/gh/medusajs/medusa-agent-skills/learning-medusa

This session only. Nothing lands on disk.

architecturemodule-isolation.md

≈3.4k tokens on demand. Your agent reads this file only when SKILL.md points to it.

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 nightmare

With isolation, circular dependencies are impossible:

✅ With isolation - No circular dependencies
Brand Module ←─────Link Layer─────▶ Product Module
  (Isolated)                         (Isolated)

Result: Clean architecture, predictable builds

2. 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.id

Key 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.

Source: SKILL.md on GitHub

1 alert8d5 checks · Risk SAFE
  • Gen Agent Trust Hub8d

    The skill is an interactive MedusaJS development tutorial that functions as a coding bootcamp instructor. It guides users through creating modules, workflows, and API routes using standard framework components and a specialized documentation MCP server. A low-severity finding is identified regarding indirect prompt injection, as the agent is designed to review user-supplied code snippets as part of the guided learning process.

  • Socket8d

    No alerts

  • Snyk8d

    Risk: LOW · No issues

  • Runlayer7mo

    13/18 files flagged

  • ZeroLeaks5mo

    Score: 93/100 · 2 sections analyzed

Signed by skilld at b34cca2. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub 3 days ago.

Activeupdated 9 months ago

README badge

README badge for medusajs/medusa-agent-skills/learning-medusa