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:
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:managementimage 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.Jsonturns into a new instance. Given two constructors and no[JsonConstructor], it picks the parameterless one and then assigns properties — so a get-onlyGreetingwould arrive as an empty string, with no error anywhere. If your receiver printsReceived: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.Createtells Brighter to declare the exchange if it is not there, which is what saves you a broker-setup step.Post, notSend. Rung 1'sSendran a handler in-process and returned when it finished.Posthands 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:Postreturns once the message is on its way, and the broker's acknowledgement arrives later. Disposing the host is what waits for it, bounded byRmqPublication.WaitForConfirmsTimeOutInMilliseconds— 500 ms by default. Written asusing var host, theConsole.WriteLinewould run before that wait.
Published greeting.eventmeans "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
ServiceActivatorand this page says Dispatcher. They are the same thing: Dispatcher is the V10 name for the component that owns the message pumps, andServiceActivatoris the older name, still carried by the assemblies and theServiceActivatorHostedServicetype so that existing code keeps compiling. You are on the right page. See Dispatcher.This is also why rung 2 pins
Microsoft.Extensions.Hostingat 10.0.10 rather than rung 1's9.0.0—Paramore.Brighter.ServiceActivator.Extensions.Hostingrequires it, and the older pin fails the build withNU1605.
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.Reactorruns 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.eventand exits0. 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:
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
Your First Command — rung 1, if you skipped it
RabbitMQ Configuration — every option on the connection, publication and subscription used above
Reactor and Proactor — what
MessagePumpType.Reactorchose, and when to choose the other oneHow the Dispatcher Works — pumps, performers and channels underneath the subscription
Dispatching Requests —
Send,PublishandPostcomparedBasic Configuration — what
AddBrighter()andAddConsumers()accept beyond these defaultsGlossary — every term this page linked, and the rest
Last updated
Was this helpful?
