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.

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 migrate

The 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