Notification System
This document describes the config-driven notification architecture, how it works, and how to add new notification types.
Architecture Overview
The notification system follows an event-driven architecture with clear separation of concerns:
┌─────────────────┐ ┌──────────────────────┐ ┌────────────────┐ ┌─────────────┐
│ Domain Services │────▶│ Notification Handlers│────▶│ SQS Queue │────▶│ Novu │
│ (emit events) │ │ (enrich & publish) │ │ (async buffer) │ │ (delivery) │
└─────────────────┘ └──────────────────────┘ └────────────────┘ └─────────────┘Key Packages
| Package | Location | Responsibility |
|---|---|---|
@repo/events | packages/core/events | Domain event bus and subscriber base class |
@repo/notifications | packages/core/notifications | Config-driven notification definitions, schemas, workflows, publisher, consumer |
@repo/notification-handlers | packages/backend/features/notification-handlers | Event handlers that enrich and publish |
Config-Driven Architecture
Instead of writing a full workflow file per notification, each notification is declared as a config object. The system generates Novu workflows automatically from configs.
Config Shape
interface NotificationConfig<TSchema extends z.ZodObject<z.ZodRawShape>> {
type: string // Notification type (kebab-case, matches Novu workflow ID)
payloadSchema: TSchema // Zod schema for payload validation
delivery: {
emailDelay?: number // Minutes. Set = delay-then-email-if-unseen. Unset = immediate email.
}
content: ( // Content function builds in-app + email from payload
payload: z.infer<TSchema>,
t: ContentTranslator, // Locale-bound translator
locale: string, // Subscriber's locale
) => NotificationContent
}Delivery Patterns
Controlled by a single emailDelay field:
emailDelay | Behavior |
|---|---|
Set (e.g., 30) | In-app immediately → delay N minutes → email only if in-app was not seen |
Unset / 0 | In-app + email sent immediately, no delay, no seen-check |
Email Types
Two email rendering approaches, discriminated structurally:
- GenericEmailContent (
title+body): Rendered by theGenericNotificationEmailtemplate. ThebodyandsecondaryBodyfields support markdown (**bold**,*italic*,[links](url), lists), rendered via@react-email/markdown. Good for simple notifications. - CustomEmailContent (
renderfunction): Uses a custom React Email template. Good for complex layouts like order summaries.
// Generic email (body supports markdown)
email: {
subject: "Your order shipped",
title: "Your order is on its way!",
body: "Good news! Your order **ORD-ABC123** has been shipped.\n\nYou can view your [order details](https://app.cardnexus.com/orders/abc123) at any time.",
ctaButton: { label: "Track Package", href: trackingUrl },
secondaryBody: "Questions? [Contact the seller](https://app.cardnexus.com/messages/seller) or visit our [Help Center](https://app.cardnexus.com/help).",
}
// Custom email
email: {
subject: "Order Confirmed",
render: () => renderTransactionSuccessEmail({ /* ... */ }),
}How It Works
1. Domain Events (Thin Facts)
Domain services emit thin events containing only IDs and essential context:
// In a domain service (e.g., CheckoutService)
await this.eventBus.emit("transaction.completed", {
transactionId: transaction.id,
buyerId: userId,
orderIds: orderIds,
totalAmountCents: 7500,
currency: "EUR",
})Events should NOT contain:
- Formatted strings (like “$75.00”)
- User display names
- URLs
- Any presentation-layer decisions
2. Notification Handlers (Enrichment Layer)
Handlers in @repo/notification-handlers subscribe to domain events, fetch additional data from domain services, and publish raw data to the notification queue:
@injectable()
export class TransactionSuccessHandler extends BaseNotificationHandler {
constructor(
@inject(EventBus) eventBus: EventBus,
@inject(NotificationPublisher) notifications: NotificationPublisher,
@inject(UserService) userService: UserService,
@inject(OrderService) private orderService: OrderService,
) {
super(eventBus, notifications, userService, "TransactionSuccessHandler")
this.subscribe("transaction.completed", this.handle.bind(this))
}
private async handle(event: TransactionCompletedEvent): Promise<void> {
const buyer = await this.userService.getUserById(event.buyerId)
const orders = await this.orderService.getOrdersByIds(event.orderIds)
const payload: TransactionSuccessPayload = {
transactionId: event.transactionId,
buyerName: buyer.username,
totalAmountCents: event.totalAmountCents,
currency: event.currency,
orders: orders.map(order => ({
orderId: order.id,
subtotalCents: order.subtotal,
// ...
})),
}
await this.sendNotification(
NotificationType.TRANSACTION_SUCCESS,
event.buyerId,
payload,
"TransactionSuccessHandler"
)
}
}3. Notification Queue (Async Buffer)
The NotificationPublisher sends messages to SQS with:
- Payload validation against Zod schemas
- Message metadata (correlation ID, timestamp, triggered by)
- FIFO ordering by user ID
4. Config-Driven Workflows
The createConfigDrivenWorkflow() factory generates Novu workflows from configs:
- Creates a locale-bound
ContentTranslatorfrom the subscriber - Calls
config.content(payload, t, locale)to getNotificationContent - Builds in-app and email resolvers from the content
- Delegates to
executeDelivery()for the delivery pattern
The NotificationConsumer processes queue messages and triggers workflows automatically via the registry.
Adding a New Notification Type
Step 1: Define the Domain Event
Add the event type to @repo/events:
// packages/core/events/src/domain-events.ts
export interface OrderShippedEvent {
orderId: string
buyerId: string
sellerId: string
trackingNumber?: string
}
export interface DomainEvents {
// ... existing events
"order.shipped": OrderShippedEvent
}Step 2: Define the Notification Payload Schema
Add schemas to @repo/notifications:
// packages/core/notifications/src/schemas/order.schemas.ts
export const OrderShippedPayloadSchema = z.object({
orderId: z.string(),
buyerName: z.string(),
sellerName: z.string(),
trackingNumber: z.string().optional(),
trackingUrl: z.string().url().optional(),
itemCount: z.number().int(),
})
export type OrderShippedPayload = z.infer<typeof OrderShippedPayloadSchema>Step 3: Add the Notification Type
// packages/core/notifications/src/types/notification-type.ts
export const NotificationType = {
TRANSACTION_SUCCESS: "transaction-success",
ORDER_SHIPPED: "order-shipped", // Add new type
} as constStep 4: Create the Notification Config
This is where the magic happens - declare everything about the notification in one place:
// packages/core/notifications/src/config/configs/order/order-shipped.config.ts
import { defineNotification } from "../../notification-config.types.js"
export const orderShippedConfig = defineNotification({
type: NotificationType.ORDER_SHIPPED,
payloadSchema: OrderShippedPayloadSchema,
delivery: { emailDelay: 30 }, // Delay 30min, email only if unseen
content: (payload, t, locale) => ({
inApp: {
subject: t("order.shipped.subject", "Your order has shipped!"),
body: t("order.shipped.body", "Your order is on its way"),
primaryAction: payload.trackingUrl
? {
label: t("order.shipped.trackButton", "Track Package"),
redirect: { url: payload.trackingUrl, target: "_blank" },
}
: undefined,
},
email: {
// Generic email (simple layout)
subject: t("order.shipped.email.subject", "Your order has shipped"),
title: t("order.shipped.email.title", "Your order is on its way!"),
body: t("order.shipped.email.body", "Good news! Your order has been shipped."),
ctaButton: payload.trackingUrl
? {
label: t("order.shipped.email.trackButton", "Track Package"),
href: payload.trackingUrl,
}
: undefined,
},
}),
})Step 5: Register the Config
// packages/core/notifications/src/config/configs/index.ts
import { orderShippedConfig } from "./order/order-shipped.config.js"
export const allNotificationConfigs = [
transactionSuccessConfig,
orderShippedConfig, // Add here
] as constThat’s it for the notifications package! The workflow, registry, and serve handler are all auto-generated from configs.
Step 6: Create the Handler
// packages/backend/features/notification-handlers/src/order-shipped.handler.ts
@injectable()
export class OrderShippedHandler extends BaseNotificationHandler {
constructor(
@inject(EventBus) eventBus: EventBus,
@inject(NotificationPublisher) notifications: NotificationPublisher,
@inject(UserService) userService: UserService,
@inject(OrderService) private orderService: OrderService,
) {
super(eventBus, notifications, userService, "OrderShippedHandler")
this.subscribe("order.shipped", this.handle.bind(this))
}
private async handle(event: OrderShippedEvent): Promise<void> {
const buyer = await this.userService.getUserById(event.buyerId)
const seller = await this.userService.getUserById(event.sellerId)
await this.sendNotification(
NotificationType.ORDER_SHIPPED,
event.buyerId,
{
orderId: event.orderId,
buyerName: buyer.username,
sellerName: seller.username,
trackingNumber: event.trackingNumber,
itemCount: order.items.length,
},
"OrderShippedHandler"
)
}
}Step 7: Register the Handler
Export from the package and register in backend bootstrap:
// apps/backend/src/bootstrap/event-subscribers.ts
import { OrderShippedHandler } from "@repo/notification-handlers"
export function registerEventSubscribers(container: DependencyContainer): void {
// ... existing handlers
container.resolve(OrderShippedHandler)
}Step 8: Add Translations
// packages/core/notifications/src/i18n/locales/en.json
{
"order.shipped.subject": "Your order has shipped!",
"order.shipped.body": "Your order is on its way",
"order.shipped.trackButton": "Track Package",
"order.shipped.email.subject": "Your order has shipped",
"order.shipped.email.title": "Your order is on its way!",
"order.shipped.email.body": "Good news! Your order has been shipped.",
"order.shipped.email.trackButton": "Track Package"
}Summary: What to Change for a New Notification
| File | Change |
|---|---|
@repo/events domain-events.ts | Add event interface |
@repo/notifications schemas/*.ts | Add payload schema |
@repo/notifications types/notification-type.ts | Add enum value |
@repo/notifications config/configs/**/*.config.ts | Create notification config |
@repo/notifications config/configs/index.ts | Add to allNotificationConfigs |
@repo/notification-handlers | Create handler |
| Backend bootstrap | Register handler |
| i18n locales | Add translations |
Key Principles
1. Handlers Pass Raw Data
Handlers should never format data. Pass:
- Amounts in cents (
totalAmountCents: 7500) - Raw IDs (
orderId: "abc123") - ISO timestamps (
shippedAt: "2024-01-15T10:30:00Z")
The config’s content function handles formatting based on user locale.
2. Handlers Call Domain Services
Handlers depend on domain services for data enrichment:
UserServicefor user infoOrderServicefor order detailsProductServicefor product info
Never access database models directly from handlers.
3. Fire-and-Forget Pattern
Notifications are non-blocking. If sending fails:
- Log the error
- Don’t rethrow (don’t break business flows)
- The message can be retried via DLQ
4. Thin Domain Events
Domain events should be minimal facts:
- IDs only
- Essential context (amounts, statuses)
- No presentation data
5. Config is the Source of Truth
Each notification is fully defined by its config object in config/configs/. The config declares:
- Payload schema (validation)
- Delivery pattern (email delay)
- Content (in-app + email, with i18n)
Workflows, registry, and serve handler are all auto-generated.
Testing
Test a Workflow Locally
# Set test user credentials
export TEST_USER_ID="your-user-id"
export TEST_USER_EMAIL="your@email.com"
# Run the test script
pnpm --filter @repo/notifications test:workflowVerify Handler Registration
# Start backend and check logs for:
# "Registered TransactionSuccessHandler"
pnpm dev --filter backendTroubleshooting
Handler Not Firing
- Check handler is registered in
event-subscribers.ts - Verify event name matches exactly (e.g.,
"order.shipped") - Check event is being emitted with correct payload
Notification Not Delivered
- Check SQS queue for messages
- Verify consumer is running
- Check Novu dashboard for workflow executions
- Review consumer logs for errors
Type Errors with Zod
If you see Zod type errors with the queue package, use as any cast:
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await this.queuePublisher.publish("queue", schema as any, payload, options)This is a temporary workaround for Zod 4 type incompatibility.