One seam, many channels
The notification dispatcher pattern, a single choke point that turns every multi-channel send into a one-file edit.
Your app already sends one notification. When an invitation goes out, the invite flow calls into your email wrapper and a message lands in someone’s inbox. That was the transactional-email work, and it stayed clean because there was exactly one of them. Now the product grows. The team wants an in-app inbox so a user signed into the app sees the invite without leaving the tab. A role change should reach the affected member. A past-due payment should reach the org’s owners. Next quarter, maybe push notifications.
Consider what happens if you build each of those the way you built the first one. The Server Action that changes a role grows a sendEmail(...) call and an INSERT into a notifications table. The action that marks billing past-due grows its own pair, and so does the next one, and the one after. Some of them remember to check whether the user actually wants email; some forget. Then push lands, and adding it means opening every action that ever told a user anything and threading a third call through it. The channel knowledge, meaning how a notification reaches a person, is now spread across dozens of files, and every one of them is a place the next change can break.
This lesson introduces the seam that prevents all of that. You’ll leave with three things: the dispatcher and the single call shape every notification flows through, the notifiable_events registry that lists every notification your app can send, and the rule that decides whether an event belongs in a user’s inbox or only in an operator’s audit log. This is the same architectural move you’ve made twice before. The Server Action boundary became the one place writes happen, the webhook handler became the one place untrusted events are verified, and now the dispatcher becomes the one place channel decisions live. A named choke point turns a cross-cutting concern into a one-place edit. The next three lessons fill in the channels, the preferences, and the deduplication, and the chapter after that wires the seam into three real flows.
The seam: one event in, many channels out
Section titled “The seam: one event in, many channels out”Here is the whole pattern in one sentence: call sites fire one event, and the dispatcher owns every channel decision. Everything else in the lesson builds on that.
A call site is a place where something happens worth telling a user about, such as a Server Action that changed a role or a webhook handler that processed a payment. Its job is to describe what happened and who should know. It does not decide whether email is involved, or what the email says, or whether the user has muted this kind of message. It hands all of that to a single function:
await dispatch({ type: 'org.member.role_changed', recipientUserIds: [member.userId], subjectId: member.id, payload: { newRole: 'admin', changedBy: actor.name },});That call site never imports your email library, and it never touches a notifications table. It states a fact and returns. Behind the function, a fan-out happens: the one event becomes an email to some recipients and an inbox row for others, depending on rules the call site doesn’t need to know.
You’ve seen this shape twice already, and recognizing it is the fastest way in. When you learned Server Actions, the action boundary became the canonical write seam: every mutation goes through one wrapper, so authorization and validation live in one place instead of scattered across handlers. When you wired up Stripe billing, the webhook handler became the trust seam, where every untrusted event gets verified and made idempotent at one door. This is the notification seam, and the payoff is identical. The cross-cutting concern, which here is channel knowledge plus preferences plus dedup, lives in exactly one module, so adding a channel or changing a preference rule is a single-file edit instead of a sweep across the codebase.
The following diagram lays the seam out in space. Read it left to right.
Notice one thing the diagram does on purpose: the audit-log arrow does not pass through the dispatcher. That’s a deliberate decision, and the back half of this lesson explains why. For now, take the diagram’s main claim as a rule about the call site itself. A call site that imports your email function, or writes to the notifications table directly, has leaked channel knowledge and broken the seam. That is the one regression the rest of this chapter exists to prevent. If you ever find a sendEmail call outside the notifications module, the seam has a gap, and a grep for direct sends is a real check you’d run to catch it.
The notifiable_events registry: one file, every notification
Section titled “The notifiable_events registry: one file, every notification”Ask a simple question about any app you didn’t write: what notifications can this thing send? If every event were defined inline at its own call site, the only way to answer would be to read the entire codebase and collect every sendEmail by hand. That makes the system hard to reason about. The fix is to make the set of notifications enumerable, meaning you put it in one place you can open and read top to bottom.
That place is the registry : a typed map keyed by event type, where each entry declares everything the dispatcher, the preferences UI, and the templates need to know. Keep it small to start, around three to five events, and let it grow as the product does.
export const notifiableEvents = { 'org.invitation.sent': { channels: ['email', 'inbox'], template: invitationEmail, preferenceCategory: 'team', dedup: { windowSeconds: 60, keyBy: ['subjectId'] }, description: 'Someone was invited to your organization', }, 'org.member.role_changed': { channels: ['email', 'inbox'], template: roleChangedEmail, preferenceCategory: 'team', dedup: { windowSeconds: 60, keyBy: ['subjectId', 'newRole'] }, description: 'A member’s role changed', }, 'billing.past_due': { channels: ['email', 'inbox'], template: pastDueEmail, preferenceCategory: 'billing', dedup: { windowSeconds: 60, keyBy: ['subjectId'] }, description: 'A subscription payment is past due', },} as const satisfies Record<string, NotifiableEvent>;
export type EventType = keyof typeof notifiableEvents;The keys are dotted string literals such as 'org.member.role_changed' and 'billing.past_due', and the scheme behind them is deliberate. The shape is domain.entity.action: the domain the event lives in, the entity it’s about, then the thing that happened. It reads top-down and it scans, because every org.* event is an organization concern and every billing.* event is money. When you add the fortieth event type, this naming is what keeps the file searchable instead of an undifferentiated list of strings.
Two pieces of TypeScript are doing quiet work here. The as const freezes the object into literal types, so 'org.invitation.sent' is its own type rather than just string, which is what lets EventType become the exact union of valid keys. The satisfies checks each entry against the NotifiableEvent shape without widening anything, so a typo in a field name is a compile error while the literal keys stay narrow. You met this exact pairing when you learned TypeScript, and this is one of its highest-value uses.
Now look at a single entry up close. The following walkthrough takes each field one at a time.
'org.member.role_changed': { channels: ['email', 'inbox'], template: roleChangedEmail, preferenceCategory: 'team', dedup: { windowSeconds: 60, keyBy: ['subjectId', 'newRole'] }, description: 'A member’s role changed',},channels is the default set of channels for the event, here both email and the inbox. The word “default” matters, because user preferences can subtract from this set, so a user who muted team email still gets the inbox row. The dispatcher reads this field, and the call site never names a channel.
'org.member.role_changed': { channels: ['email', 'inbox'], template: roleChangedEmail, preferenceCategory: 'team', dedup: { windowSeconds: 60, keyBy: ['subjectId', 'newRole'] }, description: 'A member’s role changed',},template is a reference to what renders the message: the React Email component for email, plus an inbox formatter for the in-app row. The registry references templates; it never inlines them. The next lesson builds these.
'org.member.role_changed': { channels: ['email', 'inbox'], template: roleChangedEmail, preferenceCategory: 'team', dedup: { windowSeconds: 60, keyBy: ['subjectId', 'newRole'] }, description: 'A member’s role changed',},preferenceCategory is the category the user toggles in their settings, such as 'team', 'billing', or 'security'. Many events share one category, so a user mutes a whole class at once rather than each event individually. The lesson on preferences owns this schema.
'org.member.role_changed': { channels: ['email', 'inbox'], template: roleChangedEmail, preferenceCategory: 'team', dedup: { windowSeconds: 60, keyBy: ['subjectId', 'newRole'] }, description: 'A member’s role changed',},dedup is the window and key shape that collapse rapid duplicates, so here the same role change to the same member within 60 seconds counts as one notification. The lesson on dedup builds the mechanic and explains why the key includes more than just the subjectId; in the registry it’s only a declaration.
'org.member.role_changed': { channels: ['email', 'inbox'], template: roleChangedEmail, preferenceCategory: 'team', dedup: { windowSeconds: 60, keyBy: ['subjectId', 'newRole'] }, description: 'A member’s role changed',},description is the human-readable label the preferences UI shows next to the toggle, and it’s also where a new event type has to justify why it exists. If you can’t write an honest one-line description, the event probably isn’t notifiable.
The takeaway is a rule about where change goes: a new event type is added in this one file. The dispatcher reads its channels, the preferences UI reads its preferenceCategory and description, and the templates are referenced from it. One file, one source of truth. This is the same principle you already follow with your Drizzle schema, where the table definition is the single place the row types, the validators, and the column names all come from. The registry applies that idea to notifications.
The dispatcher’s contract: input, side effects, output
Section titled “The dispatcher’s contract: input, side effects, output”A function’s contract is the promise it makes: what you must give it, what it changes in the world, and what it hands back. Building the dispatcher’s body is the next chapter’s job. Here you learn the contract, because the contract is the API you’ll call from real code.
Input. A typed event. The following shape is what every caller constructs.
type NotificationEvent = { type: EventType; recipientUserIds: string[]; subjectId: string; payload: Record<string, unknown>;};Four fields, each earning its place. type is a key of the registry, and the compiler rejects any event the registry doesn’t know about, so you can’t fire a notification that has no entry. recipientUserIds is always a list, even when there’s a single recipient. That uniformity is deliberate: if the field were sometimes one id and sometimes many, the dispatcher would have to branch on cardinality everywhere it loops, so one shape means no branching. subjectId is the entity the event is about, such as the invitation’s id, the invoice’s id, or the member’s id, and it’s what dedup keys on and what the inbox row links back to. payload carries the data the templates need to render: the new role, the amount due, the actor’s name.
Side effects. What the dispatcher does to the world. For each recipient whose preferences allow the inbox, it writes an inbox row. For each recipient whose preferences allow email, it enqueues an email. And it records the dispatch so dedup can recognize a duplicate later. Each of those is gated by a decision the dispatcher owns and the caller never sees. How each gate works is a later lesson, but the contract is that the dispatcher decides, then acts.
Output. This part is easy to skip and worth holding onto: the dispatcher returns a report.
type DispatchResult = { sent: number; deduped: number; suppressedByPrefs: number;};It tells the caller how many notifications actually went out, how many were collapsed as duplicates, and how many were suppressed because a user had opted out. The point is that the dispatcher hands back observability instead of making the caller guess what happened. Notice this is a plain count summary, not a Result type, because there’s no expected-failure path to model here, just numbers worth logging. Use the shape that fits; not every return needs to be a discriminated union.
That raises a question: what happens when a channel does fail? This is part of the contract too. The dispatcher never throws because one channel failed. If the inbox write succeeds but the email send hits a transient error, the dispatcher logs the failure and keeps going, so the inbox row stays and one failing channel doesn’t take down the others. Channels are independent by design. The next lesson builds the per-channel try/catch that enforces this, but the rule belongs to your mental model now: you fire one event and get best-effort fan-out plus a report. If the dispatcher threw on the first channel error, a flaky email provider could break role changes, and that trade is never worth making.
One more piece of the input is worth a closer look: recipientUserIds is an already-resolved list. The dispatcher does not take “the org’s owners” or “everyone with the billing role” and work out who that is. Resolving an audience to concrete user ids is the caller’s job, because the caller is the one that knows the business rule. The action marking billing past-due already knows it wants owners, and the helper to get them, getOrgMembersByRole(orgId, 'owner') from your organizations-and-roles work, is right there at the call site. Keeping that knowledge at the call site is what keeps the dispatcher audience-neutral: it stays a pure fan-out engine instead of accumulating org-specific logic that doesn’t belong to it.
Where the dispatcher is called: after the write is durable
Section titled “Where the dispatcher is called: after the write is durable”The placement rule is short: fire after the state change is durable.
The canonical home is inside the Server Action that performed the mutation, the same authedAction wrapper you’ve been using, after the database write has committed. Webhook handlers call the same dispatcher after their idempotent state transition, and background jobs can call it too. The dispatcher doesn’t care who the caller is; it cares only that the thing it’s announcing has actually, durably happened.
Why after commit and not during? Because a notification for an action that rolled back is worse than a notification you never sent. If you tell a user their role changed and then the transaction fails and rolls back, their inbox now contradicts reality. You already follow the rule that prevents this: external calls live outside db.transaction, never inside it. You learned that rule with Server Actions, to avoid holding a database connection open while you wait on a network round-trip. dispatch is exactly such an external call, so it slots into the position you already know.
export const changeRole = authedAction( 'admin', changeRoleSchema, async (input, ctx) => { const member = await ctx.db.transaction(async (tx) => { return updateMemberRole(tx, input.memberId, input.role); });
revalidateTag(orgTags.members(ctx.orgId));
await dispatch({ type: 'org.member.role_changed', recipientUserIds: [member.userId], subjectId: member.id, payload: { newRole: input.role, changedBy: ctx.user.name }, });
return ok(member); },);Read the order. The wrapper has already done the first two gates, authorizing the 'admin' role and parsing the input against changeRoleSchema before your body ran, so the body is just the work: mutate inside a transaction, revalidate the cache, then dispatch, then return the result. You’ve written that shape dozens of times. The only new thing is that dispatch takes the post-write slot, after the commit and the revalidate. That’s the whole rule, and it’s the rule you already know applied to a new call.
Two caveats are worth carrying with this rule.
The first is the gap. “After commit” means there’s a short window where the write has landed but the notification hasn’t fired yet, and if the process crashes in that window, the notification is lost. For most apps that’s an acceptable risk. When it isn’t, the upgrade is the transactional outbox : write a pending_notifications row inside the transaction, then drain it from a worker, so the committed action and its notification are atomic. Know that it exists, and reach for it when a post-commit gap becomes unacceptable. For this chapter, the after-commit call is enough.
The second is where the channel sends actually run. In this chapter, the dispatcher runs in-line with the request, so the email send happens before the action returns. That’s the right default for tens of events a minute. Three signals tell you it’s time to move the sends behind a durable queue: send volume climbs, email latency starts showing up in how long user-visible actions take, or sends need retries that survive a process crash. At that point the channel sends move behind a Trigger.dev queue, the durable-job tool you used for background work, and the dispatcher’s job shrinks to writing the event row and enqueueing one job per channel. You ship in-line first because it works, and because it leaves you exactly one place to make that change later.
Notifications or audit logs: who reads it?
Section titled “Notifications or audit logs: who reads it?”You’ve now seen the seam and the contract. The second pillar of this lesson is the line that decides, for any event, which of two tables it writes to. This is the spot people get wrong most often, so it’s worth taking slowly.
Frame the two tables by audience, not by their columns. The notifications table is the in-app inbox, and it is user-facing. A user opens it, reads it, marks things read, and expects every row to be relevant to them. The audit_logs table, the append-only table you built alongside organizations and roles, is operator-facing. An org admin reviews it for compliance, security, and incident response, and the end user never sees it. That difference in audience is the entire distinction, and it reduces to one question you can ask about any event: who reads it?
Some events answer “both.” A role change writes a notification to the demoted member and an audit row on the org: the affected person is told, and the organization keeps the record. Some answer “only one.” A failed login attempt writes only an audit log, because a user does not want an inbox alert each time they mistype their own password. A “welcome aboard” message writes only a notification, because it has no compliance value, so nothing belongs in the audit log. Keeping the two tables separate matters for a concrete reason. If you merge them to save a query, you’ve coupled two audiences into one table, which forces every inbox query to filter out the operator rows the user must never see. That filter is a permanent tax on every read, and forgetting it once exposes audit data to a user. Two audiences, two tables.
The following decision filter turns “who reads it?” into a short procedure you can run. Walk it for any event you’re unsure about, and it lands you on the right table.
A welcome-aboard message or a comment mention. The user genuinely wants to see it, but no operator ever needs a record that it happened, so it writes one inbox row and nothing else.
A failed-login attempt, or an admin running a destructive query. An operator must have the immutable record for security and incident response, but the user must never get an inbox ping for it. Pinging someone every time they mistype their own password is noise, not a notification.
A role change or a billing-past-due event. The affected user is told through an inbox row, and the org keeps the immutable record in an audit row. Two audiences, two writes, one event.
A cache invalidation, a finished background cleanup, a per-keystroke draft save. Nobody needs to be told. This is the default: an event earns a notification only when “who reads it?” answers with a real audience, and most of what an app does is invisible plumbing that writes to neither table.
That last leaf is worth pausing on. The default for any new event is no notification at all. Most of what happens inside an app, such as a cache getting invalidated, a cleanup job finishing, or a draft autosaving on every keystroke, is invisible plumbing the user never needs to know about. An event earns a notification by answering “who reads it?” with a real audience, not simply by happening.
Now apply the question to the events this app actually fires. Run “who reads it?” down the list and the table falls out:
| Event | Who reads it | | --- | --- | | Invitation sent | notification to the invitee + audit row on the org | | Role changed | notification to the affected member + audit row on the org | | Billing past-due | notification to the org owners + audit row on the org | | Login failed | audit row only | | Password changed | notification to the user (a security signal they should see) + audit row on the account |
The rule maps cleanly once you ask the audience question first. A password change is the interesting case: the user genuinely wants to see “your password was changed” as a security signal, and the account needs the immutable record, so it writes both, for two different reasons.
Try sorting these yourself. The following drill gives you a handful of real events. Drop each into the table it belongs to, judging by who reads it.
Sort each event by who reads it — the end user, an operator, or both. Drag each item into the bucket it belongs to, then press Check.
The notifications table: a row is a snapshot
Section titled “The notifications table: a row is a snapshot”The last piece is the shape of an inbox row, which carries one decision that surprises people every time. The next lesson builds the writer; here you just settle the data model.
export const notifications = pgTable('notifications', { id: uuid().primaryKey().$defaultFn(() => uuidv7()), userId: uuid().notNull(), orgId: uuid(), eventType: text().notNull(), subjectId: uuid().notNull(), title: text().notNull(), body: text().notNull(), payload: jsonb().notNull(), readAt: timestamp({ withTimezone: true }), createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(),});Most of these columns explain themselves. userId is the recipient. orgId is the org context, nullable because a personal event like a password change has no org. eventType matches a registry key, subjectId points at the entity, and readAt is null until the user opens the message. payload holds structured data the UI needs, such as a link target or an actor name. The full DDL and the (userId, createdAt desc) index that powers the feed are the next lesson’s job.
The two columns that carry the real decision are title and body. These are computed when the event fires and stored on the row, rendered at dispatch rather than at display. The inbox UI then becomes a pure render of the row: it reads title and body and shows them, with no join to live data.
Contrast the alternative, because it’s the one people reach for by instinct. Render-at-display would store only the raw ids and compute the text when the user opens their inbox, joining to the users table for the actor’s name, to the org for its name, to the invoice for the amount. That seems tidier until any of those things change. If the actor renames themselves, every old notification that mentioned them silently rewrites itself. If the invoice amount gets corrected, the past-due notice from last week now quotes a number that was never shown at the time. On top of that, every inbox read pays for those joins. Snapshotting avoids all of it, because the row records the moment.
This leads to the one thing people often misread as a bug. A notification whose title says “Jane changed your role” stays exactly that even after Jane renames herself to “Jane Doe.” That is not stale data; it’s the intended behavior. The row is a record of what was true when the event fired, the way a receipt doesn’t update when a store changes its name. The practical move is to snapshot the display strings into title and body while keeping stable ids in payload, so the UI can still link through to the live entity even though the text is frozen.
One last column: readAt. Null means unread. The unread badge is a single count of rows where readAt is null, and marking something read is a single update that stamps it with the current time. The queries and the inbox UI itself land in the next lesson and the project starter, so for now just hold the shape in mind.
Where this goes next
Section titled “Where this goes next”Here’s the model to walk away with: call sites fire one event, the dispatcher owns every channel decision, and audit logs are a separate, operator-facing write. The dispatcher is the third named choke point you’ve built, after the action boundary and the webhook seam, and it earns the same payoff: channel knowledge lives in one module, so the system grows by editing one file instead of sweeping across dozens.
From here, the seam fills in. The next lesson builds the two channel functions, email through your existing Resend wrapper and the inbox-row writer, along with the rule that one channel failing never kills the other. The lesson after that adds category-grained preferences, read exactly once inside the dispatcher. The one after that adds the 60-second dedup window that lets call sites fire freely without coordinating. Then the next chapter wires this seam into three real flows, and you see the pattern carry its weight.
External resources
Section titled “External resources”Chris Richardson's canonical pattern entry, the authoritative reference for the outbox upgrade the lesson names.
A full walkthrough using the same dispatcher and fan-out-on-write vocabulary, scaled up to many channels.
An opinionated essay on writing the title and body text you snapshot onto each inbox row.