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.ts
import {
createNotificationHandlers,
defineMailNotificationChannel,
} from "@beignet/core/notifications";
import { z } from "zod";
import type { AppContext } from "@/app-context";
const notifications = createNotificationHandlers<AppContext>();
export const AppointmentReminderNotification =
notifications.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 createInlineNotificationDispatcher(...) for local development and tests.
Production apps can call the same port from jobs or listeners so notification
delivery is backed by jobs and outbox after the database transaction commits.
import { createInlineNotificationDispatcher } from "@beignet/core/notifications";
let currentPorts: AppContext["ports"] | undefined;
const notifications = createInlineNotificationDispatcher<AppContext>({
ctx: () => {
if (!currentPorts) {
throw new Error("Notification context is not ready.");
}
return createBackgroundContext(currentPorts);
},
instrumentation: ports,
});
The dispatcher validates payloads before invoking channel handlers and emits
devtools events through the notifications watcher when instrumentation is
available.
Other channels
Email is the first built-in channel helper because Beignet already has a
MailerPort. SMS, push, and in-app notifications should use app-owned channel
handlers for now:
export const AppointmentReminderNotification =
notifications.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.
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 do not replace durable workflow primitives:
- Events represent facts that happened.
- Jobs represent background work.
- Outbox records work transactionally so it happens after commit.
- Notifications represent communication intent and channel delivery.
A common production flow is:
Use case
-> record domain event in transaction
-> outbox drains event after commit
-> listener sends notification
-> notification channel sends mail or dispatches channel work