4. Streaming with Kafka
Tutorial · Applies to Brighter V10 · Prerequisites: Your First Message Over a Broker, Adding a Durable Outbox
Send the same greeting over a three-partition Kafka topic, then start a second copy of the receiver and watch Kafka hand it half the partitions.
This is the fourth and last rung of the ladder. Rungs 2 and 3 kept one queue and one reader. Kafka splits a topic into partitions, and a group of readers divides those partitions between them — so scaling out is starting the same process again, changing nothing. What you give up is a single global order, and what you get back is order per key, which is usually the order you actually wanted.
Rung 4 branches from rung 2, not from rung 3. The durable Outbox and the Kafka transport are independent choices, and this rung changes the transport while keeping rung 2's plain Post.
What You'll Build: A Partitioned Kafka Stream
Rung 2's three projects with the transport swapped, and nothing else moved:
Greetings
unchanged
GreetingsReceiver
the subscription becomes a KafkaSubscription, with a group id. The handler is untouched
GreetingsSender
the publication becomes a KafkaPublication with three partitions, and each greeting carries a partition key
The sender publishes nine greetings — three each for alice, grace and mia, interleaved. The receiver prints them grouped by recipient rather than in the order they were sent. Then you start a second receiver, watch the group rebalance, and send the nine again to see each recipient's greetings stay in sequence on whichever consumer now owns them.
Before You Start Streaming with Kafka
Rungs 2 and 3 complete. Your First Message Over a Broker builds the three projects, and Adding a Durable Outbox is the guarantee this rung leaves alone.
Start from a copy of rung 2. Rung 3 changed rung 2's sender in place, so if you worked through it in the same folder, copy your rung 2 solution aside — or rebuild those three projects — before making the changes below. Nothing here builds on rung 3's Outbox.
The .NET 9 SDK. Check with
dotnet --version.Docker Desktop, running, with port 9092 free. RabbitMQ and Postgres are not needed; you can stop them.
About thirty minutes, most of it reading and waiting for a rebalance. The machine work measured 5.2 seconds to swap the package on both projects and 3.1 seconds to build, starting from a rung 2 solution and a NuGet cache holding only rung 2's packages. Pulling the Kafka image the first time takes longer and depends on your connection.
Step 1: Start Kafka
Put this in docker-compose-kafka.yml beside your solution:
This is a single broker running in KRaft mode — no ZooKeeper. One broker is enough to learn partitions and consumer groups on, because both are properties of a topic rather than of the cluster.
KAFKA_AUTO_CREATE_TOPICS_ENABLE: "false" is deliberate. With it on, a typo in a topic name silently creates a new topic with the broker's default partition count, and you spend an afternoon wondering why nothing arrives.
As in rung 2, the command returns before the broker is accepting connections. It is ready when this stops failing:
Expected result: docker ps shows one container running, and the command above succeeds rather than failing to connect.
Step 2: Swap the Transport
Both processes talk to the broker, so both change. Remove RabbitMQ and add Kafka:
That is the entire package change. Paramore.Brighter, Paramore.Brighter.Extensions.DependencyInjection and the two Paramore.Brighter.ServiceActivator.* packages are rung 2's and stay exactly as they were — which is the point worth noticing: a transport is a package, not an architecture. The Command Processor, the handler and the event do not know which broker they are running over.
Your .csproj files show PackageReference with a pinned version. The sample in the Brighter repository uses ProjectReference into the source tree instead, which is the one place page and sample differ on purpose.
Expected result — neither app builds, and that is correct. Both report error CS0234: The type or namespace name 'RMQ' does not exist in the namespace 'Paramore.Brighter.MessagingGateway'. You have taken away the transport their code still names; steps 3 and 4 replace that code. Greetings is untouched and still builds.
Step 3: Send with a Partition Key
Replace GreetingsSender/Program.cs with this:
Three things changed from rung 2's sender, and one thing deliberately did not.
NumPartitions = 3. A RabbitMQ queue is a single ordered stream. A Kafka topic is NumPartitions of them, and that is what gives a group of consumers something to divide. The topic is created with this many partitions the first time a process reaches a broker that does not have it.
A partition key on every message. Kafka orders messages within a partition and promises nothing across partitions, so the key is how you decide what shares an order with what. Keying on the recipient means all of alice's greetings go to one partition and stay in sequence, while grace's and mia's are free to be handled elsewhere.
No message mapper. KafkaTaskQueue, Brighter's reference Kafka sample, writes one in order to set the partition key — but it does not have to. JsonMessageMapper<T> is the registered default for both the synchronous and the asynchronous path, and it already reads the key out of the request context onto the message header. Setting context.Bag[RequestContextBagNames.PartitionKey] and passing the context to Post is all it takes. Dispatching a Request covers the request context properly.
Post is unchanged. No Outbox, no transaction — rung 3's guarantee is orthogonal to this one, and combining them is a decision you make per service rather than a step on this ladder.
Expected result: GreetingsSender builds now, 0 Error(s). GreetingsReceiver still fails with step 2's CS0234 — it is step 4's turn.
Step 4: Consume as a Reactor
Replace GreetingsReceiver/Program.cs with this:
groupId is the delta that matters. Every instance sharing a group id is one member of one consumer group, and Kafka guarantees each member a disjoint set of partitions. That is what makes starting a second copy of this process useful rather than duplicative — two instances with different group ids would each receive every greeting.
The pump runs as a Reactor, which is KafkaSubscription<T>'s own default and is written out because it is this rung's point. A Reactor is a single thread per performer, and noOfPerformers defaults to 1 — so one running process is one group member with one thread, draining its partitions in turn.
That single thread is why per-key ordering holds. It is not that Brighter runs a message pump per partition; it runs one per performer, and Kafka gives that member whatever partitions the group's split assigns it. Reactor and Proactor covers the choice properly, including what changes if you raise noOfPerformers.
GreetingEventHandler.cs does not change at all — it is rung 2's file, byte for byte. A handler does not know what transport delivered its message, and that is the whole benefit of having one.
Expected result: all three projects build, 0 Error(s). Step 2's CS0234 is gone from both apps.
Step 5: Watch One Consumer Take All Three Partitions
Start the receiver:
Among its startup logging, one line is the one to watch:
One member gets every partition. The group has one instance, so the split is trivial — but it is the same mechanism that will divide them in step 6.
Now, in a second terminal:
And the receiver prints:
Read that carefully, because it is not the order they were sent. The sender interleaved the recipients — alice #1, grace #1, mia #1, alice #2, and so on — and they came back grouped. One consumer, one thread, and still the order changed.
That is partitions doing exactly what they promise. The single thread drains one partition at a time, and each partition holds one recipient's greetings. There is no global order to preserve, and Kafka never claimed there was. What survived is each recipient's own sequence.
You can see where the keys landed by reading the topic directly:
Those three names were chosen, and the reason is a trap worth knowing about. Kafka picks the partition by hashing the key, so you choose the key and the hash chooses the partition. With three keys over three partitions there is only a 2-in-9 chance they land one apiece — and the first three names this tutorial used, alice, bob and carol, all hashed to partition 2. Nothing looked wrong: one consumer holds every partition anyway, so all nine greetings arrived in perfect order and the code appeared to work while demonstrating nothing at all. Running the command above is what exposed it.
Step 6: Start a Second Instance
Leave everything running and start the receiver again, in a third terminal, with no arguments and no configuration change:
The first receiver logs that its partitions were taken away and which it got back:
The second logs what it was given:
That is a rebalance. Kafka revoked the whole assignment and redistributed it, and the two sets are disjoint and cover all three partitions between them. Which instance gets which is Kafka's decision, not yours — run this twice and you may well see the split the other way round.
Nothing was configured to make this happen. Scaling a Kafka consumer out is starting the process again; scaling it in is stopping one. The group id is the only thing that had to agree.
Step 7: Ordering Holds Per Key
With both receivers running, send the nine greetings again:
The instance holding partitions 0 and 2 prints grace's and alice's:
The instance holding partition 1 prints mia's:
The work divided and every recipient's sequence survived. No greeting was handled twice, no recipient's greetings were split across the two processes, and each one's #1, #2, #3 arrived in that order on whichever consumer owned its partition.
Which recipient's block prints first may differ on your machine, and that is the point rather than a flaw. The instance holding two partitions drains them in whatever order it polls them, so grace and alice can swap places between runs — this page has seen both. What never varies is the run inside each block. The order you are promised is the one you asked for with a key.
This is the guarantee Kafka actually offers, and it is worth stating exactly: ordering is per partition, and therefore per key. If you need two events to be processed in order, give them the same key. If they do not share a key, you have not asked for an order and you will not get one — no matter how few consumers are running.
Two defaults are quietly making the per-key half true, and they are worth knowing before you tune anything: KafkaPublication sets EnableIdempotence = true and MaxInFlightRequestsPerConnection = 1. Without them, a producer retrying a failed send could reorder messages within a partition, and careful keying would not save you.
Step 8: Offsets and What a Rebalance Costs You
An offset is a consumer group's bookmark in a partition. Ask Kafka where this group has got to:
Run that immediately after the greetings are handled and the answer is surprising:
Nothing is committed, although every message was handled. Handling a message stores an offset; it does not commit one. Brighter commits on two paths, and at nine messages neither has run yet:
commitBatchSize, default 10 — offsets are committed in batches, and nine is not ten.sweepUncommittedOffsetsInterval, default 30 seconds — a timer that commits whatever is still outstanding.
Wait half a minute and ask again:
The sweeper has committed, and each partition is left one short. That residual LAG 1 is a Brighter defect rather than Kafka semantics, tracked as #4281; a group that has handled everything ought to reach LAG 0.
Whatever is uncommitted when a rebalance happens gets handled again. Look back at step 6: as well as logging the new assignment, each receiver handled one greeting a second time — the one sitting at the uncommitted offset on each partition it picked up. Three greetings, one per partition, already handled by the instance that had them before.
That is at-least-once delivery, and it is the honest statement about every broker on this ladder. Rung 3's Outbox gave you at-least-once on the sending side; this is the same promise on the receiving side. Nothing is lost. Some things arrive twice. A handler that does real work — charging a card, sending an email — has to tolerate seeing the same message again.
Tuning the two options above changes how many duplicates a rebalance costs, and cannot take it to zero: a rebalance can always arrive between handling a message and committing its offset. Kafka Configuration documents both, along with everything else KafkaSubscription and KafkaPublication expose.
When you are finished:
What Streaming with Kafka Showed You
You swapped one package and gained a scaling model:
A transport is a package. The event, the handler and the Command Processor never learned which broker they were running over. Only the publication and the subscription changed, and the handler file is rung 2's byte for byte.
Partitions are the unit of parallelism, and the key chooses one. You do not pick a partition; you pick a key and Kafka hashes it. Everything sharing a key shares an order.
A consumer group scales by starting the process again. Two instances, one group id, and Kafka divided the partitions without either process being told about the other.
Ordering is per key, not per topic — and the single-threaded Reactor pump is what delivers it. One pump per performer, one performer by default, so one member drains its partitions in turn. It is not a pump per partition.
Delivery is at-least-once, and a rebalance is when you notice. Offsets commit in batches or on a timer, so anything uncommitted when partitions move is handled again.
That is the ladder. You have built a command and a handler, sent a message between two processes, made the send survive a crash, and scaled the receiving side across a partitioned stream. Everything after this is configuration, middleware and the shape of your own domain — and the rest of this documentation is organised around exactly those.
Further Reading
Adding a Durable Outbox — rung 3, if you skipped it; its guarantee composes with this one
Kafka Configuration — every option on
KafkaSubscriptionandKafkaPublication, includingcommitBatchSize, the sweep interval and securityReactor and Proactor — why the pump is single-threaded, and what changes when you raise
noOfPerformersDispatching a Request — the request context, and the other well-known keys besides the partition key
Get Started with Brighter — the ladder's front door, and where to go next
Glossary — every term this page linked, and the rest
Last updated
Was this helpful?
