Notifications
Notifications represent user-facing communication intent. Use them when a use case needs to tell a person or team that something happened, but should not care whether delivery uses email today, SMS later, push later, or an in-app inbox.
Jobs, events, and outbox still own reliable background execution. Notifications sit above them and give communication a stable application API.
Define a notification
Keep feature-owned notifications under the feature that owns the business event:
features/
appointments/
notifications/
index.tsCreate the app-bound defineNotification builder once in
lib/notifications.ts with createNotifications<AppContext>() (see
app-bound builders), then define
notifications in feature files:
import { defineMailNotificationChannel } from "@beignet/core/notifications";
import { z } from "zod";
import { defineNotification } from "@/lib/notifications";
export const AppointmentReminderNotification =
defineNotification("appointments.reminder", {
payload: z.object({
appointmentId: z.string().uuid(),
patientEmail: z.string().email().optional(),
startsAt: z.string().datetime(),
}),
channels: {
email: defineMailNotificationChannel(({ payload }) => {
if (!payload.patientEmail) return undefined;
return {
to: payload.patientEmail,
subject: "Upcoming appointment",
text: `Your appointment starts at ${payload.startsAt}.`,
};
}),
},
});defineMailNotificationChannel(...) uses ctx.ports.mailer, so the app can
swap Resend, SMTP, memory mail, or another mail adapter without changing the
notification definition.
Send from a use case
Use cases should request the communication intent, not a vendor-specific delivery mechanism:
await ctx.ports.notifications.send(AppointmentReminderNotification, {
appointmentId: appointment.id,
patientEmail: appointment.patientEmail,
startsAt: appointment.startsAt.toISOString(),
});This keeps application code focused on "notify the patient" instead of "enqueue this specific email job."
Wire the port
Use createInlineNotificationsProvider(...) as the dev-default provider for
the notifications port. It installs an inline dispatcher whose channel
handlers receive an app service context built lazily through the server
context blueprint on each send:
// server/providers.ts
import { createInlineNotificationsProvider } from "@beignet/core/notifications";
export const providers = [createInlineNotificationsProvider()] as const;beignet make notification does this wiring for you, adding the
notifications and mailer ports with dev-default providers and skipping
keys the app already wires; see CLI for the generator details. Replace
the memory mailer with a real mail provider when the app should deliver email;
the notification definitions do not change.
When the app wires ports by hand in an app-local provider, use
createInlineNotificationDispatcher(...) directly with a ctx factory and an
optional instrumentation port. The dispatcher validates payloads before
invoking channel handlers and emits devtools events when instrumentation is
available.
Channel outcomes and failure isolation
Every selected channel runs in declaration order. A failed channel produces a
failed result without preventing later channels from running:
const result = await ctx.ports.notifications.send(
AppointmentReminderNotification,
payload,
);
for (const channel of result.results) {
if (channel.status === "failed") {
ctx.ports.logger.error("Notification channel failed", {
channel: channel.channel,
reason: channel.reason,
});
}
}Channel outcomes are queued, sent, skipped, or failed. Inline delivery
reports complete results by default. When a caller must reject after any
channel failure, configure its dispatcher with failureMode: "throw".
NotificationDeliveryError is thrown only after every selected channel has
run and carries the complete result.
Beignet records notification.channel.queued, .sent, .skipped, and
.failed instrumentation events in addition to aggregate enqueue/send events.
Queued delivery
Use the first-party delivery job when channels should run through the app's
existing jobs or outbox infrastructure. Keep the registry and job in
server/notifications.ts:
import {
defineNotificationDeliveryJob,
defineNotificationRegistry,
} from "@beignet/core/notifications";
import type { AppContext } from "@/app-context";
import { AppointmentReminderNotification } from "@/features/appointments/notifications";
export const notificationRegistry = defineNotificationRegistry<AppContext>([
AppointmentReminderNotification,
]);
export const DeliverNotificationJob =
defineNotificationDeliveryJob<AppContext>({
registry: notificationRegistry,
});
export const notificationJobs = [DeliverNotificationJob] as const;Add notificationJobs to every job or outbox registry that can receive queued
notifications. The delivery job defaults to three attempts with exponential
backoff. Each channel is a separate job, so retrying email cannot duplicate an
in-app notification that already succeeded.
Install the queued provider after the base providers inside the server loader.
Composing it there avoids a type cycle in apps whose AppContext infers ports
from the base provider tuple:
import { createQueuedNotificationsProvider } from "@beignet/core/notifications";
export const getServer = createNextServerLoader(async () => {
const { providers } = await import("./providers");
const { DeliverNotificationJob } = await import("./notifications");
return createNextServer({
// ...
providers: [
...providers,
createQueuedNotificationsProvider({
deliveryJob: DeliverNotificationJob,
}),
],
});
});The provider uses the installed jobs port. That may be an inline dispatcher,
BullMQ, Inngest, or an app-owned outbox-backed dispatcher. Queueing is atomic
with application writes only when code inside the transaction constructs a
queued notification dispatcher against that transaction's
createOutboxJobDispatcher(...). The globally installed provider cannot make
request-side notification sends part of an application transaction by itself.
Preferences and opt-outs
Preferences stay app-owned because recipient identity and preference storage
vary by product. Implement NotificationPreferencesPort<AppContext> and pass
it to the inline dispatcher or delivery job:
// infra/notification-preferences.ts
import type { NotificationPreferencesPort } from "@beignet/core/notifications";
import type { AppContext } from "@/app-context";
export const notificationPreferences: NotificationPreferencesPort<AppContext> = {
async evaluate({ notification, channel, payload, ctx }) {
const enabled = await ctx.ports.notificationSettings.isEnabled({
notificationName: notification.name,
channel,
payload,
});
return enabled
? { deliver: true }
: { deliver: false, reason: "Disabled by the recipient" };
},
};Pass the preference adapter to the delivery job in server/notifications.ts:
export const DeliverNotificationJob =
defineNotificationDeliveryJob<AppContext>({
registry: notificationRegistry,
preferences: notificationPreferences,
});Preferences are evaluated immediately before delivery, including on job
retries, so queued notifications honor current settings. A denied decision is
skipped. A preferences lookup failure is failed and the channel handler is
not invoked; durable jobs can then retry without sending through an uncertain
opt-out state.
Other channels
Email is the first built-in channel helper because Beignet already has a
MailerPort. SMS, push, and in-app notifications can use app-owned channel
handlers:
export const AppointmentReminderNotification =
defineNotification("appointments.reminder", {
payload: z.object({
phoneNumber: z.string().optional(),
}),
channels: {
sms: async ({ payload, ctx, channel }) => {
if (!payload.phoneNumber) {
return { channel, status: "skipped", reason: "No phone number" };
}
const result = await ctx.ports.sms.send({
to: payload.phoneNumber,
body: "Your appointment is coming up.",
});
return {
channel,
status: "sent",
id: result.id,
provider: result.provider,
};
},
},
});This keeps the framework primitive stable while leaving vendor-specific preferences, templates, and provider choices in app code.
In-app inbox notifications
An in-app inbox is Beignet's paved first-party non-email pattern: notifications land in a per-user inbox table and render inside the product with an unread count. The storage model stays app-owned because recipients, tenancy, read models, and retention vary by product. Scaffold the whole slice for a Drizzle-backed app:
beignet make inbox
beignet db generate
beignet db migrateThe generator creates a features/inbox feature — an inbox_notifications
table and repository behind ctx.ports.inbox, cursor-paginated list,
unread-count, mark-read, and mark-all-read contracts and use cases — plus
defineInboxNotificationChannel, a channel factory that writes inbox rows
from any notification. Deliver an existing notification into the inbox by
adding the channel:
import { defineInboxNotificationChannel } from "@/features/inbox/channel";
export const PostPublishedNotification = defineNotification(
"posts.published",
{
payload: z.object({
userId: z.string(),
postTitle: z.string(),
}),
channels: {
inApp: defineInboxNotificationChannel(({ payload }) => ({
userId: payload.userId,
type: "post.published",
title: `"${payload.postTitle}" was published`,
})),
},
},
);The channel returns skipped when the mapping resolves no recipient, so a
notification can safely mix email and in-app delivery. The inbox is personal —
rows are scoped to the recipient's user id — and the notification type is an
open string, so any feature can deliver its own kinds without editing the
inbox schema. Apps with the frontend shell also get an /inbox page and an
unread badge component.
Test notification intent
Use createMemoryNotificationPort(...) when a test only needs to assert that a
use case requested a notification:
import { createMemoryNotificationPort } from "@beignet/core/notifications";
const notifications = createMemoryNotificationPort({
id: () => "notification_1",
});
await useCase.run({
ctx: {
...ctx,
ports: {
...ctx.ports,
notifications,
},
},
input,
});
expect(notifications.deliveries).toEqual([
expect.objectContaining({
notificationName: "appointments.reminder",
}),
]);Use the inline dispatcher when a test should also verify channel rendering or mailer behavior.
Relationship to jobs, events, and outbox
Notifications represent communication intent and channel delivery; they do not replace durable workflow primitives. Workflow primitives gives the full decision guide for when to use a notification versus a job, event, schedule, command, idempotency key, or outbox record.
A common production flow is:
Use case
-> record domain event in transaction
-> outbox drains event after commit
-> listener queues a notification
-> one independently retryable job delivers each selected channel