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.
The routing key is the name of the event
We follow the
<context>.<entity>.<past-tense
fact>.v<N>
convention:
-
order.order.placed.v1andorder.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.v1subscribes to a single event; -
order.order.*.v1subscribes 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
topicexchange 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
- RabbitMQ, Work Queues tutorial: messages handed in turn to the consumers of a queue.
-
RabbitMQ,
Topics
tutorial: routing keys,
*and#wildcards. - RabbitMQ, Alternate Exchanges: collecting unroutable messages.
- RabbitMQ, Streams: a replayable log inside RabbitMQ.
-
RabbitMQ,
rabbitmqctl: the
list_bindingscommand. -
Symfony,
Messenger, AMQP transport:
exchange,queues,binding_keys,auto_setupoptions. -
Symfony,
Messenger, retries and failures:
retry_strategyandfailure_transport. - Symfony, AMQP transport code: queue declaration and retry routing.
- Enterprise Integration Patterns, Publish-Subscribe Channel and Competing Consumers.