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

Create 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. 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.

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.

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 sends notification
  -> notification channel sends mail or dispatches channel work