Publish/Subscribe
One event, many reactions
Section titled “One event, many reactions”Sometimes a single event should trigger several unrelated things. An order is placed, and independently: the email service sends a confirmation, the analytics service records it, and the cache service invalidates a page. None of them knows about the others, and each must get its own copy of the event.
That is publish/subscribe, and the mechanism is a fanout exchange (or a topic exchange when you also want filtering) with one queue per subscriber.
flowchart LR p["Producer (order placed)"] --> x["fanout exchange"] x --> qe["queue: email"] --> se["email service"] x --> qa["queue: analytics"] --> sa["analytics service"] x --> qc["queue: cache"] --> sc["cache service"]
The crucial difference from a work queue
Section titled “The crucial difference from a work queue”This is the distinction the whole module hinges on, so make it concrete:
| Work queue | Publish/subscribe | |
|---|---|---|
| Queues | One shared queue | One queue per subscriber |
| Each message goes to | Exactly one worker | Every subscriber (a copy each) |
| Add a consumer to… | Increase throughput | Add an independent reaction |
| Exchange | Often the default/direct | Fanout (or topic) |
If three “subscribers” all bound the same queue, you’d have a work queue by accident — the message would go to only one of them. Pub/sub requires each subscriber to declare and bind its own queue.
Each subscriber binds its own queue
Section titled “Each subscriber binds its own queue”A common, clean setup: each subscriber declares an exclusive, auto-delete queue (unique to that consumer instance) and binds it to the shared fanout exchange. The exchange copies every message into every bound queue.
const channel = await conn.createChannel();await channel.assertExchange('orders', 'fanout', { durable: true });
// Each subscriber gets its own queue.const { queue } = await channel.assertQueue('', { exclusive: true });await channel.bindQueue(queue, 'orders', '');
channel.consume(queue, (msg) => { if (!msg) return; handleOrderEvent(msg.content.toString()); channel.ack(msg);});channel = conn.channel()channel.exchange_declare(exchange="orders", exchange_type="fanout", durable=True)
# Each subscriber gets its own queue.result = channel.queue_declare(queue="", exclusive=True)queue = result.method.queuechannel.queue_bind(exchange="orders", queue=queue)
def on_event(ch, method, properties, body): handle_order_event(body.decode()) ch.basic_ack(delivery_tag=method.delivery_tag)
channel.basic_consume(queue=queue, on_message_callback=on_event)channel.start_consuming()ch, _ := conn.Channel()ch.ExchangeDeclare("orders", "fanout", true, false, false, false, nil)
// Each subscriber gets its own queue.q, _ := ch.QueueDeclare("", false, false, true, false, nil)ch.QueueBind(q.Name, "", "orders", false, nil)
msgs, _ := ch.Consume(q.Name, "", false, false, false, false, nil)for d := range msgs { handleOrderEvent(string(d.Body)) d.Ack(false)}The producer never changes as you add subscribers — it just publishes to the orders exchange. Want a new fraud-detection reaction? Start a service that binds its own queue. That is the decoupling payoff from the foundations module, made concrete.
Durable subscribers vs ephemeral ones
Section titled “Durable subscribers vs ephemeral ones”Exclusive auto-delete queues vanish when the subscriber disconnects — good for a live dashboard that only cares about events while it’s watching. For a subscriber that must not miss events while it’s briefly down (like the email service), give it a named, durable queue instead, so messages accumulate for it and wait until it reconnects.