Notifications Guide
Guren provides a unified API for sending notifications across multiple channels like email, database, Slack, and more. Notifications are class-based, making them reusable and easy to test.
Notifications Guide
Guren provides a unified API for sending notifications across multiple channels like email, database, Slack, and more. Notifications are class-based, making them reusable and easy to test.
Core Concepts
- Notification – A class representing a notification with methods for each delivery channel.
- NotificationManager – Central hub for registering channels and sending notifications.
- Notifiable – Interface for entities that can receive notifications (users, teams, etc.).
- Channel – Delivery mechanism (mail, database, Slack, etc.).
Creating Notifications
Basic Notification
import { Notification } from '@guren/core'
import type { Notifiable, NotificationMailMessage } from '@guren/core'
export class OrderShipped extends Notification {
constructor(
private readonly order: Order,
private readonly trackingNumber: string
) {
super()
}
// Define delivery channels
via(notifiable: Notifiable): string[] {
return ['mail', 'database']
}
// Email content
toMail(notifiable: Notifiable): NotificationMailMessage {
return {
subject: `Order #${this.order.id} has shipped!`,
html: `
<h1>Your order is on its way!</h1>
<p>Tracking number: ${this.trackingNumber}</p>
<a href="/orders/${this.order.id}">View Order</a>
`,
}
}
// Database record
toDatabase(notifiable: Notifiable): Record<string, unknown> {
return {
orderId: this.order.id,
trackingNumber: this.trackingNumber,
message: 'Your order has been shipped',
}
}
}
With CLI
bunx guren make:notification OrderShipped
Sending Notifications
Setup
import {
NotificationManager,
MailChannel,
DatabaseChannel,
} from '@guren/core'
const notifications = new NotificationManager()
// Register channels
notifications
.registerChannel('mail', new MailChannel(mailManager))
.registerChannel('database', new DatabaseChannel())
Container Integration
The notification subsystem is registered as a singleton via a ServiceProvider. You can resolve it from the container:
// Access via app.container or this.container in providers
const notifications = container.make('notifications') // NotificationManager
await notifications.send(user, new OrderShipped(order, 'ABC123'))
Sending to a User
// User must implement Notifiable interface
const user: Notifiable = {
id: 1,
email: 'user@example.com',
routeNotificationFor(channel: string): string | null {
if (channel === 'mail') return this.email
return null
},
}
// Send notification
await notifications.send(user, new OrderShipped(order, 'ABC123'))
Sending to Multiple Users
await notifications.sendToMany(users, new OrderShipped(order, 'ABC123'))
Immediate Sending (Skip Queue)
// Send immediately even if notification is configured to queue
await notifications.sendNow(user, new OrderShipped(order, 'ABC123'))
Notification Channels
Mail Channel
import { MailChannel } from '@guren/core'
const mailChannel = new MailChannel(mailManager, {
from: 'notifications@example.com',
})
// In notification class
toMail(notifiable: Notifiable): NotificationMailMessage {
return {
subject: 'Welcome!',
html: '<h1>Welcome to our platform!</h1>',
text: 'Welcome to our platform!',
from: 'hello@example.com', // Override default
replyTo: 'support@example.com',
cc: ['admin@example.com'],
attachments: [{
filename: 'welcome.pdf',
path: './storage/welcome.pdf',
}],
}
}
Database Channel
Store notifications in the database:
import { DatabaseChannel } from '@guren/core'
const databaseChannel = new DatabaseChannel({
store: async (notifiable, notification) => {
await Notification.create({
id: notification.id,
type: notification.type,
notifiableId: notifiable.id,
notifiableType: 'User',
data: notification.data,
readAt: null,
createdAt: notification.createdAt,
})
},
})
// In notification class
toDatabase(notifiable: Notifiable): Record<string, unknown> {
return {
title: 'New Comment',
message: 'Someone commented on your post',
postId: this.post.id,
commentId: this.comment.id,
}
}
Slack Channel
import { SlackChannel } from '@guren/core'
const slackChannel = new SlackChannel({
webhookUrl: process.env.SLACK_WEBHOOK_URL,
channel: '#notifications', // Default channel
username: 'Notification Bot',
})
// In notification class
toSlack(notifiable: Notifiable): SlackMessage {
return {
text: `Order #${this.order.id} has been shipped!`,
blocks: [
{
type: 'section',
text: {
type: 'mrkdwn',
text: `*Order Shipped* :package:\nTracking: ${this.trackingNumber}`,
},
},
{
type: 'actions',
elements: [
{
type: 'button',
text: { type: 'plain_text', text: 'View Order' },
url: `https://example.com/orders/${this.order.id}`,
},
],
},
],
}
}
Memory Channel (Testing)
import { MemoryChannel } from '@guren/core'
const memoryChannel = new MemoryChannel()
// Later, check sent notifications
const sent = memoryChannel.getSentNotifications()
Notifiable Interface
Entities receiving notifications must implement Notifiable:
import type { Notifiable } from '@guren/core'
class User implements Notifiable {
id: number
email: string
slackId?: string
phone?: string
routeNotificationFor(channel: string): string | null {
switch (channel) {
case 'mail':
return this.email
case 'slack':
return this.slackId ?? null
case 'sms':
return this.phone ?? null
default:
return null
}
}
}
Pinning the Notifiable Type
The database channel persists a notifiableType alongside each record, and it
defaults to the constructor name. That name is lost in two places: a bundler can
mangle it, and a notifiable rebuilt from a queued payload is a plain object, so
its constructor name is Object.
Declare notifiableType to pin it — the mirror of Notification.type on the
notification side:
class User implements Notifiable {
notifiableType = 'User'
routeNotificationFor(channel: string): string | null {
// ...
}
}
The declared type is serialized into the queue payload and restored onto the notifiable the worker rebuilds, so it survives the round trip.
Queued Notifications
Configure Queue
export class WelcomeNotification extends Notification {
// Enable queuing
static shouldQueue = true
// Specify queue name (optional)
static queue = 'notifications'
// Add delay in milliseconds (optional)
static delay = 5000 // 5 seconds
via(notifiable: Notifiable): string[] {
return ['mail']
}
toMail(notifiable: Notifiable): NotificationMailMessage {
return {
subject: 'Welcome!',
html: '<h1>Welcome to our app!</h1>',
}
}
}
Queue Setup
import { createQueueManager, MemoryDriver } from '@guren/core'
const queue = createQueueManager({
default: 'memory',
drivers: {
memory: () => new MemoryDriver(),
},
})
queue.driver()
// Notifications with shouldQueue = true will be queued
await notifications.send(user, new WelcomeNotification())
// Process with worker
// bunx guren queue:work --queue=notifications
Workers in a Separate Process
A queued notification is stored as data, so the worker rebuilds the class to
deliver it. When the worker shares the process that sent the notification, this
happens automatically. When you run queue:work as its own process, register
the notification classes it needs from a provider:
import { ServiceProvider, registerNotification } from '@guren/core'
import { WelcomeNotification } from '@/app/Notifications/WelcomeNotification'
export class NotificationServiceProvider extends ServiceProvider {
register(): void {
registerNotification(WelcomeNotification)
}
}
An unregistered notification fails loudly rather than silently delivering nothing.
Two things do not survive the queue, because only a notification's own properties are stored:
- Constructor arguments. Keep anything a channel needs in a property, as
WelcomeNotificationdoes above — the constructor is not re-run. - Values JSON cannot represent, such as
Map,Set, or#privatefields. Prefer plain values.
Routing is unaffected: routeNotificationFor() is called when the notification
is queued and the resulting routes travel with it, so per-recipient addresses
and webhooks arrive intact.
Conditional Notifications
shouldSend Method
class OrderStatusNotification extends Notification {
constructor(private readonly order: Order) {
super()
}
// Only send if user has notifications enabled
async shouldSend(notifiable: Notifiable): Promise<boolean> {
const user = notifiable as User
return user.notificationsEnabled && !user.isDeleted
}
via(notifiable: Notifiable): string[] {
const channels = ['database']
// Add mail only if user opted in
if ((notifiable as User).emailNotifications) {
channels.push('mail')
}
return channels
}
toMail(notifiable: Notifiable): NotificationMailMessage {
return {
subject: `Order #${this.order.id} Update`,
html: `<p>Your order status: ${this.order.status}</p>`,
}
}
toDatabase(notifiable: Notifiable): Record<string, unknown> {
return {
orderId: this.order.id,
status: this.order.status,
}
}
}
Custom Channels
Create custom notification channels:
import type { NotificationChannel, Notifiable } from '@guren/core'
import type { Notification } from '@guren/core'
class SMSChannel implements NotificationChannel {
readonly name = 'sms'
constructor(private readonly twilioClient: TwilioClient) {}
async send(notifiable: Notifiable, notification: Notification): Promise<void> {
const phone = notifiable.routeNotificationFor('sms')
if (!phone) return
// Get SMS content from notification
const message = (notification as any).toSMS?.(notifiable)
if (!message) return
await this.twilioClient.messages.create({
to: phone,
from: process.env.TWILIO_FROM,
body: message.body,
})
}
}
// Register the channel
notifications.registerChannel('sms', new SMSChannel(twilioClient))
// Use in notification
class OrderConfirmation extends Notification {
via(notifiable: Notifiable): string[] {
return ['mail', 'sms']
}
toSMS(notifiable: Notifiable) {
return {
body: `Order #${this.order.id} confirmed! Total: $${this.order.total}`,
}
}
}
Testing
Using container.fake()
Swap the notification manager in tests:
// Access via app.container or this.container in providers
import { NotificationManager, MemoryChannel } from '@guren/core'
test('sends order notification', async () => {
const memoryChannel = new MemoryChannel()
const fakeNotifications = new NotificationManager({
channels: { memory: memoryChannel },
})
using _ = container.fake('notifications', fakeNotifications)
// Run code under test
const sent = memoryChannel.getSentNotifications()
expect(sent).toHaveLength(1)
})
Manual Testing
import { describe, test, expect, beforeEach } from 'bun:test'
import {
NotificationManager,
MemoryChannel,
Notification,
} from '@guren/core'
import type { Notifiable, NotificationMailMessage } from '@guren/core'
class TestNotification extends Notification {
via() {
return ['memory']
}
toMail(): NotificationMailMessage {
return { subject: 'Test', html: '<p>Test</p>' }
}
}
describe('Notifications', () => {
let notifications: NotificationManager
let memoryChannel: MemoryChannel
beforeEach(() => {
memoryChannel = new MemoryChannel()
notifications = new NotificationManager({
channels: { memory: memoryChannel },
})
})
test('sends notification', async () => {
const user: Notifiable = {
id: 1,
routeNotificationFor: () => 'test@example.com',
}
await notifications.send(user, new TestNotification())
const sent = memoryChannel.getSentNotifications()
expect(sent).toHaveLength(1)
expect(sent[0].notification).toBeInstanceOf(TestNotification)
})
test('respects shouldSend', async () => {
class ConditionalNotification extends Notification {
shouldSend() {
return false
}
via() {
return ['memory']
}
}
const user: Notifiable = {
id: 1,
routeNotificationFor: () => 'test@example.com',
}
await notifications.send(user, new ConditionalNotification())
expect(memoryChannel.getSentNotifications()).toHaveLength(0)
})
test('sends to multiple channels', async () => {
const mailChannel = new MemoryChannel()
const dbChannel = new MemoryChannel()
notifications.registerChannel('mail', mailChannel)
notifications.registerChannel('database', dbChannel)
class MultiChannelNotification extends Notification {
via() {
return ['mail', 'database']
}
toMail() {
return { subject: 'Test', html: '<p>Test</p>' }
}
toDatabase() {
return { message: 'Test' }
}
}
const user: Notifiable = {
id: 1,
routeNotificationFor: () => 'test@example.com',
}
await notifications.send(user, new MultiChannelNotification())
expect(mailChannel.getSentNotifications()).toHaveLength(1)
expect(dbChannel.getSentNotifications()).toHaveLength(1)
})
})
Best Practices
One notification per event: Create separate notification classes for different events.
Keep notifications focused: Each notification should have a clear purpose.
Use queuing for non-critical notifications: Queue email and Slack notifications to avoid blocking.
Implement shouldSend for conditional logic: Use
shouldSend()instead of checking conditions before sending.Test notification content: Write tests to verify notification messages are correct.
Handle channel failures gracefully: Channels should log errors without breaking other channels.
Use typed payloads: Define interfaces for notification data for type safety.
Store database notifications for in-app notifications: Use the database channel for notification centers.