Back to articles
SymfonyDDDApril 3, 2026

Domain events with Symfony Messenger: keeping side effects out of your aggregates

An aggregate that sends an email when an order ships knows about SMTP. That's the wrong kind of knowledge for a class whose only job is to protect a business invariant.

Bad - the aggregate reaches into infrastructure

final class Order
{
    public function ship(Mailer $mailer): void
    {
        $this->status = OrderStatus::SHIPPED;
        $mailer->send(new ShippingConfirmationEmail($this->customerEmail));
    }
}

Order now depends on Mailer, which depends on a transport, which depends on configuration. Testing ship() means mocking an email service to verify a state transition. And every new side effect - a Slack notification, a loyalty-points update - means widening the aggregate's constructor again.

Good - the aggregate records, Messenger dispatches

final class Order
{
    private array $events = [];

    public function ship(): void
    {
        $this->status = OrderStatus::SHIPPED;
        $this->events[] = new OrderWasShipped($this->id);
    }

    public function releaseEvents(): array
    {
        $events = $this->events;
        $this->events = [];
        return $events;
    }
}

final class ShipOrderHandler
{
    public function __construct(
        private readonly OrderRepository $orders,
        private readonly MessageBusInterface $eventBus,
    ) {}

    public function __invoke(ShipOrder $command): void
    {
        $order = $this->orders->getById($command->orderId);
        $order->ship();
        $this->orders->save($order);

        foreach ($order->releaseEvents() as $event) {
            $this->eventBus->dispatch($event);
        }
    }
}

OrderWasShipped is a plain object with no framework dependency. Symfony Messenger dispatches it to however many handlers care - a mailer, a Slack notifier, a read-model projector - without Order knowing any of them exist. Testing ship() is now an assertion on releaseEvents(), not a mock.

The tradeoff: an event class and a handler per side effect, where a direct method call used to do the job in one line. Worth it once an aggregate has more than one reason to notify the outside world, or once you need those side effects to survive a retry without re-running the state change itself.