Skip to content
Backend

One Notification System to Rule Every Channel

Intent vs delivery as the founding seam: a typed notification catalog, a preference engine that resolves instead of looks up, queue-per-channel adapters, digesting, and the build order that avoids the rewrite.

5 min read Updated Sep 3, 2026
One Notification System to Rule Every Channel

Notification systems are never designed; they accrete. First a mailable, then a push provider, then someone adds in-app toasts, then marketing gets a tool of their own — and two years later "notify the user" means five code paths, duplicate sends, a preferences page that lies, and a compliance question nobody can answer. I've now rebuilt this system enough times that the target architecture is stable. Let me draw it once, properly, so you can build it before the accretion instead of after.

Domain events flowing through a preference engine and channel router to push, mail and in-app delivery

The core separation: intent vs delivery

Every notification mess traces to one conflation: business code deciding how to deliver. The fix is a hard seam. Business code emits an intent — "this user should know their order shipped" — and a notification pipeline owns everything after:

Domain event ──▶ Notification intent ──▶ Preference engine ──▶ Channel router
 (OrderShipped)   (type, user, payload)   (may I? where?)       (push? mail? in-app?)
                                                                      │
                                              ┌───────────┬──────────┼─────────┐
                                              ▼           ▼          ▼         ▼
                                          OneSignal     Mailer    in_app    (SMS…)
                                                                  table

In Laravel, the skeleton is pleasantly native — a Notification class per type, with via() as the router hook; in .NET you'll write the dispatcher yourself in an afternoon. The architecture is identical: one entry point, one decision layer, N delivery adapters (each provider behind a port — the genuinely swappable seam where interfaces earn their keep).

The notification catalog: types as first-class citizens

The system's backbone is a registry of notification types, each declaring its category (transactional / activity / marketing), default channels, whether users may mute it, and its collapse/digest behavior:

enum NotificationType: string
{
    case OrderShipped     = 'order.shipped';      // transactional — unmutable, push+mail
    case CommentReply     = 'comment.reply';      // activity — mutable, push+in_app, digestible
    case WeeklyDigest     = 'digest.weekly';      // marketing — mutable, mail only

    public function category(): Category { /* ... */ }
    public function defaultChannels(): array { /* ... */ }
    public function userMutable(): bool { /* ... */ }
}

This enum-with-metadata is deceptively powerful: the preferences UI renders from it (no more page/reality drift), the preference engine enforces from it, compliance questions ("what do we send and why") are answered by it, and adding a notification type becomes a one-file change reviewed like the small API change it is.

The preference engine: resolution, not lookup

Real preference checks are a resolution chain, evaluated in order: legal/compliance gates (marketing consent, KVKK/GDPR flags — non-negotiable), user per-type-per-channel choices (the settings page), capability (is the user even pushable? verified email?), and protective rules — quiet hours in the user's timezone and frequency caps for non-transactional sends. The output isn't a boolean; it's a delivery plan: send via in-app now, push suppressed (quiet hours), mail skipped (user muted). Log that plan — "why didn't I get the email?" tickets die instantly when support can read the resolution trace.

Delivery: queue-everything, adapter-per-channel

Every send is a queued job per channel — provider hiccups retry without touching business flow, channels fail independently (mail down ≠ push down), and each adapter stays dumb: template in, provider API out, result recorded. Two non-obvious rules: the in-app channel is just a table (notifications: user, type, payload, read_at) — it's the one channel you fully own, it never bounces, and it doubles as the user-facing history; and record every send in a unified log (type, user, channel, provider ID, status) — this is where delivery webhooks land, where the idempotency check lives (dedupe on (type, user, entity, channel) within a window — the retry that would double-send hits the unique index and stops), and where analytics start.

Digesting: the feature that saves the channel

The highest-leverage component nobody builds early: batching. Twelve comment replies in an hour should be one "12 people replied" push, not twelve. Mechanically it's modest — digestible types write to a buffer table instead of sending; a scheduled job (Quartz/Laravel scheduler) flushes per user per window, collapsing buffered intents into one templated summary. The judgment call is per-type windows: activity digests hourly, social stuff daily, transactional never. Teams that skip digesting spend the saved week later, on the churn analysis.

Build order, for the team starting today

  1. The type enum + unified send log + in-app table — one sprint, and everything after hangs off it.
  2. Channel adapters behind ports (mail you have; push via provider; in-app free).
  3. Preference engine with the resolution chain — and the settings page rendered from the catalog.
  4. Digesting for the chattiest type you have.
  5. Delivery feedback loop + the four health metrics from the lifecycle post.

None of it is hard; all of it compounds. A notification system with a catalog, a resolution trace and a send log is boring to operate and trivially auditable — and "boring and auditable" is precisely what the accreted version never achieves, no matter how many rewrites it survives.

Whether you're at the accretion stage or the blank page, this is a system I can draw onto your stack in a week — the brief form awaits.

Keep reading

Related articles

Backend 5 min read

The Caching Stack: From Browser to Buffer Pool

Five layers walked top to bottom — browser headers, CDN edge with its famous incident, Redis with a job description, database-adjacent options — plus the staleness grid that makes TTLs a product decision.

Backend 4 min read

GraphQL: An Honest Take After the Hype Cycle

The specific problem it solves brilliantly, the five bills itemized — resolver N+1s, forfeited HTTP caching, query-surface DoS, field-level auth, the toolchain — and the BFF alternative most teams actually need.