Skip to main content
Version: 2.x

How to Route Messages with Exchanges

A publisher sends every message to an exchange, never directly to a queue, and the exchange decides which queues receive a copy. This guide shows how to use a direct, a fanout and a topic exchange from ZIO AMQP to deliver one message to the right set of consumers. The semantics of the exchange types are those of RabbitMQ; see Model Types for the list.

All snippets assume these imports:

import zio._
import zio.amqp._
import zio.amqp.model._

Send to a Queue by Nameโ€‹

When a message has exactly one destination, use the default exchange, ExchangeName(""). RabbitMQ declares it for you, and it routes a message to the queue whose name equals the routing key, without any binding:

def sendToQueue(channel: Channel, queue: QueueName, body: String): ZIO[Any, Throwable, Unit] =
channel.publish(ExchangeName(""), body.getBytes("UTF-8"), RoutingKey(QueueName.unwrap(queue)))

Route by Exact Key with a Direct Exchangeโ€‹

To send messages to different queues depending on a label, declare a direct exchange and bind each queue with the label as its routing key. A message goes to every queue whose binding key equals the message's routing key. In the example, only the errors queue receives the second message:

def directRouting(channel: Channel): ZIO[Any, Throwable, Unit] = {
val logs = ExchangeName("logs")
val infos = QueueName("logs.info")
val errors = QueueName("logs.error")

for {
_ <- channel.exchangeDeclare(logs, ExchangeType.Direct)
_ <- channel.queueDeclare(infos)
_ <- channel.queueDeclare(errors)
_ <- channel.queueBind(infos, logs, RoutingKey("info"))
_ <- channel.queueBind(errors, logs, RoutingKey("error"))
_ <- channel.publish(logs, "disk is 80% full".getBytes("UTF-8"), RoutingKey("info"))
_ <- channel.publish(logs, "disk is full".getBytes("UTF-8"), RoutingKey("error"))
} yield ()
}

Binding one queue several times, once per key, delivers to it every message that matches any of them.

Broadcast with a Fanout Exchangeโ€‹

To deliver every message to every interested consumer, such as a cache-invalidation notice, use a fanout exchange. It ignores routing keys, so the key given to Channel#queueBind and Channel#publish is arbitrary. Each subscriber declares its own queue, and a server-named exclusive queue disappears with the subscriber:

def subscribe(channel: Channel, exchange: ExchangeName): ZIO[Scope, Throwable, Unit] =
for {
queue <- channel.queueDeclare(QueueName(""), exclusive = true, autoDelete = true).map(QueueName(_))
_ <- channel.queueBind(queue, exchange, RoutingKey(""))
_ <- channel
.consume(queue, ConsumerTag("subscriber"), autoAck = true)
.mapZIO(delivery => Console.printLine(new String(delivery.getBody, "UTF-8")))
.runDrain
.forkScoped
} yield ()

def broadcast(channel: Channel, exchange: ExchangeName): ZIO[Any, Throwable, Unit] =
channel.exchangeDeclare(exchange, ExchangeType.Fanout) *>
channel.publish(exchange, "cache cleared".getBytes("UTF-8"))

A queue only receives messages published after it was bound, so declare and bind the subscriber queues before the first publish.

Route by Pattern with a Topic Exchangeโ€‹

A topic exchange matches the routing key against patterns made of dot-separated words. In a binding key, * stands for exactly one word and # for zero or more words. With the keys orders.created, payments.error and orders.error, the queue bound with orders.* receives the first and the third, and the queue bound with *.error receives the second and the third:

def topicRouting(channel: Channel): ZIO[Any, Throwable, Unit] = {
val events = ExchangeName("events")
val orders = QueueName("orders")
val errors = QueueName("errors")

for {
_ <- channel.exchangeDeclare(events, ExchangeType.Topic)
_ <- channel.queueDeclare(orders)
_ <- channel.queueDeclare(errors)
_ <- channel.queueBind(orders, events, RoutingKey("orders.*"))
_ <- channel.queueBind(errors, events, RoutingKey("*.error"))
_ <- ZIO.foreachDiscard(List("orders.created", "payments.error", "orders.error")) { key =>
channel.publish(events, key.getBytes("UTF-8"), RoutingKey(key))
}
} yield ()
}

A message that matches both bindings, here orders.error, is delivered once to each queue.

Clean Upโ€‹

Exchanges and queues declared without durable or autoDelete stay on the broker until they are deleted, or until the broker restarts. Delete the ones that a program or a test created with Channel#queueDelete and Channel#exchangeDelete. Deleting a queue or an exchange also removes its bindings:

def cleanUp(channel: Channel): ZIO[Any, Throwable, Unit] =
channel.queueDelete(QueueName("orders")) *>
channel.queueDelete(QueueName("errors")) *>
channel.exchangeDelete(ExchangeName("events"))

Running the Exampleโ€‹

The file RoutingExample.scala runs the topic exchange scenario above and prints how many messages each queue holds. Start RabbitMQ with docker compose up -d and run sbt "zio-amqp-examples/runMain zio.amqp.examples.howto.RoutingExample". The output is:

orders.* queue: 2 messages, *.error queue: 2 messages

ZIO Chat

Hello! ๐Ÿ˜Š I'm your ZIO Chat Bot, designed to be your go-to resource for all things ZIO! Whether you're new to this powerful functional effect system or a seasoned pro, I'm here to assist you. ZIO empowers you to build concurrent and resilient applications in Scala, offering referential transparency, composable data types, type-safety, and more. So, go ahead and ask me anything about ZIO โ€“ I'm here to provide the answers you need! ๐Ÿค–