Event Driven Microservices mit Symfony Messenger
AI generated
SF
{ }
Symfony · Messenger · Microservices · Sagas
Event Driven Microservices mit Symfony Messenger
Choreografie statt synchroner Kopplung zwischen Services

Event Driven Microservices ersetzen synchrone REST Aufrufe zwischen Services durch asynchrone Integration Events, die ueber Symfony Messenger auf einen gemeinsamen Broker verteilt werden. Dieser Artikel zeigt, wie das Transactional Outbox Pattern das Dual Write Problem loest, wie Consumer idempotent bleiben, wie Event Schemas sich versionieren lassen und wann ein Saga Orchestrator fuer verteilte Geschaeftsprozesse noetig wird.

21 Min. Lesezeit Outbox Pattern · Idempotenz · Saga · Choreografie Symfony 7.x · PHP 8.3+

1. Warum synchrone Aufrufe zwischen Microservices an Grenzen stossen

Event Driven Microservices loesen ein Problem, das fast jede Symfony Microservice Landschaft irgendwann trifft: Ein Order Service ruft synchron per HTTP den Inventory Service auf, der wiederum den Pricing Service ruft, der wiederum eine externe Steuer API abfragt. Faellt ein einzelner Service in dieser Kette aus, faellt die gesamte Anfrage aus, obwohl der urspruengliche Order Service selbst fehlerfrei arbeitet. Diese Art der Kopplung ueber synchrone Aufrufketten heisst kaskadierendes Fehlverhalten, und sie ist einer der haeufigsten Gruende, warum Microservice Architekturen in der Praxis weniger robust sind als versprochen.

Der Wechsel zu Event Driven Microservices kehrt die Abhaengigkeitsrichtung um. Statt dass ein Service aktiv einen anderen aufruft und auf dessen Antwort wartet, veroeffentlicht er ein Ereignis ueber Symfony Messenger auf einem gemeinsamen Broker, und interessierte Services konsumieren dieses Ereignis unabhaengig voneinander, in ihrem eigenen Tempo. Der Order Service muss nicht wissen, welche und wie viele andere Services auf ein OrderPlacedEvent reagieren. Diese Entkopplung ist der zentrale Vorteil von Event Driven Microservices, bringt aber neue Herausforderungen mit, die dieser Artikel Schritt fuer Schritt behandelt.

2. Domain Events und Integration Events sauber trennen

Ein haeufiger Fehler beim Aufbau von Event Driven Microservices ist, ein internes Domain Event eines Aggregats direkt und unveraendert auf den Broker zu veroeffentlichen. Das koppelt die interne Modellierung eines Service an alle konsumierenden Services, denn jede Umbenennung eines Feldes im Aggregat wuerde sofort andere Teams treffen. Die robustere Loesung trennt zwischen einem internen Domain Event, das nur innerhalb des eigenen Symfony Kernels lebt, und einem daraus abgeleiteten Integration Event, das explizit fuer den externen Konsum entworfen wird und eine eigene, stabile Struktur hat.

Diese Trennung erlaubt es einem Team, das interne Domain Modell frei weiterzuentwickeln, solange die Uebersetzung in das Integration Event stabil bleibt. In Event Driven Microservices mit Symfony ist dafuer ein dedizierter Event Mapper zustaendig, der auf das interne Doctrine Lifecycle Event oder ein eigenes Domain Event hoert und daraus das oeffentliche Integration Event mit expliziter Versionsnummer baut, bevor es ueberhaupt an Messenger uebergeben wird.


<?php

declare(strict_types=1);

namespace App\Order\Application\EventMapping;

use App\Order\Domain\Event\OrderWasPlaced;
use App\Order\Integration\Event\OrderPlacedIntegrationEvent;
use Symfony\Component\Messenger\MessageBusInterface;

// Translates an internal domain event into a stable, versioned
// integration event before it reaches Messenger's async transport.
final readonly class OrderIntegrationEventMapper
{
    public function __construct(
        private MessageBusInterface $eventBus,
    ) {}

    public function onOrderWasPlaced(OrderWasPlaced $domainEvent): void
    {
        $integrationEvent = new OrderPlacedIntegrationEvent(
            eventVersion: '1.1',
            orderId: $domainEvent->orderId->value,
            customerId: $domainEvent->customerId->value,
            totalAmountInCents: $domainEvent->totalAmount->cents,
            currency: $domainEvent->totalAmount->currency,
            occurredAt: $domainEvent->occurredAt,
        );

        $this->eventBus->dispatch($integrationEvent);
    }
}

3. Symfony Messenger als Integrationsschicht zwischen Services

Damit Event Driven Microservices tatsaechlich ueber Service Grenzen hinweg kommunizieren, muss der Messenger Transport auf einen Broker zeigen, den mehrere unabhaengige Symfony Anwendungen gemeinsam nutzen koennen, typischerweise RabbitMQ mit einem Topic Exchange oder Kafka. Jeder produzierende Service konfiguriert einen Sender fuer seine Integration Events, jeder konsumierende Service abonniert die fuer ihn relevanten Routing Keys ueber eine eigene, unabhaengige Queue. Diese Queue pro Consumer ist entscheidend: Faellt ein konsumierender Service fuer eine Stunde aus, sammeln sich seine Nachrichten in seiner eigenen Queue, ohne andere Consumer zu beeintraechtigen.

Die Konfiguration in messenger.yaml unterscheidet sich dabei kaum von einer Single Service Konfiguration, der entscheidende Unterschied liegt im Deployment: Jeder Service ist ein eigener Codebase, ein eigenes Deployment, aber alle teilen sich denselben Exchange Namen als Vertrag. Aendert ein Team den Exchange Namen ohne Abstimmung, brechen alle konsumierenden Services gleichzeitig, deshalb gehoert der Exchange Name in Event Driven Microservices in dieselbe Governance wie das Event Schema selbst.


# config/packages/messenger.yaml
framework:
  messenger:
    transports:
      order_events_publish:
        dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
        options:
          exchange:
            name: 'order.events'
            type: 'topic'
          delivery_persistent: true

      order_events_consume:
        dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
        options:
          exchange:
            name: 'order.events'
            type: 'topic'
          queues:
            inventory_service.order_events:
              binding_keys: ['order.placed', 'order.cancelled']

    routing:
      'App\Order\Integration\Event\OrderPlacedIntegrationEvent': order_events_publish

4. Transactional Outbox Pattern gegen das Dual Write Problem

Sobald ein Service in derselben Transaktion sowohl eine Datenbankaenderung speichert als auch ein Integration Event auf den Broker schreiben will, entsteht das sogenannte Dual Write Problem: Datenbank Commit und Broker Publish sind zwei getrennte Systeme, die nicht in einer einzigen atomaren Transaktion verbunden werden koennen. Schlaegt der Broker Publish nach einem erfolgreichen Datenbank Commit fehl, ist das Ereignis fuer immer verloren, obwohl die Aenderung bereits gespeichert wurde. Genau dieses Risiko macht Event Driven Microservices ohne Absicherung fragiler als synchrone Aufrufe.

Das Transactional Outbox Pattern loest das, indem das Integration Event zunaechst in einer eigenen Outbox Tabelle in derselben Datenbanktransaktion wie die fachliche Aenderung gespeichert wird. Ein separater Worker Prozess liest periodisch unverarbeitete Zeilen aus der Outbox Tabelle, veroeffentlicht sie ueber Messenger auf den Broker und markiert sie danach als verarbeitet. Weil beide Schreibvorgaenge, die fachliche Aenderung und der Outbox Eintrag, in derselben Transaktion liegen, gibt es keinen Zustand mehr, in dem die Datenbank Aenderung existiert, aber das Ereignis fehlt.


<?php

declare(strict_types=1);

namespace App\Order\Infrastructure\Outbox;

use Doctrine\DBAL\Connection;

// Writes the domain change and the outbox row inside one DB transaction,
// eliminating the dual write problem between database and broker.
final readonly class OutboxAwareOrderRepository
{
    public function __construct(
        private Connection $connection,
    ) {}

    public function placeOrderAndRecordEvent(string $orderId, string $payloadJson): void
    {
        $this->connection->transactional(function () use ($orderId, $payloadJson): void {
            $this->connection->executeStatement(
                'UPDATE orders SET status = :status WHERE id = :id',
                ['status' => 'placed', 'id' => $orderId]
            );

            $this->connection->executeStatement(
                'INSERT INTO outbox (id, event_type, payload, created_at, processed_at)
                 VALUES (:id, :type, :payload, NOW(), NULL)',
                ['id' => bin2hex(random_bytes(16)), 'type' => 'order.placed', 'payload' => $payloadJson]
            );
        });
    }
}

5. Choreografie: Services reagieren unabhaengig auf Events

Bei Event Driven Microservices mit Choreografie kennt kein Service den vollstaendigen Ablauf eines Geschaeftsprozesses. Der Order Service veroeffentlicht OrderPlacedIntegrationEvent, ohne zu wissen, dass der Inventory Service daraufhin Bestand reserviert, der Notification Service eine Bestaetigungsmail versendet und der Analytics Service einen Report aktualisiert. Jeder Consumer entscheidet selbststaendig, wie er auf das Ereignis reagiert. Dieses Muster passt gut zu einfachen, weitgehend unabhaengigen Reaktionen, bei denen kein Service auf das Ergebnis eines anderen warten muss.

Die Kehrseite der Choreografie zeigt sich, sobald ein Geschaeftsprozess mehrere aufeinander folgende Schritte mit Abhaengigkeiten hat, etwa erst Zahlung pruefen, dann Bestand reservieren, dann Versand anstossen. Bei reiner Choreografie verteilt sich diese Ablauflogik implizit ueber viele Event Handler in verschiedenen Services, was den Gesamtprozess schwer nachvollziehbar macht. Fuer solche mehrstufigen Prozesse ist der Saga Ansatz aus Abschnitt acht die robustere Wahl, waehrend Choreografie fuer einfache, parallele Reaktionen in Event Driven Microservices weiterhin die richtige Loesung bleibt.

6. Idempotente Consumer fuer doppelt zugestellte Events

Message Broker garantieren in der Praxis meist At Least Once Delivery, kein Exactly Once. Ein Netzwerkfehler zwischen Consumer und Broker, ein Timeout beim Acknowledgement oder ein Neustart eines Workers kann dazu fuehren, dass dasselbe Integration Event zweimal beim selben Consumer ankommt. Ein Consumer in Event Driven Microservices, der diese Wiederholung nicht abfaengt, verarbeitet dieselbe Bestellung womoeglich doppelt und bucht Bestand zweimal ab oder versendet dieselbe E-Mail zweimal.

Die Loesung ist ein idempotenter Consumer, der jedes verarbeitete Event ueber eine eindeutige Event ID in einer eigenen Tabelle protokolliert, bevor die eigentliche fachliche Verarbeitung stattfindet. Trifft dieselbe Event ID ein zweites Mal ein, erkennt der Consumer das an der bereits vorhandenen Zeile und ueberspringt die Verarbeitung, bestaetigt die Nachricht aber trotzdem als erfolgreich verarbeitet gegenueber dem Broker. Dieser Schutz ist in Event Driven Microservices keine Option, sondern eine Grundvoraussetzung fuer jeden Consumer, der Seiteneffekte wie Zahlungen, Lagerbuchungen oder Benachrichtigungen ausloest.


<?php

declare(strict_types=1);

namespace App\Inventory\Application\Handler;

use App\Order\Integration\Event\OrderPlacedIntegrationEvent;
use App\Inventory\Infrastructure\ProcessedEventLog;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;

// Idempotent consumer — skips reprocessing when the same event
// arrives twice due to at-least-once delivery guarantees.
#[AsMessageHandler]
final readonly class ReserveInventoryOnOrderPlaced
{
    public function __construct(
        private ProcessedEventLog $processedEvents,
    ) {}

    public function __invoke(OrderPlacedIntegrationEvent $event): void
    {
        $eventId = $event->orderId . ':' . $event->eventVersion;

        if ($this->processedEvents->wasAlreadyProcessed($eventId)) {
            return; // already handled, acknowledge without side effects
        }

        // ... reserve stock for the order here ...

        $this->processedEvents->markAsProcessed($eventId);
    }
}

7. Event Schema Versionierung ohne Konsumenten zu brechen

In einer wachsenden Landschaft von Event Driven Microservices aendert sich frueher oder spaeter die Struktur eines Integration Events, etwa weil ein neues Pflichtfeld hinzukommt oder ein bestehendes Feld umbenannt werden soll. Weil produzierende und konsumierende Services unabhaengig deployt werden, kann nicht vorausgesetzt werden, dass alle Consumer zum selben Zeitpunkt die neue Version verstehen. Additive Aenderungen wie ein neues optionales Feld sind unkritisch, solange Consumer unbekannte Felder ignorieren. Entfernte oder umbenannte Felder sind dagegen Breaking Changes und brechen jeden Consumer, der sich auf das alte Feld verlaesst.

Ein bewaehrtes Muster fuer Event Driven Microservices ist, die Event Version explizit im Payload mitzufuehren und bei Breaking Changes zunaechst beide Versionen parallel zu veroeffentlichen, bis alle bekannten Consumer auf die neue Version migriert sind. Ein Upcaster im Consumer kann aeltere Event Versionen automatisch in die aktuelle interne Struktur transformieren, sodass die eigentliche Handler Logik nur eine einzige, aktuelle Struktur kennen muss, unabhaengig davon, in welcher Version das Event tatsaechlich auf dem Broker ankam.

8. Sagas: verteilte Geschaeftsprozesse ueber mehrere Services

Fuer mehrstufige Geschaeftsprozesse, die mehrere Services in einer bestimmten Reihenfolge involvieren, bietet sich in Event Driven Microservices ein Saga Orchestrator an, umsetzbar mit der Symfony Workflow Komponente kombiniert mit Messenger. Der Orchestrator haelt den Zustand eines einzelnen Bestellprozesses, veroeffentlicht Kommandos an die jeweils naechsten Services und reagiert auf deren Antwort Events, um den naechsten Schritt einzuleiten. Anders als bei Choreografie ist der gesamte Ablauf an einer Stelle im Code sichtbar und nachvollziehbar, statt implizit ueber viele verteilte Handler.

Der entscheidende Unterschied zu einer klassischen Datenbank Transaktion: Eine Saga in Event Driven Microservices kann nicht einfach zurueckrollen, wenn ein spaeter Schritt fehlschlaegt. Stattdessen definiert jeder beteiligte Service eine kompensierende Aktion, etwa eine Bestandsreservierung wieder freigeben, falls die anschliessende Zahlung fehlschlaegt. Der Saga Orchestrator ruft bei einem Fehler die kompensierenden Aktionen der bereits erfolgreich durchlaufenen Schritte in umgekehrter Reihenfolge auf, um einen konsistenten Endzustand ueber alle beteiligten Services hinweg herzustellen.


<?php

declare(strict_types=1);

namespace App\OrderSaga\Application;

use App\OrderSaga\Integration\Command\ReserveInventoryCommand;
use App\OrderSaga\Integration\Command\ReleaseInventoryCommand;
use App\OrderSaga\Integration\Event\InventoryReservationFailed;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\MessageBusInterface;

// Saga orchestrator step — reacts to a failed step and issues
// compensating commands to already completed steps of the process.
#[AsMessageHandler]
final readonly class CompensateOnInventoryFailure
{
    public function __construct(
        private MessageBusInterface $commandBus,
        private SagaStateRepository $sagaState,
    ) {}

    public function __invoke(InventoryReservationFailed $event): void
    {
        $saga = $this->sagaState->findByOrderId($event->orderId);

        if ($saga->hasCompletedStep('payment_reserved')) {
            $this->commandBus->dispatch(new ReleaseInventoryCommand($event->orderId));
        }

        $saga->markAsFailed('inventory_reservation');
        $this->sagaState->save($saga);
    }
}

9. Event Driven Microservices im Vergleich zu synchronen Aufrufen

Weder Event Driven Microservices noch synchrone REST Aufrufe sind die universelle Loesung. Die folgende Tabelle stellt beide Ansaetze fuer typische Entscheidungssituationen gegenueber.

Kriterium Synchroner REST Aufruf Event Driven Microservices
Kopplung Hoch, Aufrufer kennt Ziel Service direkt Niedrig, Producer kennt Consumer nicht
Verhalten bei Ausfall Kaskadierender Fehler entlang der Kette Nachrichten warten in der Queue
Antwortzeit Sofortiges Ergebnis fuer den Aufrufer Eventual Consistency, keine sofortige Antwort
Nachvollziehbarkeit des Ablaufs Direkt im Code sichtbar Braucht Tracing oder Saga Orchestrator
Passt am besten fuer Anfragen mit sofort benoetigter Antwort Entkoppelte Reaktionen und Benachrichtigungen

In der Praxis kombinieren die meisten Symfony Microservice Landschaften beide Ansaetze: synchrone Aufrufe fuer Anfragen, die sofort eine Antwort brauchen, etwa eine Preisberechnung waehrend des Checkouts, und Event Driven Microservices fuer alles, was danach folgt und keine sofortige Antwort erfordert, etwa Bestandsreservierung, Benachrichtigungen und Reporting.

Mironsoft

Symfony Microservices, Messenger Integrationen und verteilte Prozesse

Services kommunizieren noch synchron und fragil?

Wir bauen eine Symfony Messenger Integrationsschicht mit Outbox Pattern, idempotenten Consumern und Saga Orchestrierung auf, damit eure Services entkoppelt bleiben und einzelne Ausfaelle nicht kaskadieren.

Outbox Einfuehrung

Transactional Outbox gegen Dual Write Probleme implementieren

Idempotenz Audit

Bestehende Consumer auf doppelte Zustellung absichern

Saga Design

Verteilte Geschaeftsprozesse mit kompensierenden Aktionen modellieren

10. Zusammenfassung

Event Driven Microservices mit Symfony Messenger loesen die kaskadierenden Ausfaelle synchroner Aufrufketten, indem Services ueber Integration Events statt direkter Aufrufe kommunizieren. Die saubere Trennung von internen Domain Events und oeffentlichen Integration Events, das Transactional Outbox Pattern gegen das Dual Write Problem, idempotente Consumer gegen doppelte Zustellung und eine explizite Event Versionierung bilden zusammen das technische Fundament, ohne das eine solche Architektur in Produktion unzuverlaessig wird.

Fuer einfache, unabhaengige Reaktionen bleibt Choreografie das passende Muster in Event Driven Microservices. Sobald ein Geschaeftsprozess mehrere Schritte mit klaren Abhaengigkeiten und Kompensationslogik braucht, ist ein Saga Orchestrator die robustere Wahl, weil er den Ablauf an einer Stelle sichtbar haelt statt implizit ueber viele verteilte Handler. Wer diese Bausteine konsequent kombiniert, bekommt eine Microservice Landschaft, in der einzelne Ausfaelle lokal bleiben, statt sich durch die gesamte Aufrufkette fortzupflanzen.

Event Driven Microservices mit Symfony Messenger — Das Wichtigste auf einen Blick

Event Trennung

Interne Domain Events bleiben im Service, oeffentliche Integration Events sind stabil und versioniert.

Outbox Pattern

Fachliche Aenderung und Outbox Eintrag in einer Transaktion, ein Worker publiziert danach zuverlaessig.

Idempotenz

Jeder Consumer protokolliert verarbeitete Event IDs gegen doppelte Zustellung durch den Broker.

Choreografie vs. Saga

Einfache Reaktionen per Choreografie, mehrstufige Prozesse mit Kompensation per Saga Orchestrator.

11. FAQ: Event Driven Microservices mit Symfony Messenger

1Unterschied zu synchronen REST Aufrufen?
Synchrone Aufrufe kennen das Ziel direkt und warten. Event Driven Microservices veroeffentlichen entkoppelt, ohne Consumer zu kennen.
2Warum nicht Domain Event direkt publizieren?
Das koppelt Consumer an das interne Modell. Ein separates Integration Event bleibt stabil, auch wenn sich das Domain Modell aendert.
3Was ist das Dual Write Problem?
DB Commit und Broker Publish laufen ohne gemeinsame Transaktion, ein fehlgeschlagener Publish laesst das Ereignis verschwinden.
4Wie loest Outbox das Dual Write Problem?
Aenderung und Outbox Eintrag in einer Transaktion, ein Worker publiziert danach zuverlaessig aus der Outbox Tabelle.
5Warum muessen Consumer idempotent sein?
At Least Once Delivery kann Events doppelt zustellen. Ohne Idempotenz entstehen doppelte Seiteneffekte wie doppelte Buchungen.
6Wie versioniert man Events sicher?
Additive Felder sind unkritisch. Bei Breaking Changes beide Versionen parallel fahren und per Upcaster in Consumern uebersetzen.
7Choreografie oder Saga Orchestrator?
Choreografie fuer einfache unabhaengige Reaktionen, Saga Orchestrator fuer mehrstufige Prozesse mit Kompensationslogik.
8Was ist eine kompensierende Aktion?
Sie macht einen bereits erfolgreichen Schritt rueckgaengig, wenn ein spaeterer Schritt der Saga fehlschlaegt.
9Welcher Broker eignet sich am besten?
Meist RabbitMQ mit Topic Exchange, alternativ Kafka. Der Exchange Name ist ein gemeinsamer Vertrag zwischen allen Services.
10Muss alles asynchron werden?
Nein, sofort benoetigte Antworten bleiben synchron, Event Driven Microservices ergaenzen das fuer entkoppelte Folgereaktionen.