For the complete documentation index, see llms.txt. This page is also available as Markdown.

2. Your First Message Over a Broker

Tutorial · Applies to Brighter V10 · Prerequisites: Your First Command

Send an event from one process, over a RabbitMQ exchange, to a second process that handles it.

This is the second rung of the ladder. Rung 1 sent a request to a handler inside one process. Here the handler moves into a different process, and the two only ever agree on a name — the routing key. Your code barely changes; what changes is that the two halves can now be deployed, restarted and scaled apart from each other.

What You'll Build: Your First Message Over a Broker

RabbitMQ in Docker, and a solution with three projects:

Project
What it is

Greetings

a class library holding GreetingEvent — the one type both processes share

GreetingsSender

a console app that publishes the event and exits

GreetingsReceiver

a console app that runs until you stop it, handling whatever arrives

The sender prints Published greeting.event and exits. The receiver prints Received: Hello from the sender and keeps running.

The shared library is the only code the two processes have in common. That is deliberate: in a real system the sender and the receiver are separately deployed, and often separately owned. A shared library is convenient here, not required — what actually couples them is the routing key and the shape of the JSON on the wire.

Before You Start Your First Message

  • Rung 1 complete. Your First Command introduced the Command Processor, handlers and AutoFromAssemblies(). This page assumes all three.

  • The .NET 9 SDK. Check with dotnet --version.

  • Docker Desktop, running, with ports 5672 (AMQP) and 15672 (the management UI) free. If something else is on those ports, RabbitMQ will start and your apps will not connect.

  • About twenty minutes. The machine work — create, restore, build — measured 23 seconds on a clean machine with an empty NuGet package cache. Pulling the rabbitmq:management image the first time takes longer and depends on your connection.

Step 1: Start RabbitMQ

Create docker-compose.yml in a new folder:

Expected result: docker ps shows one container running.

That command returns before RabbitMQ accepts connections, because this compose file has no healthcheck. Wait until http://localhost:15672 answers — guest / guest — before you start either app. Starting early prints connection failures until the broker comes up, which looks exactly like a broken sample and is not one.

Step 2: Define the Greeting Event

Delete the generated Class1.cs, and put this in Greetings/GreetingEvent.cs:

An event derives from Event, where rung 1's command derived from Command. The difference is intent, and it is not cosmetic: a command is addressed to exactly one handler, while an event is a statement of fact that any number of subscribers may act on. Crossing a broker is what makes that distinction pay.

Both the parameterless constructor and the setter are load-bearing. The receiver does not get your object; it gets bytes, which System.Text.Json turns into a new instance. Given two constructors and no [JsonConstructor], it picks the parameterless one and then assigns properties — so a get-only Greeting would arrive as an empty string, with no error anywhere. If your receiver prints Received: and nothing else, this is why.

Expected result: dotnet build Greetings reports 0 Error(s). Nothing runs yet — a class library has nothing to run, and neither process exists.

Step 3: Build the Sender

Replace GreetingsSender/Program.cs:

Three things are new since rung 1:

  • A publication is the outbound half of the arrangement: this request type goes to this routing key on this broker. MakeChannels = OnMissingChannel.Create tells Brighter to declare the exchange if it is not there, which is what saves you a broker-setup step.

  • Post, not Send. Rung 1's Send ran a handler in-process and returned when it finished. Post hands the request to the transport. Nothing in this process handles it.

  • The using (…) { } block is deliberate, and so is the line after it. Publisher confirms are asynchronous: Post returns once the message is on its way, and the broker's acknowledgement arrives later. Disposing the host is what waits for it, bounded by RmqPublication.WaitForConfirmsTimeOutInMilliseconds500 ms by default. Written as using var host, the Console.WriteLine would run before that wait.

Published greeting.event means "sent", not "accepted". A negative acknowledgement from the broker, or a confirm window that lapses, surfaces as a log line rather than an exception — so this message prints either way. Rung 3 is where the message stops depending on this process staying alive to be delivered at all.

Expected result: dotnet build GreetingsSender reports 0 Error(s). Do not run it yet — on a brand-new broker there is no queue until the receiver has declared one, and step 5 starts the two in the order that works.

Step 4: Build the Receiver

The package names say ServiceActivator and this page says Dispatcher. They are the same thing: Dispatcher is the V10 name for the component that owns the message pumps, and ServiceActivator is the older name, still carried by the assemblies and the ServiceActivatorHostedService type so that existing code keeps compiling. You are on the right page. See Dispatcher.

This is also why rung 2 pins Microsoft.Extensions.Hosting at 10.0.10 rather than rung 1's 9.0.0Paramore.Brighter.ServiceActivator.Extensions.Hosting requires it, and the older pin fails the build with NU1605.

Put this in GreetingsReceiver/GreetingEventHandler.cs:

That is rung 1's handler with a different request type. Nothing about it knows a broker exists, which is the point.

Replace GreetingsReceiver/Program.cs:

  • A subscription mirrors the publication: the queue to read, the routing key bound to it, and how to run the pump. "greeting.event" appears here twice — as the channel (queue) name and as the routing key — and once in the sender. Three bare strings, deliberately not a shared constant: agreeing on a name over the wire is the coupling, and hiding it behind a constant would hide the lesson.

  • MessagePumpType.Reactor runs a single-threaded pump, so your handler can be synchronous and needs no locking. See Reactor and Proactor.

  • host.RunAsync(), which rung 1 did not have. Now there is something to host: the Dispatcher runs until you stop it.

Expected result: all three projects build, 0 Error(s).

Step 5: Run Both Processes

Two terminals, the receiver first — it is the process that declares the queue and binding:

Expected result — the receiver, ending at the greeting and then waiting:

And the sender, which exits:

Both listings are trimmed: each process also logs the whole serialized message on one very long line, and the receiver logs its hosting environment and content root. Identifiers and timestamps differ on every run.

The decoded body is worth seeing once, because you never wrote any code to produce it:

Brighter serialized your event with its default mapper, JsonMessageMapper<T>, and deserialized it on the other side. Registering a mapper by hand is something you do when the wire format has to match someone else's contract, not to get started.

If you ran the sender first, on a brand-new broker, nothing arrives. The sender declares the exchange; the receiver declares the queue and the binding. With no queue bound, RabbitMQ discards the message — and the publish still succeeds, so the sender prints Published greeting.event and exits 0. Start the receiver, then run the sender again.

Order only matters that first time. The queue is declared with autoDelete: false, so once the receiver has run, the queue and its binding outlive it and survive until the broker restarts. After that you can run the sender with nothing listening, and the message waits in the queue until the receiver comes back.

Step 6: See It in RabbitMQ

Open http://localhost:15672 and log in with guest / guest. Under Exchanges and Queues you will find what your two processes declared:

Name
Notable

Exchange

paramore.brighter.exchange

type direct — the routing key must match exactly

Queue

greeting.event

0 messages once the receiver has drained it, 1 consumer

Binding

exchange → queue

on routing key greeting.event

If the queue is missing, the receiver has not run. If it is there with messages piling up, the receiver has stopped but its queue survived — which is the autoDelete: false behaviour above.

Both are declared non-durable, so a broker restart removes them. That is a property of the queue, and a different question from whether a message survives the sender crashing — which is rung 3's subject.

Stop the broker when you are done:

What Your First Message Showed You

The handler did not change in any way that matters, and it now runs in another process:

  • A publication and a subscription are two halves of one agreement, and the agreement is a string. The sender knows a routing key; the receiver knows the same routing key. Neither knows the other exists.

  • The Dispatcher is the consuming counterpart of the Command Processor. It owns the pump that reads the queue, hands each message to Brighter, and acknowledges it once your handler returns. You configure it; you never call it.

  • Acknowledgement happens after your handler returns, which is why a crash mid-handler redelivers rather than loses. That is at-least-once delivery, and it means your handler should tolerate seeing the same message twice.

What you still do not have is durability. The message existed only in RabbitMQ: if the sender had crashed between writing its data and publishing, the two would have disagreed permanently. The next rung puts an Outbox in the same transaction as your business data, so the message is either stored with it or not at all.

Further Reading

Last updated

Was this helpful?