14 KiB
Subscribers and Events
Subscribers are asynchronous functions that execute when specific events are emitted. Use them to perform actions after commerce operations, like sending confirmation emails when an order is placed.
Contents
- When to Use Subscribers
- Creating a Subscriber
- Common Commerce Events
- Accessing Event Data
- Triggering Custom Events
- Best Practices
When to Use Subscribers
Use subscribers when you need to react to events that happen in your application:
- ✅ Send confirmation emails when orders are placed
- ✅ Sync data to external systems when products are updated
- ✅ Trigger webhooks when entities change
- ✅ Update analytics when customers are created
- ✅ Perform non-blocking side effects
Don't use subscribers for:
- ❌ Periodic tasks (use scheduled jobs instead)
- ❌ Operations that must block the main flow (use workflows instead)
- ❌ Scheduling future tasks (subscribers execute immediately)
Subscribers vs Scheduled Jobs:
- Subscriber: Reacts to
order.placedevent and sends confirmation email (event-driven) - Scheduled Job: Finds abandoned carts every 6 hours and sends emails (polling pattern)
Creating a Subscriber
Create a TypeScript file in the src/subscribers/ directory:
// src/subscribers/order-placed.ts
import { SubscriberArgs, type SubscriberConfig } from "@medusajs/framework"
export default async function orderPlacedHandler({
event: { eventName, data },
container,
}: SubscriberArgs<{ id: string }>) {
const logger = container.resolve("logger")
logger.info(`Order ${data.id} was placed`)
// Resolve services
const orderService = container.resolve("order")
const notificationService = container.resolve("notification")
// Retrieve full order data
const order = await orderService.retrieveOrder(data.id, {
relations: ["customer", "items"],
})
// Send confirmation email
await notificationService.createNotifications({
to: order.customer.email,
template: "order-confirmation",
channel: "email",
data: { order },
})
logger.info(`Confirmation email sent for order ${data.id}`)
}
export const config: SubscriberConfig = {
event: "order.placed", // Single event
}
Listening to Multiple Events
// src/subscribers/product-changes.ts
import { SubscriberArgs, type SubscriberConfig } from "@medusajs/framework"
export default async function productChangesHandler({
event: { eventName, data },
container,
}: SubscriberArgs<{ id: string }>) {
const logger = container.resolve("logger")
logger.info(`Product event: ${eventName} for product ${data.id}`)
// Handle different events
switch (eventName) {
case "product.created":
// Handle product creation
break
case "product.updated":
// Handle product update
break
case "product.deleted":
// Handle product deletion
break
}
}
export const config: SubscriberConfig = {
event: ["product.created", "product.updated", "product.deleted"],
}
Common Commerce Events
⚠️ IMPORTANT: Event data typically contains only the ID of the affected entity. You must retrieve the full data if needed.
Order Events
"order.placed" // Order was placed
"order.updated" // Order was updated
"order.canceled" // Order was canceled
"order.completed" // Order was completed
"order.shipment_created" // Shipment was created for order
Product Events
"product.created" // Product was created
"product.updated" // Product was updated
"product.deleted" // Product was deleted
Customer Events
"customer.created" // Customer was created
"customer.updated" // Customer was updated
Cart Events
"cart.created" // Cart was created
"cart.updated" // Cart was updated
Auth Events
"auth.password_reset" // Password reset was requested
Invite Events
"invite.created" // Invite was created (for admin users)
For a complete list of events, ask MedusaDocs for the specific module's events.
Accessing Event Data
Event Data Structure
interface SubscriberArgs<T> {
event: {
eventName: string // e.g., "order.placed"
data: T // Event payload (usually contains { id: string })
}
container: MedusaContainer // DI container
}
Retrieving Full Entity Data
⚠️ IMPORTANT: The data object typically only contains the entity ID. Retrieve the full entity data using services or query:
// src/subscribers/order-placed.ts
import { SubscriberArgs, type SubscriberConfig } from "@medusajs/framework"
export default async function orderPlacedHandler({
event: { data },
container,
}: SubscriberArgs<{ id: string }>) {
const logger = container.resolve("logger")
const query = container.resolve("query")
// data.id contains the order ID
logger.info(`Handling order.placed event for order: ${data.id}`)
// Retrieve full order data with relations
const { data: orders } = await query.graph({
entity: "order",
fields: [
"id",
"email",
"total",
"customer.*",
"items.*",
"items.product.*",
],
filters: {
id: data.id,
},
})
const order = orders[0]
// Now you have the full order data
logger.info(`Order total: ${order.total}`)
logger.info(`Customer email: ${order.customer.email}`)
}
export const config: SubscriberConfig = {
event: "order.placed",
}
Using Module Services
export default async function productUpdatedHandler({
event: { data },
container,
}: SubscriberArgs<{ id: string }>) {
const productService = container.resolve("product")
// Retrieve product using service
const product = await productService.retrieveProduct(data.id, {
select: ["id", "title", "status"],
relations: ["variants"],
})
// Process product
}
Triggering Custom Events
Emit custom events from workflows using the emitEventStep:
// src/workflows/create-review.ts
import { createWorkflow, WorkflowResponse } from "@medusajs/framework/workflows-sdk"
import { emitEventStep } from "@medusajs/medusa/core-flows"
const createReviewWorkflow = createWorkflow(
"create-review",
function (input: { product_id: string; rating: number }) {
// Create review step
const review = createReviewStep(input)
// Emit custom event
emitEventStep({
eventName: "review.created",
data: {
id: review.id,
product_id: input.product_id,
rating: input.rating,
},
})
return new WorkflowResponse({ review })
}
)
export default createReviewWorkflow
Then create a subscriber for the custom event:
// src/subscribers/review-created.ts
import { SubscriberArgs, type SubscriberConfig } from "@medusajs/framework"
export default async function reviewCreatedHandler({
event: { data },
container,
}: SubscriberArgs<{ id: string; product_id: string; rating: number }>) {
const logger = container.resolve("logger")
const query = container.resolve("query")
logger.info(`Review ${data.id} created for product ${data.product_id}`)
// If rating is low, notify support
if (data.rating <= 2) {
const notificationService = container.resolve("notification")
await notificationService.createNotifications({
to: "support@example.com",
template: "low-rating-alert",
channel: "email",
data: {
review_id: data.id,
product_id: data.product_id,
rating: data.rating,
},
})
}
}
export const config: SubscriberConfig = {
event: "review.created",
}
Best Practices
1. Always Use Logging
export default async function mySubscriber({
event: { eventName, data },
container,
}: SubscriberArgs<{ id: string }>) {
const logger = container.resolve("logger")
logger.info(`Handling ${eventName} for ${data.id}`)
try {
// Subscriber logic
logger.info(`Successfully handled ${eventName}`)
} catch (error) {
logger.error(`Failed to handle ${eventName}: ${error.message}`)
}
}
2. Handle Errors Gracefully
Subscribers run asynchronously and don't block the main flow. Log errors but don't throw:
// ✅ GOOD: Catches errors and logs
export default async function mySubscriber({
event: { data },
container,
}: SubscriberArgs<{ id: string }>) {
const logger = container.resolve("logger")
try {
// Subscriber logic that might fail
await sendEmail(data.id)
} catch (error) {
logger.error(`Failed to send email: ${error.message}`)
// Don't throw - subscriber completes gracefully
}
}
3. Keep Subscribers Fast and Non-Blocking
Subscribers should perform quick operations. For long-running tasks, consider:
- Queuing the task for background processing
- Using scheduled jobs instead
- Breaking the work into smaller steps
// ✅ GOOD: Quick operation
export default async function orderPlacedHandler({
event: { data },
container,
}: SubscriberArgs<{ id: string }>) {
const notificationService = container.resolve("notification")
// Quick: Queue email for sending
await notificationService.createNotifications({
to: "customer@example.com",
template: "order-confirmation",
channel: "email",
data: { order_id: data.id },
})
}
4. Use Workflows for Mutations
If your subscriber needs to perform mutations, use workflows:
// ✅ GOOD: Uses workflow for mutations
import { syncProductWorkflow } from "../workflows/sync-product"
export default async function productCreatedHandler({
event: { data },
container,
}: SubscriberArgs<{ id: string }>) {
const logger = container.resolve("logger")
// Execute workflow to sync to external system
try {
await syncProductWorkflow(container).run({
input: { product_id: data.id },
})
logger.info(`Product ${data.id} synced successfully`)
} catch (error) {
logger.error(`Failed to sync product ${data.id}: ${error.message}`)
}
}
5. Avoid Infinite Event Loops
Be careful when subscribing to events that trigger more events:
// ❌ BAD: Can cause infinite loop
export default async function productUpdatedHandler({
event: { data },
container,
}: SubscriberArgs<{ id: string }>) {
const productService = container.resolve("product")
// This triggers another product.updated event!
await productService.updateProducts({
id: data.id,
metadata: { last_updated: new Date() },
})
}
// ✅ GOOD: Add guard condition
export default async function productUpdatedHandler({
event: { data },
container,
}: SubscriberArgs<{ id: string }>) {
const logger = container.resolve("logger")
const query = container.resolve("query")
// Retrieve product to check if we should update
const { data: products } = await query.graph({
entity: "product",
fields: ["id", "metadata"],
filters: { id: data.id },
})
const product = products[0]
// Guard: Only update if not already processed
if (!product.metadata?.processed) {
const productService = container.resolve("product")
await productService.updateProducts({
id: data.id,
metadata: { processed: true },
})
}
}
6. Make Subscribers Idempotent
Subscribers might be called multiple times for the same event. Design them to handle this:
export default async function orderPlacedHandler({
event: { data },
container,
}: SubscriberArgs<{ id: string }>) {
const logger = container.resolve("logger")
const myService = container.resolve("my-service")
// Check if we've already processed this order
const processed = await myService.isOrderProcessed(data.id)
if (processed) {
logger.info(`Order ${data.id} already processed, skipping`)
return
}
// Process order
await myService.processOrder(data.id)
// Mark as processed
await myService.markOrderAsProcessed(data.id)
}
Complete Example: Order Confirmation Email
// src/subscribers/order-placed.ts
import { SubscriberArgs, type SubscriberConfig } from "@medusajs/framework"
export default async function sendOrderConfirmationEmail({
event: { data },
container,
}: SubscriberArgs<{ id: string }>) {
const logger = container.resolve("logger")
logger.info(`Sending order confirmation for order: ${data.id}`)
try {
const query = container.resolve("query")
// Retrieve full order data
const { data: orders } = await query.graph({
entity: "order",
fields: [
"id",
"display_id",
"email",
"total",
"currency_code",
"customer.first_name",
"customer.last_name",
"items.*",
"items.product.title",
"shipping_address.*",
],
filters: {
id: data.id,
},
})
if (!orders || orders.length === 0) {
logger.error(`Order ${data.id} not found`)
return
}
const order = orders[0]
// Send confirmation email
const notificationService = container.resolve("notification")
await notificationService.createNotifications({
to: order.email,
template: "order-confirmation",
channel: "email",
data: {
order_id: order.display_id,
customer_name: `${order.customer.first_name} ${order.customer.last_name}`,
items: order.items,
total: order.total,
currency: order.currency_code,
shipping_address: order.shipping_address,
},
})
logger.info(`Order confirmation email sent to ${order.email}`)
} catch (error) {
logger.error(
`Failed to send order confirmation for ${data.id}: ${error.message}`
)
}
}
export const config: SubscriberConfig = {
event: "order.placed",
}