Im heutigen digitalen Zeitalter bleibt E-Mail-Kommunikation ein unverzichtbares Werkzeug für Unternehmen und Privatpersonen gleichermaßen. Ob wichtige Benachrichtigungen, Marketingkampagnen oder transaktionale Updates – E-Mails spielen eine entscheidende Rolle bei der Sicherstellung einer effektiven Kommunikation.

Auf den ersten Blick ist das Versenden einer E-Mail nur eine Zeile Code, oder? Nun, die Integration jeglicher asynchroner Messaging-Funktionalität (z. B. das Versenden von E-Mails, das Senden von Daten an Drittanbieter-Dienste, Abrechnungssysteme usw.) in Softwareanwendungen kann eine entmutigende Aufgabe sein, insbesondere wenn es darum geht, die Zuverlässigkeit und Konsistenz der Nachrichtenübermittlung zu gewährleisten. Meistens benötigen Anwendungen eine Möglichkeit, eine Nachricht genau dann zu senden, wenn die Datenbank um einen Eintrag ergänzt wird. Ein einfaches Beispiel, bei dem dieses Verhalten notwendig ist, wäre die Benutzerregistrierung. Wenn sich ein neuer Benutzer registriert, muss die Anwendung eine Bestätigungs-E-Mail an seine Adresse senden. Klingt einfach genug – was könnte also schiefgehen?

Versuchen wir, etwas Spring-Boot-Code mit Kotlin zu schreiben, um das Problem zu veranschaulichen:

@Transactional
fun registerUser(user: User){
    userRepo.save(user)
    emailService.send(ConfirmationEmail())
}

Auf den ersten Blick mag dieser Code richtig erscheinen. Aber was passiert, wenn der Server nach dem Speichern des neuen Benutzers auf einen Fehler stößt? Die E-Mail wird gesendet, aber der Benutzer wird nicht in der Datenbank vorhanden sein. Nun könnte man daran denken, diesen Code in einen Try-Catch-Block zu verpacken, sodass die send-Funktion nicht ausgeführt wird, wenn die save-Operation fehlschlägt. Das würde etwa so aussehen:

@Transactional
fun registerUser(user: User){
    try {
        userRepo.save(user)
        emailService.send(ConfirmationEmail())
    }
    catch(err: UserRepoException) {
        // ...    
    }
}

Leider ist das auch kein guter Ansatz. Was ist, wenn der E-Mail-Anbieter zum aktuellen Zeitpunkt nicht verfügbar ist? Der Benutzer wird in der Datenbank persistiert, aber die Bestätigungs-E-Mail wird ihn nie erreichen. Sie wird gar nicht erst gesendet. Das erscheint in einer professionellen, modernen Webanwendung inakzeptabel. Wir möchten irgendwie das Speichern des Benutzers rückgängig machen können, wenn das Senden der E-Mail fehlschlägt. Das erinnert euch vielleicht an das Kapitel über Transaktionen aus der Datenbankvorlesung.

Das Transactional Outbox Pattern zur Rettung

Das Transactional Outbox Pattern, ein bewährtes Architekturdesign, bietet eine robuste und zuverlässige Lösung für das Management des E-Mail-Versands innerhalb von Anwendungen. Es deckt verschiedene Anwendungsfälle ab, in denen die Nachrichtenübermittlung entscheidend ist, und hilft, mögliche Ausfälle abzufedern, die ohne dieses Pattern auftreten können.

Die grundlegende Idee besteht darin, eine Outbox-Tabelle zu haben, die die E-Mails enthält, die unsere Anwendung versenden muss. Wir können nun in derselben Transaktion in diese neue Tabelle und die User-Tabelle einfügen, wenn ein neuer Benutzer erstellt wird.

Diagram of a user registration system with services, email sending process, and PostgreSQL tables for users and outbox. The data flow between components and tables is illustrated with arrows, demonstrating how the Transactional Outbox Pattern ensures reliable communication during registration.

Schlüsselkomponenten des Transactional Outbox Patterns:

Outbox: Die zentrale Komponente dieses Patterns ist die „Outbox”, die als temporärer Speicher für Nachrichten fungiert, die gesendet werden müssen. Wenn eine Nachricht gesendet werden muss (z. B. eine E-Mail), wird sie, anstatt direkt gesendet zu werden, zunächst in die Outbox eingefügt – in derselben Transaktion wie die Aktualisierung der anderen Tabelle (z. B. Insert in die User-Tabelle). Diese Outbox kann als Datenbanktabelle, Message Queue oder anderer persistenter Speicher implementiert werden. In unserem Fall ist es eine Datenbanktabelle.

Message Queue oder Scheduler: Ein wesentlicher Teil des Patterns ist ein Mechanismus, der die Outbox auf Nachrichten überwacht und sie zum geeigneten Zeitpunkt versendet. Das kann mit einer Message Queue (z. B. RabbitMQ, Kafka) oder einem Scheduler erfolgen, der regelmäßig die Outbox auf ausstehende Nachrichten prüft. Wenn eine Nachricht erfolgreich gesendet wurde, wird sie in der Outbox als „gesendet” markiert.

Transaktionales Verhalten: Das Transactional Outbox Pattern stellt sicher, dass das Senden von Nachrichten Teil einer größeren Transaktion als atomare Datenbankoperation ist. Wenn die Transaktion fehlschlägt (z. B. aufgrund eines Fehlers oder einer Ausnahme), werden die Nachrichten nicht in die Outbox eingefügt und die Daten werden in der anderen Tabelle nicht aktualisiert (UsersTable in unserem Beispiel). Das garantiert, dass Nachrichten nur dann gesendet werden, wenn die gesamte Transaktion erfolgreich ist, was die Datenkonsistenz aufrechterhält. Dieses Verhalten kann in Spring JPA mit der @Transactional-Annotation einfach erreicht werden, da sie die „ACID”-Anforderungen erfüllt: sie ist Atomar, Konsistent, Isoliert und Dauerhaft.

Sequenzdiagramm

Wir können den Ablauf der Registrierung eines neuen Benutzers in einer Anwendung veranschaulichen, die das Transactional Outbox Pattern implementiert. Das würde etwa so aussehen:

Sequence diagram showing user registration, email confirmation, and database interactions between EndUser, ClientApplication, API, Database, and email services. The diagram illustrates reliable communication by incorporating the Transactional Outbox Pattern to ensure that steps for account creation and confirmation email sending are consistently coordinated across all components. Arrows indicate each stage of the process for seamless integration and dependable delivery.

Implementierung

Jetzt, da wir das Problem verstehen und eine Lösung haben, können wir eine EmailService-Klasse schreiben, um zuverlässigen E-Mail-Versand zu erreichen:

@Service
class TransactionalEmailService(
    val outboxRepository: OutboxRepository,
    val emailOutboxMapper: EmailOutboxMapper
) {
    @Transactional
    override fun sendEmail(email: Email) {
        this.outboxRepository.save(this.emailOutboxMapper.emailToOutbox(email))
    }
}

Der Service hat nur eine Funktion: sendEmail(). Die Funktion speichert das Email-Objekt als Datenbankeintrag in der Outbox-Tabelle. Sie ist mit @Transactional annotiert, was Spring ermöglicht, die Datenbank-Speicheroperation als Teil einer Transaktion zu behandeln. Bitte beachtet, dass die Standard-Propagation für @Transactional required ist, da wir möchten, dass der Code in der sendEmail()-Funktion in derselben Transaktion läuft wie der Code in der aufrufenden Funktion (sodass sowohl die ursprüngliche Datenbankoperation als auch das Speichern im Outbox-Repository in derselben Transaktion stattfinden). Die Verwendung einer anderen Propagation könnte dazu führen, dass eine zweite Transaktion für sendEmail() erstellt wird, was den Zweck verfehlen würde. Weitere Informationen findet ihr in der offiziellen Dokumentation.

Geplante Aufgabe (E-Mail-Relay)

Um die Outbox-Tabelle nach neuen Einträgen zu überwachen, haben wir uns für eine geplante Aufgabe entschieden. Diese Aufgabe ruft Batches (Seiten) von Einträgen aus der Outbox ab, bis keine weiteren nicht abgerufenen Einträge vorhanden sind. Es ist wichtig, dies so zu implementieren und nicht nur einen Batch pro Ausführung abzurufen, denn wenn zu einem beliebigen Zeitpunkt viele Nachrichten in die Outbox eingehen (wenn z. B. ein Newsletter gesendet werden muss), wird die geplante Aufgabe nicht aufhören, bis sie versucht hat, jede Nachricht zu senden, und spart so die Zeit, die sie zwischen den geplanten Läufen hätte warten müssen.

Erwähnenswert ist auch, dass wir in der Outbox-Tabelle neben den üblichen E-Mail-Feldern auch folgendes speichern:

  • Das geplante Datum zum Senden der E-Mail, das zwei Zwecken dient: E-Mails zu einem bestimmten Zeitpunkt in der Zukunft zu senden; und wenn das Senden fehlschlägt, zu markieren, zu welchem Zeitpunkt ein erneuter Sendeversuch erfolgen soll
  • Anzahl der Versuche: Wenn eine E-Mail zu oft fehlschlägt, unternimmt der Service keine weiteren Sendeversuche.

Um unsere Selects zu optimieren, haben wir einen zusammengesetzten Index auf der Outbox-Tabelle für die Spalten ID, Anzahl der Versuche und Geplantes Datum erstellt.

Ein Fallstrick, auf den wir achten müssen, ist, dass möglicherweise mehrere Instanzen der geplanten Aufgabe gleichzeitig laufen. Um sicherzustellen, dass die zwei Aufgaben nicht denselben Batch von E-Mails auswählen (was dazu führen würde, dass dieselben E-Mails mehrfach gesendet werden), können wir ein verteiltes Lock wie ShedLock verwenden. Sein Zweck ist sicherzustellen, dass zu einem bestimmten Zeitpunkt nur eine Instanz der geplanten Aufgabe läuft. Eine andere Möglichkeit, die stärker vom jeweiligen Datenbankmanagementsystem abhängt, sind die Zeilensperren FOR UPDATE SKIP LOCKED. Dieser Befehl steht in PostgreSQL, Oracle und weiteren Systemen zur Verfügung, fehlt aber zum Beispiel in SQL Server und SQLite.

Ein Pseudocode unserer Implementierung würde etwa so aussehen:

Verteiltes Lock erwerben

Wiederholen bis keine Batches mehr vorhanden {
batch = select relevant entries from Outbox table

for each email in batch {
Versuchen {
send(email)
// send – successful
E-Mail aus Outbox-Tabelle löschen
}
Abfangen {
// Senden – fehlgeschlagen
E-Mail -> Anzahl der Versuche erhöhen
E-Mail -> Geplantes Sendedatum auf irgendwann in der Zukunft setzen // (Zeitpunkt des erneuten Versuchs)
E-Mail in Outbox-Tabelle aktualisieren
}
}
}

Verteiltes Lock freigeben

E-Mail-Sender

Es gibt mehrere Ansätze zur Handhabung dieses Schritts, und er ist sehr projektspezifisch. Ihr könnt einen SMTP-Server oder etwas wie AWS SES verwenden. An diesem Punkt kann nichts schiefgehen, solange ihr darauf achtet, alle Ausnahmen in der geplanten Aufgabe abzufangen, damit sie ordnungsgemäß behandelt werden.

Wir haben eine benutzerdefinierte AWS-SES-Sender-Klasse erstellt, die JavaMailSender implementiert. Auf diese Weise können wir einfach zwischen der Standard-JavaMailSenderImpl-SMTP-Implementierung und der AWS-SES-Implementierung wechseln, indem wir nur eine Konfiguration ändern. Das ist Spring-Boot-spezifisch, sollte aber in anderen Sprachen und Frameworks ziemlich ähnlich sein. Unsere Implementierung sieht etwa so aus:

@ConditionalOnProperty(
    value = ["email.sender"],
    havingValue = "AWS",
    matchIfMissing = false
)
@Component
class AWSSESJavaMailSender(
    @Autowired val sesClient: AmazonSimpleEmailService
): JavaMailSender {
    override fun send(mimeMessage: MimeMessage) {
        try {
            val outputStream = ByteArrayOutputStream()
            mimeMessage.writeTo(outputStream)
            val buf = ByteBuffer.wrap(outputStream.toByteArray())
            val rawMessage = RawMessage(buf)
            val rawEmailRequest = SendRawEmailRequest(rawMessage)
            sesClient.sendRawEmail(rawEmailRequest)
        }
        catch (ex: Exception) {
            throw MailSendException("Could not send email through AWS SES", ex)
        }
    }
    override fun createMimeMessage(): MimeMessage {
        return MimeMessage(Session.getDefaultInstance(Properties()))
    }
    // other functions' from  JavaMailSender implementation...
}

Verwendung

Der Zweck dieses Projekts war es, das Pattern auf eine einfach wiederverwendbare Weise für unsere Spring-Boot-Projekte zu implementieren. Wir haben eine Möglichkeit bereitgestellt, das Pattern einfach zu verwenden und dabei den Code sauber zu halten.

Die sendEmail()-Funktion des TransactionalEmailService kann in jeder anderen transaktionalen Funktion aufgerufen werden:

@Service
class ExampleEmailServiceImpl(
    val userRepo: UserRepo,
    val emailService: TransactionalEmailService
){
    @Transactional
    fun exampleUsage() {
        this.userRepo.save(User(...))
        val email = Email(...)
        this.emailService.sendEmail(email)
  }
}

Beachtet, dass sowohl die exampleUsage()-Funktion als auch unsere sendEmail()-Funktion mit @Transactional annotiert sind. Spring ist clever genug, beide Datenbankänderungen in einer einzigen Transaktion zu behandeln und damit die ACID-Anforderungen zu erfüllen.

Für einen entkoppelteren Ansatz können wir Event-Listener nutzen:

@Service
class ExampleUserService(
    val userRepo: UserRepo,
    val eventPublisher: ApplicationEventPublisher
){
    @Transactional
    fun exampleUsage() {
        val user = userRepo.save(User(...))
        eventPublisher.publish(UserCreatedEvent(user))
    }
}
@Service
class UserEventListener(
    val emailService: TransactionalEmailService
) {
    @Transactional
    @EventListener
    override fun handleUserCreatedEvent(event: UserCreatedEvent) {
        val email = Email(...)
        emailService.sendEmail(email);
    }
}

Fazit

Falls ihr gerade an dieses Projekt denkt, an dem ihr in der Vergangenheit gearbeitet habt, und ihr die Informationsübermittlung nicht wirklich zuverlässig implementiert habt – ihr seid nicht allein. Das ist leider ein sehr häufiger Fehler, aber wir hoffen, dass wir durch diesen Artikel ein klareres Bild von diesem Entwurfsmuster, warum es nützlich ist und wie es implementiert wird, zeichnen konnten.