The problem

Take an online shop split into services. The order service records an order and publishes the order.order.placed.v1 event. Two services must react to it: stock reserves the goods, shipping prepares the shipment.

Each of them must receive its own copy of the event. The RabbitMQ topology that carries this flow often degrades in one of three ways.

One queue shared by several services. stock and shipping consume the same order-placed queue. RabbitMQ then splits the messages between the connected consumers. Each message is processed by a single service: stock reserves for only part of the orders, shipping prepares only the others.

One queue per use case. To fix it, teams create stock.reserve-on-order-placed, then shipping.prepare-on-order-placed, then one more queue for every new need. Each queue name describes a business reaction. The topology grows with every feature, and the publisher ends up coupled to the uses of its events.

One exchange per domain. order, stock and shipping each publish to their own exchange. A service that listens to several domains piles up cross bindings. Without opening the management UI, nobody can tell which message ends up in which queue.

The options

One shared queue per event

  • What it solves: nothing for broadcasting. It fits when several instances of the same service share the work.
  • What it costs: each message reaches a single consumer. Plugging a new service into the queue makes it "steal" part of the others' messages.

One queue per use case

  • What it solves: each reaction gets its own copy of the message.
  • What it costs: a queue, a binding and often a retry policy per feature. The number of queues follows the number of features, not the number of services.

One exchange per domain

  • What it solves: each domain publishes in a space of its own.
  • What it costs: a consumer binds to several exchanges, and the bindings scatter. The routing key repeats the domain anyway: the exchange adds no information.

A single topic exchange, one queue per consuming service

  • What it solves: every interested service gets its copy. Publishers have a single entry point, and each service owns a queue named after it.
  • What it costs: a strict naming convention, and an overview of the subscriptions to build (see the trade-offs below).

Our recommendation

A single topic exchange for the whole system, one queue per consuming service, and binding keys that state what each one receives.

A topic exchange copies each message into every queue with a binding key that matches its routing key. The publisher publishes once, and RabbitMQ delivers a copy to each interested service. Within a queue, the instances of one service then share the work: that is where load balancing belongs.

One exchange, one queue per service: each service gets its own copy of the event order producing service publishes order.order.placed.v1 app.events topic exchange stock.events binding: order.order.placed.v1 consumed by the stock service shipping.events binding: order.order.*.v1 consumed by the shipping service Avoid: a shared queue splits the messages instead of broadcasting them order-placed shared queue stock shipping stock handles messages 1, 3, 5... shipping handles messages 2, 4, 6...

The routing key is the name of the event

We follow the <context>.<entity>.<past-tense fact>.v<N> convention:

  • order.order.placed.v1 and order.order.cancelled.v1;
  • stock.reservation.confirmed.v1;
  • shipping.shipment.dispatched.v1.

The context is the service that owns the fact. The past tense is a reminder that an event describes what happened, not what to do. The version makes it possible to publish a v2 next to the v1 during a transition.

One queue per service, named after it

The queues are called stock.events, shipping.events, billing.events. Each service declares its queue and chooses its binding keys and its retry policy. Adding a consumer requires no change on the publisher's side.

Precise binding keys

In a binding key, * stands for exactly one word and # for zero or more words:

  • order.order.placed.v1 subscribes to a single event;
  • order.order.*.v1 subscribes to every fact of version 1 of the order;
  • # subscribes to all the traffic.

Avoid #. The service would receive events it cannot handle. And every new event in the system would land in its queue without anyone deciding it.

The * wildcard has the same effect on a smaller scale: every new order.order.<fact>.v1 event will reach shipping. Keep it for a family of events the service handles in full.

Event, command, query

Only events go through the topic exchange:

  • an event is a past fact, broadcast to whoever wants to hear it;
  • a command, in the CQRS sense, is a request addressed to one specific service ("reserve this stock"), point to point;
  • a query expects an immediate answer: it stays synchronous, over HTTP.

A command broadcast through the topic exchange could be executed by two services, or by none if nobody subscribed.

When to split a service's queue

One queue per service is a starting point. We only split it on a concrete signal:

  • retry or time-to-live (TTL) policies that diverge between two families of events;
  • heavy messages delaying the critical ones queued behind them (head-of-line blocking), observed in the processing times;
  • an ordering constraint on a subset of the events, which requires a single worker on the queue that carries them;
  • the need to scale one kind of processing separately from the others.

Queues born from a split keep the service prefix: stock.events.priority, stock.events.bulk. Their owner is always obvious.

The trade-offs we accept

A single namespace. All the routing keys go through the same exchange. A typo in a key goes unnoticed: the message leaves, no queue receives it, and RabbitMQ drops it. We contain this with the naming convention, shared constants and, if needed, an alternate exchange that collects the unroutable messages.

Subscriptions live with the consumers. Knowing who consumes what means reading each service's configuration, or the RabbitMQ management UI. Plan a catalogue: a documentation page kept up to date, or an export generated from the configuration.

A queue mixes the events of one service. As long as their processing is similar, that is an advantage: a single queue to monitor per service. The day they diverge, split it, with the criteria above.

The signal that should trigger a review of this choice: a measured delay on critical messages, caused by other messages in the same queue. Or a recurring need to replay the event history, which calls for a log instead: Kafka, or a RabbitMQ stream read by a client other than Messenger.

Implementation

The examples use Symfony 7.4 and the Messenger AMQP transport (symfony/amqp-messenger). The options are the same in Symfony 8.

1. Share the names, not the queues

The exchange name and the routing keys are shared by every service, in a contracts package. Queue names and binding keys stay with each consumer.

// app-contracts/src/Routing.php
namespace AppContracts;

final class Routing
{
    public const EXCHANGE = 'app.events';

    public const ORDER_PLACED_V1 = 'order.order.placed.v1';
    public const ORDER_CANCELLED_V1 = 'order.order.cancelled.v1';
    public const STOCK_RESERVATION_CONFIRMED_V1 = 'stock.reservation.confirmed.v1';
}

2. Publisher side: the exchange, no queue

The order service declares the topic exchange and no queue. With auto_setup, on by default, Messenger declares and binds every configured queue, on the publishing side too. queues: [] avoids creating a queue that nobody would consume.

# config/packages/messenger.yaml (order service)
framework:
  messenger:
    transports:
      events:
        dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
        options:
          exchange:
            name: !php/const AppContracts\Routing::EXCHANGE
            type: topic
          # No queue on the publisher's side: each consumer owns its own.
          queues: []
    routing:
      AppContracts\Order\OrderPlacedV1: events
      AppContracts\Order\OrderCancelledV1: events

The !php/const YAML tag reads the constants of the contracts package: a misspelled name becomes an error at boot, not a lost message.

The routing key is set on the Messenger envelope with an AmqpStamp. Without it, Messenger falls back to default_publish_routing_key, or to no key at all: a topic exchange would then route the message to no queue.

// src/Messenger/EventPublisher.php (order service)
namespace App\Messenger;

use Symfony\Component\Messenger\Bridge\Amqp\Transport\AmqpStamp;
use Symfony\Component\Messenger\MessageBusInterface;

final class EventPublisher
{
    public function __construct(
        private readonly MessageBusInterface $bus,
    ) {
    }

    public function publish(object $event, string $routingKey): void
    {
        $this->bus->dispatch($event, [new AmqpStamp($routingKey)]);
    }
}

The call reads $publisher->publish($event, Routing::ORDER_PLACED_V1). In production, we do not publish from the HTTP request. A relay reads an outbox table, written in the same transaction as the order: no event gets lost on the way.

3. Consumer side: its queue, its binding keys

The stock service declares the same exchange and its own queue, with the events it handles.

# config/packages/messenger.yaml (stock service)
framework:
  messenger:
    failure_transport: failed
    transports:
      events:
        dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
        options:
          exchange:
            name: !php/const AppContracts\Routing::EXCHANGE
            type: topic
          queues:
            stock.events:
              binding_keys:
                - !php/const AppContracts\Routing::ORDER_PLACED_V1
                - !php/const AppContracts\Routing::ORDER_CANCELLED_V1
        retry_strategy:
          max_retries: 3
          delay: 1000
          multiplier: 2
      failed: 'doctrine://default?queue_name=failed'

The shipping service does the same with the shipping.events queue and the order.order.*.v1 binding key. Each handler receives the event's DTO:

// src/MessageHandler/ReserveStockOnOrderPlaced.php (stock service)
namespace App\MessageHandler;

use AppContracts\Order\OrderPlacedV1;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;

#[AsMessageHandler]
final class ReserveStockOnOrderPlaced
{
    public function __invoke(OrderPlacedV1 $event): void
    {
        // Reserves the stock of each line of order $event->orderId.
    }
}

The worker runs with php bin/console messenger:consume events. An event that is bound but has no handler throws an exception on the consumer's side: only bind what the service actually handles.

These examples keep Messenger's default serializer, which encodes the PHP class of the message. The publisher and the consumer then share these classes, through the app-contracts package. In production, we publish a JSON envelope, decoded by a dedicated serializer (the transport's serializer option): the format no longer depends on the PHP code.

4. Retries and quarantine, service by service

With this transport, a retry does not go back through the topic exchange. Messenger republishes the message to a delay queue, which then sends it back to the failing queue only. A failure in stock therefore does not redeliver the event to shipping.

After max_retries retries, the message goes to the service's failure transport. The messenger:failed:show and messenger:failed:retry commands let you inspect and replay it. Without a failure transport, the message would be rejected and lost, unless the queue has a dead letter exchange.

5. Create and check the topology

A queue only receives the messages published after it exists, and the order service declares none. So we run php bin/console messenger:setup-transports when deploying each consumer, before the publisher emits the events it expects. Then we check the bindings:

rabbitmqctl list_bindings source_name destination_name routing_key

The output should show app.events bound to stock.events and shipping.events, with the expected binding keys, and no # binding key.

Checklist

  • A single topic exchange for events.
  • One queue per consuming service, named after it (stock.events).
  • Versioned routing keys: <context>.<entity>.<past-tense fact>.v<N>.
  • The exchange name and the routing keys in shared constants.
  • No # binding key.
  • A handler for every event the binding keys let through, wildcards included.
  • A failure transport (or a DLQ) per service, and a procedure to process it.
  • Commands and queries kept out of the event exchange.
  • A queue split only on a measured signal.

Sources