Skip to Content
BackendNotification System

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

PackageLocationResponsibility
@repo/eventspackages/core/eventsDomain event bus and subscriber base class
@repo/notificationspackages/core/notificationsConfig-driven notification definitions, schemas, workflows, publisher, consumer
@repo/notification-handlerspackages/backend/features/notification-handlersEvent 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:

emailDelayBehavior
Set (e.g., 30)In-app immediately → delay N minutes → email only if in-app was not seen
Unset / 0In-app + email sent immediately, no delay, no seen-check

Email Types

Two email rendering approaches, discriminated structurally:

  • GenericEmailContent (title + body): Rendered by the GenericNotificationEmail template. The body and secondaryBody fields support markdown (**bold**, *italic*, [links](url), lists), rendered via @react-email/markdown. Good for simple notifications.
  • CustomEmailContent (render function): 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:

  1. Creates a locale-bound ContentTranslator from the subscriber
  2. Calls config.content(payload, t, locale) to get NotificationContent
  3. Builds in-app and email resolvers from the content
  4. 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 const

Step 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 const

That’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

FileChange
@repo/events domain-events.tsAdd event interface
@repo/notifications schemas/*.tsAdd payload schema
@repo/notifications types/notification-type.tsAdd enum value
@repo/notifications config/configs/**/*.config.tsCreate notification config
@repo/notifications config/configs/index.tsAdd to allNotificationConfigs
@repo/notification-handlersCreate handler
Backend bootstrapRegister handler
i18n localesAdd 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:

  • UserService for user info
  • OrderService for order details
  • ProductService for 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:workflow

Verify Handler Registration

# Start backend and check logs for: # "Registered TransactionSuccessHandler" pnpm dev --filter backend

Troubleshooting

Handler Not Firing

  1. Check handler is registered in event-subscribers.ts
  2. Verify event name matches exactly (e.g., "order.shipped")
  3. Check event is being emitted with correct payload

Notification Not Delivered

  1. Check SQS queue for messages
  2. Verify consumer is running
  3. Check Novu dashboard for workflow executions
  4. 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.

Last updated on