Ein Benachrichtigungssystem für SaaS bauen: Kanäle, Präferenzen und Zustellgarantien

#Benachrichtigungssystem SaaS
Sandor Farkas - Founder & Lead Developer at Wolf-Tech

Sandor Farkas

Gründer & Lead Developer

Experte für Softwareentwicklung und Legacy-Code-Optimierung

Jedes SaaS-Produkt braucht irgendwann Benachrichtigungen. Eine neue Nachricht trifft ein, ein Abonnement läuft bald aus, ein Teammitglied erwähnt dich in einem Kommentar - der Nutzer muss davon erfahren. Die erste Implementierung ist meist ein direkter Aufruf: hier eine E-Mail senden, dort eine Slack-Nachricht absetzen. Für ein Wochenendprojekt funktioniert das. Es hört in dem Moment auf zu funktionieren, in dem du einen zweiten Kanal hinzufügst, Nutzer ihre Präferenzen konfigurieren lässt oder ein System betreibst, in dem der erneute Versuch einer fehlgeschlagenen Zustellung dieselbe Benachrichtigung zweimal senden kann.

Ein Benachrichtigungssystem für SaaS zu bauen, das wirklich skaliert, erfordert, vier Probleme gleichzeitig zu durchdenken: welche Kanäle du unterstützt, wie Nutzer ihre Präferenzen ausdrücken, wie du Zustellung ohne Duplikate garantierst und wie das Ganze wartbar bleibt, während die Anforderungen wachsen. Dieser Beitrag geht jede dieser Ebenen praxisnah durch.

Warum Benachrichtigungssysteme schnell kompliziert werden

Die naive Implementierung behandelt Benachrichtigungen als Fire-and-Forget-Nebeneffekte: Ein Nutzer registriert sich, also rufst du inline $mailer->send(...) auf. Das erzeugt eine Reihe von Problemen, die erst in der Produktion auftauchen.

Kopplung an den Zustellmechanismus. Deine Geschäftslogik hängt jetzt von einem externen Dienst ab. Ist das SMTP-Relay langsam, ist dein Registrierungs-Endpoint langsam. Ist es ausgefallen, scheitern Registrierungen.

Keine Retry-Logik. Vorübergehende Fehler - Netzwerkaussetzer, Rate Limits, Provider-Ausfälle - verwerfen Benachrichtigungen stillschweigend ohne Wiederherstellungspfad.

Keine Unterstützung für Präferenzen. Manche Nutzer wollen E-Mail. Manche wollen Slack. Manche wollen gar nichts. Einen einzigen Kanal fest zu verdrahten bedeutet, entweder alle zu nerven oder Präferenzlogik verstreut über jede Aufrufstelle zu bauen.

Doppelter Versand bei Retries. Sobald du eine Job-Queue hinzufügst, um die ersten beiden Probleme zu lösen, führst du ein neues ein: Stürzt der Worker nach der Zustellung, aber vor der Bestätigung ab, läuft der Job erneut und der Nutzer erhält zwei Kopien derselben Benachrichtigung.

Jedes davon ist lösbar. Der Schlüssel ist, sie in der richtigen Reihenfolge zu lösen, mit einem Design, das jedes Anliegen in seiner eigenen Ebene hält.

Ebene 1: Das Benachrichtigungs-Event

Die Grundlage eines sauberen Benachrichtigungssystems ist die Trennung von Absicht und Zustellung. Statt direkt einen Mailer oder einen Slack-Client aufzurufen, gibt dein Anwendungscode ein Benachrichtigungs-Event aus - ein einfaches Datenobjekt, das beschreibt, was passiert ist, für wen und welcher Kontext relevant ist.

final class SubscriptionExpiringNotification
{
    public function __construct(
        public readonly string $userId,
        public readonly \DateTimeImmutable $expiresAt,
        public readonly string $planName,
    ) {}
}

Dieses Objekt hat kein Wissen darüber, wie es zugestellt wird. Es beschreibt nur den Fakt: Nutzer $userId hat ein Abonnement, das um $expiresAt ausläuft. Ein Dispatcher nimmt das Event entgegen und übergibt es an die Zustellebene. Dein Anwendungscode gibt das Event aus und macht weiter.

Diese Trennung bringt dir sofort mehreres: Die Geschäftslogik ist testbar, ohne Mailer zu mocken, du kannst neue Kanäle hinzufügen, ohne den auslösenden Code anzufassen, und du hast einen natürlichen Punkt, an dem du Nutzerpräferenzen anwendest, bevor irgendeine Zustellung passiert.

Ebene 2: Nutzerpräferenzen

Nutzerpräferenzen sollten als explizite Regeln gespeichert werden, nicht als boolesche Flags pro Kanal. Ein boolescher Ansatz - email_notifications: true, slack_notifications: false - erzwingt eine Schema-Migration jedes Mal, wenn du einen Kanal hinzufügst. Ein regelbasierter Ansatz speichert die Zuordnung zwischen Event-Typen und Kanälen und lässt Nutzer sie pro Event-Kategorie konfigurieren.

Ein einfaches Schema speichert eine Zeile pro Nutzer pro Event-Typ pro Kanal:

CREATE TABLE notification_preferences (
    user_id       UUID         NOT NULL,
    event_type    VARCHAR(100) NOT NULL,
    channel       VARCHAR(50)  NOT NULL,
    enabled       BOOLEAN      NOT NULL DEFAULT TRUE,
    PRIMARY KEY (user_id, event_type, channel)
);

Wenn ein Benachrichtigungs-Event eintrifft, fragt der Dispatcher diese Tabelle ab, um zu bestimmen, welche Kanäle für diesen Nutzer verwendet werden. Existiert keine Präferenz, greife auf einen sinnvollen Default zurück - typischerweise nur E-Mail. Das gibt Nutzern Kontrolle, ohne dass sie irgendetwas konfigurieren müssen, bevor das System funktioniert.

Eine praktische Überlegung: Event-Typen sollten im UI in Kategorien gruppiert werden ("Konto & Abrechnung", "Team-Aktivität", "Sicherheitswarnungen"), auch wenn sie in der Datenbank als einzelne Typen gespeichert sind. Sicherheitskritische Benachrichtigungen - Passwortänderungen, neue Anmeldung von einem unbekannten Gerät - sollten nicht abwählbar sein und unabhängig von der Präferenz immer per E-Mail zugestellt werden. Mache das explizit im Schema oder in der Dispatcher-Logik, nicht als Sonderfall, der tief im Zustellcode vergraben ist.

Ebene 3: Multi-Channel-Zustellung

Jeder Kanal ist ein separater Adapter, der ein gemeinsames Interface implementiert:

interface NotificationChannel
{
    public function supports(string $eventType): bool;
    public function deliver(NotificationEnvelope $envelope): void;
}

Das NotificationEnvelope umhüllt das ursprüngliche Event mit aufgelösten Empfängerdetails - E-Mail-Adresse, Slack-Nutzer-ID, Push-Token -, damit jeder Adapter hat, was er braucht, ohne die Datenbank erneut abzufragen. Adapter werden in einer Sammlung registriert und anhand der Präferenzen des Nutzers und des Event-Typs ausgewählt.

E-Mail, In-App, Slack und Push haben jeweils wirklich unterschiedliche Fehlermodi und Rate Limits. E-Mail-Provider wie Postmark oder SendGrid sind im Allgemeinen zuverlässig, haben aber Sendelimits pro Konto. Slack-API-Aufrufe können mit rate_limited-Antworten fehlschlagen, die exponentielles Backoff erfordern. Push-Benachrichtigungen über FCM oder APNs sind Fire-and-Forget ohne Zustellbestätigung. Entwirf jeden Adapter so, dass er die Fehlermodi seines Kanals explizit behandelt, statt dich auf einen generischen Retry-Wrapper zu verlassen.

Wenn du einen In-App-Benachrichtigungsfeed anbietest - das Glockensymbol mit einem Badge-Zähler -, behandle ihn als separaten Storage-Schreibvorgang und nicht als Zustellkanal. Schreibe den Benachrichtigungsdatensatz direkt in deine Datenbank. Dieser Kanal schlägt nie fehl und braucht keine Queue. Nutze ihn als zuverlässige Prüfspur dessen, was das System zuzustellen versucht hat.

Ebene 4: Queuing und Zustellgarantien

Zuverlässige Zustellung erfordert eine persistente Queue. Der Dispatcher stellt Benachrichtigungen nicht direkt zu; er reiht einen Zustell-Job ein und kehrt sofort zurück. Der Job trägt genug Informationen, um den Zustellversuch unabhängig erneut auszuführen: das serialisierte Event, den aufgelösten Kanal und eine eindeutige Benachrichtigungs-ID.

Symfony Messenger mit einem Datenbank-Transport oder RabbitMQ funktioniert hier gut. Die wichtigen Konfigurationsentscheidungen sind:

Transport-Persistenz. Verwende einen dauerhaften Transport. Eine In-Memory-Queue verliert alle ausstehenden Benachrichtigungen bei einem Neustart. Ein datenbankgestützter Transport (Doctrine-Transport in Symfony Messenger) übersteht Neustarts auf Kosten leicht höherer Latenz.

Retry-Policy. Konfiguriere eine Retry-Verzögerung mit exponentiellem Backoff und einer maximalen Retry-Anzahl. Drei bis fünf Retries mit Verzögerungen von 1 Minute, 5 Minuten und 30 Minuten decken die meisten vorübergehenden Fehler ab, ohne einen Provider zu bombardieren, der wirklich ausgefallen ist.

Dead Letter Queue. Jobs, die ihre Retries aufgebraucht haben, sollten in einer Dead Letter Queue landen und nicht stillschweigend verworfen werden. Alarmiere beim Wachstum der Dead Letter Queue - es ist ein Frühindikator für einen Ausfall eines Kanal-Providers oder einen Bug in einem Adapter.

Wenn deine Worker nach der Zustellung, aber vor der Bestätigung des Jobs ausfallen können, brauchst du Idempotenz auf der Zustellebene, was uns zum wichtigsten Problem im gesamten System bringt.

Idempotenz: Der schwierige Teil

Eine Job-Queue mit Retries erzeugt eine At-least-once-Zustellgarantie: Der Job läuft mindestens einmal, kann aber mehr als einmal laufen, wenn der Worker im falschen Moment abstürzt. Für die meisten Hintergrund-Jobs ist das in Ordnung - einen Report-Generierungs-Job zweimal auszuführen ist verschwenderisch, aber harmlos. Für Benachrichtigungen ist es ein Problem der Nutzererfahrung. Niemand will zwei Kopien von "Deine Rechnung ist fertig".

Die Lösung besteht darin, die Zustellung idempotent zu machen, indem du verfolgst, welche Benachrichtigungen bereits zugestellt wurden. Bevor ein Kanal-Adapter eine Nachricht sendet, prüft er, ob für diese Benachrichtigungs-ID und diesen Kanal bereits ein Zustelldatensatz existiert. Wenn ja, überspringt der Adapter die Zustellung und gibt Erfolg zurück. Wenn nicht, stellt der Adapter zu und schreibt den Datensatz atomar.

CREATE TABLE notification_deliveries (
    notification_id UUID        NOT NULL,
    channel         VARCHAR(50) NOT NULL,
    delivered_at    TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    PRIMARY KEY (notification_id, channel)
);

Die Benachrichtigungs-ID muss generiert werden, bevor der Job eingereiht wird, nicht innerhalb des Jobs. Generiere sie, wenn das Event zuerst ausgelöst wird, füge sie in den Job-Payload ein und verwende sie als Deduplizierungsschlüssel. Wenn du die ID innerhalb des Jobs generierst, erhält jeder Retry eine neue ID und die Deduplizierungsprüfung greift nie.

Für E-Mail gibt es eine zusätzliche Ebene: Die meisten E-Mail-Provider akzeptieren einen Idempotenzschlüssel oder Message-ID-Header, der verhindert, dass sie dieselbe Nachricht zweimal zustellen, selbst wenn du ihre API mehrfach aufrufst. Postmark verwendet eine MessageID, die du explizit setzen kannst. SendGrid unterstützt einen x-message-id-Tracking-Parameter. Nutze dies in Kombination mit deiner eigenen Zustell-Tracking-Tabelle für doppelt abgesicherte Deduplizierung.

Alles zusammenfügen: Der Dispatcher

Der Dispatcher ist der zentrale Koordinator, der diese Ebenen zusammenbindet:

  1. Das Benachrichtigungs-Event empfangen
  2. Eine stabile Benachrichtigungs-ID generieren (UUID v5, abgeleitet aus Event-Typ + Nutzer-ID + einem Zeitstempel-Bucket, sodass identische Events in einem kurzen Fenster natürlich dedupliziert werden)
  3. Die Kanalpräferenzen des Nutzers für diesen Event-Typ nachschlagen
  4. Kanäle herausfiltern, die deaktiviert oder für diesen Event-Typ zwingend erzwungen sind
  5. Für jeden aufgelösten Kanal einen Zustell-Job mit der Benachrichtigungs-ID, dem Event-Payload und dem Kanal einreihen
  6. Einen In-App-Benachrichtigungsdatensatz direkt schreiben (keine Queue nötig)

Jeder eingereihte Job läuft dann unabhängig, prüft die Zustelltabelle, stellt zu, falls noch nicht zugestellt, und schreibt den Zustelldatensatz.

Dieses Design gibt dir At-least-once-Zustellung mit Deduplizierung auf der Adapterebene, ein sanftes Degradieren, wenn ein einzelner Kanal fehlschlägt, und eine vollständige Prüfspur dessen, was versucht wurde und was erfolgreich war.

Skalierungsüberlegungen

Eine einzelne PostgreSQL-Tabelle für das Zustell-Tracking bewältigt Millionen von Zeilen ohne Probleme, aber füge einen partiellen Index auf aktuelle Benachrichtigungen hinzu, wenn du nach kürzlich zugestelltem Zustand abfragst:

CREATE INDEX idx_notification_deliveries_recent
ON notification_deliveries (delivered_at)
WHERE delivered_at > NOW() - INTERVAL '30 days';

Für Benachrichtigungen mit hohem Volumen - wöchentliche Digests, Marketing-Ankündigungen - erwäge eine separate Digest-Queue, die Events pro Nutzer bündelt und eine Nachricht pro Periode statt einer pro Event zustellt. Das ist ein separates System von der transaktionalen Benachrichtigungs-Pipeline und sollte auch so behandelt werden.

Rate Limiting pro Nutzer und pro Kanal lohnt sich früh. Ein Bug im Code, der Events auslöst und zehntausend "Neue Nachricht"-Benachrichtigungen für denselben Nutzer auslöst, ist ein echter Vorfall. Ein einfacher Zähler in Redis mit einer TTL fängt das ab, bevor die Queue mit Müll volläuft.

Wie es weitergeht

Wenn du ein SaaS-Produkt baust und sich diese Benachrichtigungsarchitektur komplexer anfühlt, als dein aktuelles Team auf einmal verkraften kann, ist das ein Signal, das du ernst nehmen solltest. Die Grundlagen richtig hinzubekommen - saubere Trennung zwischen Event-Ausgabe und Zustellung, idempotente Adapter, ein sinnvolles Präferenzmodell - spart später Wochen der Fehlersuche, wenn die Randfälle in der Produktion auftauchen.

Wolf-Tech hilft SaaS-Teams, genau diese Art von Infrastruktur zu entwerfen und zu bauen. Ob du ein vollständiges System von Grund auf entworfen brauchst oder eine Überprüfung dessen, was du bereits gebaut hast, melde dich unter hello@wolf-tech.io oder besuche wolf-tech.io. Wenn du deine aktuelle Codebasis bewertest, bevor du neue Fähigkeiten hinzufügst, sind unsere Leistungen Code-Quality-Consulting und individuelle Softwareentwicklung ein guter Ausgangspunkt.

Ein Benachrichtigungssystem, das Nutzerpräferenzen respektiert und nie doppelt sendet, ist kein Luxus-Feature - es ist die Art von Infrastruktur, die Produkte, denen Nutzer vertrauen, von Produkten trennt, die sie am Ende abbestellen.