Build a Multi-Channel Notification Dispatcher
A complete notification dispatcher: templated messages, per-channel adapters, user preferences, deduplication, delivery tracking, and retry tests.
Project goal
Deliver a notification through the user preferred channel, deduplicate identical events, retry transient failures, and record every delivery attempt.
Final features
- Channel adapters
- Template rendering
- Preference resolution
- Deduplication
- Delivery tracking
Architecture
A dispatcher consumes notification events, resolves the user channel preference, renders a template, and hands off to a channel adapter. Each attempt is persisted with a status, and a dedup key prevents duplicate sends for the same event.
Prerequisites
- Node.js 20 LTS or later
- PostgreSQL 15+
- Channel credentials for the test provider
Dependencies
- pg ^8.12.0 — Preference and delivery records
- nodemailer ^6.9.14 — Email channel adapter
- express ^4.21.0 — Enqueue endpoint
Directory structure
notification-dispatcher/
├── src/dispatch.ts
├── src/channels/email.ts
├── src/channels/webhook.ts
├── src/templates.ts
├── src/dedupe.ts
├── tests/dispatch.test.ts
└── package.json
Phases
1. Channel adapters
Define one interface implemented by email, webhook, and push.
interface Channel { send(msg: Rendered): Promise<{ id: string }>; }
Expected behavior: A uniform adapter keeps dispatch logic channel-independent.
2. Template rendering
Render the message with escaped variables per channel.
const body = template(params).replace(/[<>&]/g, escapeHtml);
Expected behavior: Escaping prevents HTML injection in email bodies.
3. Preference resolution
Choose the channel the user opted into, with a fallback.
const channel = prefs[event.type] ?? user.defaultChannel;
Expected behavior: Respecting preferences is both a UX and a compliance concern.
4. Deduplication
Skip a notification whose dedup key was already delivered.
const inserted = await db.insertIgnore('deliveries', { dedupKey });
if (!inserted) return;
Expected behavior: Duplicate events are common in at-least-once pipelines.
5. Delivery tracking
Store each attempt with a status and provider id.
await db.insertAttempt({ notificationId, channel, status, providerId });
Expected behavior: Tracking enables retries and user support.
Testing
Use a fake provider adapter and assert that a duplicate event sends once, that a transient failure retries, and that a permanent failure is recorded without retrying.
Troubleshooting
Users receive duplicate emails
The dedup key included the timestamp, making every event unique; base it on the event id.
A provider outage floods retries
Retries were unbounded; cap attempts and use exponential backoff.
Deployment
Run channel sends through a queue so a slow provider cannot block dispatch. Store provider credentials in a secret manager and rotate them without redeploy.
Known limitations
- No in-app real-time channel beyond webhook.
- Templates are code, not operator-editable.
- Digest and quiet-hours scheduling are out of scope.
Project overview
Notifications look like a one-line function until a provider fails, a user receives the same alert twice, or a message goes out on a channel the user never checks. This build treats a notification as a durable event with a delivery history rather than as a fire-and-forget call.
You will consume notification events, resolve each user’s preferred channel, render a channel-appropriate template, hand the result to an adapter, and record every delivery attempt with a status, so an operator can answer what happened to a specific message without asking the provider.
The deduplication key is what keeps a retry from becoming a double send. Because the same triggering event can be redelivered by an upstream queue, the dispatcher must recognize an event it has already handled and refuse to send again.
The design keeps channel specifics behind an adapter interface, so adding a channel is a new adapter rather than a branch in the dispatcher, and each adapter can be tested with a fake that returns a chosen outcome.
Each phase adds one property: adapters, templates, preference resolution, deduplication, and delivery tracking, so the dispatcher is useful and correct at every stage.
It is written for teams whose first notification feature was a single send call and whose second requirement was a delivery guarantee. The dispatcher turns that guarantee into a per-event state machine with a dedup key, so whether a user was notified has a stored answer rather than an inference from provider logs that expire.
What you will build
The dispatcher provides five capabilities, each with a test that uses a fake channel adapter:
- Channel adapters
- Template rendering
- Preference resolution
- Deduplication
- Delivery tracking
Adapters isolate channel details; templates render content per channel; preference resolution picks where to send; a dedup key prevents duplicate sends for one event; and delivery tracking records every attempt and outcome.
The design assumes upstream events are at-least-once, so it makes the dispatcher idempotent by dedup key rather than trusting the producer to send exactly once.
Architecture and data model
A dispatcher consumes notification events, resolves the user channel preference, renders a template, and hands off to a channel adapter. Each attempt is persisted with a status, and a dedup key prevents duplicate sends for the same event.
A dispatcher consumes notification events from a queue, resolves the recipient’s channel preference, renders the template for that channel, and hands the result to the matching adapter. The event’s identity is the deduplication key that makes the whole path idempotent.
Each attempt is persisted with the channel, the adapter response, and a status, so the delivery history is queryable and a retry is a state transition rather than a new send.
Adapters share a small interface: send a rendered message and return a classification of the outcome. Transient failures are retried with backoff and permanent failures are recorded without retrying, which keeps a rejected address from looping forever.
Preference resolution reads the user’s chosen channel or falls back to a default, so the dispatcher never decides for the user beyond a documented default.
The adapter interface is deliberately narrow: a rendered message in, a classified outcome out. That narrowness lets a fake adapter stand in for a real provider in every test and keeps the dispatcher from growing channel-specific branches as channels are added.
Deduplication is enforced in the store rather than in the dispatcher’s memory, because two dispatcher instances can consume the same event concurrently and only a shared key turns that race into a single send.
Prerequisites and dependencies
You need a queue for notification events, storage for the dedup keys and delivery history, and credentials for each channel provider.
- Node.js 20 LTS or later
- PostgreSQL 15+
- Channel credentials for the test provider
The dependencies cover the queue, the template engine, the channel adapters, and storage:
pg(^8.12.0) — Preference and delivery recordsnodemailer(^6.9.14) — Email channel adapterexpress(^4.21.0) — Enqueue endpoint
pnpm init
pnpm add pg nodemailer express
pnpm add -D typescript tsx vitest
Keep every provider credential in a secret manager and load it per adapter, because a dispatcher that fans out to several providers concentrates several credentials in one process. Record the default channel and the retry policy in configuration.
Directory structure
Separate the adapters, the template rendering, the preference resolution, the dedup store, and the delivery tracker so each is testable with fakes:
notification-dispatcher/
├── src/dispatch.ts
├── src/channels/email.ts
├── src/channels/webhook.ts
├── src/templates.ts
├── src/dedupe.ts
├── tests/dispatch.test.ts
└── package.json
Phase 1: Channel adapters
Define one interface implemented by email, webhook, and push.
interface Channel { send(msg: Rendered): Promise<{ id: string }>; }
A uniform adapter keeps dispatch logic channel-independent.
Define a small adapter interface and implement one channel behind it, so the dispatcher depends on the interface rather than on a provider. A fake adapter is then the first test double and the first real value of the boundary.
Phase 2: Template rendering
Render the message with escaped variables per channel.
const body = template(params).replace(/[<>&]/g, escapeHtml);
Escaping prevents HTML injection in email bodies.
Render the template per channel, because the same notification needs different shapes for email, chat, and push. Rendering at the dispatcher rather than in the caller keeps channel details out of the rest of the system.
Phase 3: Preference resolution
Choose the channel the user opted into, with a fallback.
const channel = prefs[event.type] ?? user.defaultChannel;
Respecting preferences is both a UX and a compliance concern.
Resolve the recipient’s preferred channel with a documented fallback, so the dispatcher sends somewhere predictable when the preference is missing or the preferred channel is disabled.
Phase 4: Deduplication
Skip a notification whose dedup key was already delivered.
const inserted = await db.insertIgnore('deliveries', { dedupKey });
if (!inserted) return;
Duplicate events are common in at-least-once pipelines.
Deduplicate on the event identity so a redelivered event does not send twice. Store the key as part of the delivery record, and treat an existing key as a no-op rather than an error.
Phase 5: Delivery tracking
Store each attempt with a status and provider id.
await db.insertAttempt({ notificationId, channel, status, providerId });
Tracking enables retries and user support.
Record every attempt with its status and provider response, so the delivery history is complete enough to diagnose a failure without querying the provider.
Testing
Use a fake provider adapter and assert that a duplicate event sends once, that a transient failure retries, and that a permanent failure is recorded without retrying.
Use a fake adapter that can return a transient or a permanent failure, and assert that a duplicate event sends once, a transient failure retries with backoff, and a permanent failure is recorded without retrying.
Assert that a resolved preference actually selects the expected adapter, because a bug that always sends on the default channel is invisible until a user complains about the wrong medium.
Assert that the dedup key is written before the send is acknowledged, since an unrecorded send is exactly the case that produces a duplicate on retry.
Assert that the delivery record captures the adapter response, because a generic failure status is not enough to distinguish a rate limit from a rejected address.
Because the failure modes are about state rather than output, the tests assert on the delivery record: the number of attempts, the status of each, and whether a duplicate produced a second record. Asserting on a fake adapter call count alone would miss a duplicate that was recorded but not sent.
pnpm vitest run
Deployment and operations
Run channel sends through a queue so a slow provider cannot block dispatch. Store provider credentials in a secret manager and rotate them without redeploy.
Run channel sends through a queue so a slow provider cannot block dispatch, and keep provider credentials in a secret manager so they can rotate without a redeploy.
Set adapter concurrency per provider rather than globally, because each provider has its own rate limit and a single global setting will overrun the strictest one.
Monitor per-channel success rates separately, since a provider outage is obvious only when the channels are charted apart.
Keep a dead-letter state for events that exhaust their attempts, and surface it so support can see a message that never arrived rather than learning from the user.
Run each channel’s adapter with its own concurrency and its own dead-letter view, because one shared setting will either overrun the strictest provider or under-use the most generous one, and one dead-letter queue hides which channel is actually failing.
Security considerations
Never log message bodies containing personal data; store only what support needs. Verify webhook signatures and enforce unsubscribe preferences at dispatch time.
Verify webhooks and callbacks from providers, because an unauthenticated delivery callback can mark messages as delivered or inject a bounce.
Keep notification content free of secrets and do not log the full body, since a dispatcher sees every message the system sends and is a tempting place to read data.
Bound the template inputs, because a template that interpolates user input can inject channel-specific markup such as HTML into an email.
Rate-limit the event source, so a bug that emits events in a loop cannot flood every user on every channel at once.
Webhooks and delivery callbacks are the half of the integration people forget to authenticate. An unverified status callback lets an attacker mark messages delivered and suppress a real retry, so the endpoint needs the same signature discipline as the outbound send.
Reject an event whose recipient or channel is unresolvable rather than defaulting silently, because a default that sends on the wrong channel is a privacy problem when the content is sensitive.
Observability
Track per-channel success rate, retry rate, and time-to-delivery. Alert when a channel success rate drops, which often means an expired credential.
Track sends, retries, and failures per channel, because the retry-to-failure ratio is the clearest signal that a provider changed behavior.
Chart the dedup hit rate, since a rising rate means the upstream producer is redelivering more than before and may need attention.
Measure the time from event to delivery, because a growing delay points at queue depth or adapter concurrency rather than at the provider.
Alert on permanent-failure rate per channel, because a step change usually means a credential expired or a template broke for that channel.
Watch the gap between the dedup hit rate and the send rate, because when the two diverge the producer is either redelivering aggressively or the dedup window is too short and legitimate sends are being suppressed as duplicates.
Track the number of events that exhaust their attempts, because a small but steady number usually means one address list or one template is broken rather than a systemic problem.
Troubleshooting
Users receive duplicate emails. The dedup key included the timestamp, making every event unique; base it on the event id.
A provider outage floods retries. Retries were unbounded; cap attempts and use exponential backoff.
If a user receives a message twice, check the dedup key first, because a key that omits the channel or the event type will treat two distinct sends as one or two as two.
If sends stop on one channel only, check that channel’s credential before the dispatcher, since a single expired token affects one adapter while everything else stays healthy.
If delivery records show attempts but no provider-side receipt, check the adapter response parsing, because a response the adapter cannot classify is recorded as a generic failure even though the provider delivered.
Limitations and next steps
- No in-app real-time channel beyond webhook.
- Templates are code, not operator-editable.
- Digest and quiet-hours scheduling are out of scope.
The dispatcher handles one event per delivery and does not implement batched digests or scheduled sends, which a marketing system would need.
Preference resolution supports a single preferred channel with a fallback, not per-event-type preferences or quiet hours.
There is no in-app notification center or read state; the dispatcher sends and records delivery, and presentation is left to the client.
Provider failover within a channel is absent, so a single provider outage stops that channel rather than shifting to a backup.
The service does not model quiet hours or per-notification-type preferences, so a system that must not wake users at night would need to extend preference resolution, and there is no rendered preview, which means template changes are verified by test rather than by eye.