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:

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