
JasperFx has very frequently helped our clients deal with concurrency in messaging intensive systems -- especially using sagas or Event Sourcing workflows
Ordered Messaging Without the Locks: Wolverine's Global Partitioning and Re-Sequencer
One of the oldest, nastiest problems in asynchronous messaging is what happens when related messages — commands against the same order, events for the same account, readings from the same device — hit a system that's using competing consumers for scalability. The messages arrive interleaved across threads and nodes, they get processed concurrently against the same entity, and now you're picking your poison: pessimistic locks that throttle everything, optimistic concurrency with retry loops, or the "just design your system to not care about ordering" advice that works right up until it doesn't.
While Wolverine and the greater "Critter Stack" absolutely has first class support for optimistic concurrency and message retries, you don't want to take the very serious performance hit that comes with in high volume systems where concurrency is going to be painfully common. For whatever reason, Wolverine's community has taken concurrency concerns much more seriously than any other messaging framework in .NET (other than true Actor frameworks, but more on that later).
Okay, to be honest, Event Sourcing is admittedly sensitive to concurrency so the Critter Stack has naturally invested into resiliency to concurrency as it is also the only messaging framework that has made Event Sourcing integration a first class feature
Wolverine 5 shipped two capabilities aimed squarely at this problem — Partitioned Sequential Messaging with Global Partitioning, and the Re-Sequencer — and after surveying the rest of the .NET ecosystem, I feel comfortable saying the combination is unique among .NET messaging frameworks. I want to walk through why these features matter, how the mainstream alternatives fall short, and how Wolverine can also exploit Azure Service Bus sessions and Kafka partitions when you'd rather lean on transport-native mechanics.
A quick recap
Global Message Partitioning assigns every message to a group — inferred automatically from saga identity or aggregate stream identity, pulled from a message property, or taken from tenant id — then guarantees that messages within a group are processed strictly sequentially, one at a time, in the order received, while messages in different groups process in parallel across the whole application cluster. Wolverine shards the work across queues, routes every message (including cascading messages) to the queue or node that owns its group, and rebalances as nodes come and go.
using var host = await Host.CreateDefaultBuilder()
.UseWolverine(opts =>
{
opts.UseRabbitMq();
opts.MessagePartitioning
// Use the saga identity or aggregate handler workflow
// identity as the message group id
.UseInferredMessageGrouping()
// Or teach Wolverine explicitly
.ByMessage<IOrderCommand>(x => x.OrderId)
.GlobalPartitioned(topology =>
{
// Shard the work across 5 Rabbit MQ queues,
// with sticky group-to-queue assignment
topology.UseShardedRabbitQueues("orders", 5);
topology.MessagesImplementing<IOrderCommand>();
});
}).StartAsync();The Re-Sequencer attacks the sibling problem: messages that arrive out of sequence from an upstream system that numbers them. It's the classic Resequencer pattern from Enterprise Integration Patterns — buffer out-of-order messages and release them in order — shipped as a first-class saga type:
public record MySequencedCommand(Guid SagaId, int? Order) : SequencedMessage;
public class MyWorkflowSaga : ResequencerSaga<MySequencedCommand>
{
public Guid Id { get; set; }
public static MyWorkflowSaga Start(StartMyWorkflow cmd)
=> new MyWorkflowSaga { Id = cmd.Id };
public void Handle(MySequencedCommand cmd)
{
// Only called when messages arrive in the correct order, or
// when held-back messages are replayed after gaps are filled
}
}As far as we can tell, no other .NET messaging framework ships a resequencer as a packaged feature — Spring Integration and Apache Camel have had one on the JVM for years, but in .NET the answer has always been "roll your own saga and good luck."
No locks, no semaphores, no retry storms
Here's the part I want to emphasize, because it's easy to miss: Wolverine achieves sequential-per-group processing structurally, not through runtime coordination. The group id is hashed deterministically to a slot; the slot maps to exactly one queue with exactly one sequential consumer; that consumer lives on exactly one node. Ordering falls out of the topology. There is no distributed lock to acquire, no semaphore to contend on, no lease to renew, and no lock manager to become a bottleneck or a single point of failure under load.
Contrast that with what the mainstream .NET alternatives offer:
- NServiceBus ships no partitioned-ordering feature at all. Its documented answer to concurrent messages hitting the same saga is optimistic concurrency at the persistence layer: one handler wins, and "the other handlers fail, roll back, and their messages enter recoverability." Particular's official position is that you don't need ordered delivery — design every saga to tolerate out-of-order arrival. There is a partitioned-processing recipe buried in the docs, but it's entirely DIY: you split the queues yourself, mark each instance uniquely addressable yourself, and write the routing logic yourself.
- MassTransit's general-purpose partitioner middleware is honest about its scope — the docs state plainly that it "does not partition across load balanced consumer instances." It serializes per key within one process only. To MassTransit's credit, its SQL transport does have a genuine cluster-wide
PartitionedOrderedreceive mode — the closest thing to a peer feature in .NET — but it exists on that one transport only, and the coordination is mediated through database locking rather than topology. Wolverine's partitioning works the same way over RabbitMQ, Azure Service Bus, Amazon SQS, Kafka, Pulsar, NATS, Redis, GCP Pub/Sub, and plain local queues.
And this brings me to the point that I think gets underrated in these discussions: sometimes the payoff isn't ordering at all — it's throughput. Even when your handlers are formally safe under optimistic concurrency, every collision costs you: a wasted database round trip, a rollback, a redelivery, another trip through the pipeline, and — in the pathological case of a hot saga getting hammered by a burst of related messages — a retry storm where handlers repeatedly trample each other. I've watched real systems spend a disturbing fraction of their capacity reprocessing work they already did. Partitioning makes the collisions impossible instead of recoverable, and the retries simply never happen. Same hardware, more useful work.
"But sagas should just tolerate disorder"
The "design for out-of-order delivery" advice is genuinely good advice — when you can take it. Plenty of workflows are commutative or can be made so. But an honest accounting says there's a large class of real-world integrations where it's somewhere between painful and impossible:
- Downstream feeds from systems that emit numbered, sequenced records — EDI documents, mainframe batch extracts, ledger postings — where record n+1 is literally meaningless without record n
- Item-level inventory or account-balance updates where applying deltas out of order produces different (wrong) states
- Change-data-capture or event streams being replicated into another store, where disorder means corruption
- Long-running workflows where "handle every permutation of message arrival order" turns a ten-line handler into a state-machine hairball that's far harder to test than the sequential version
For those cases, "make every handler tolerate any ordering" isn't simplification — it's pushing the complexity out of the framework and into every single handler you'll ever write. Wolverine's position is that the framework should offer you the guarantee, and the ResequencerSaga covers even the case where the transport can't be trusted to deliver in order in the first place.
What about actor frameworks?
Credit where due: Akka.NET's Cluster Sharding and Orleans grains genuinely deliver both halves of this guarantee — per-identity serialized execution plus cluster-aware routing to the entity's owning node. If you're already invested in the actor paradigm, you have this capability.
But that's the rub: the paradigm. The actor model asks your team to restructure how they write, test, and reason about everything. The criticisms are well documented and they're not strawmen. Noel Welsh's "Why I Don't Like Akka Actors" points at the fundamental signature of an actor — you send a message and something happens — as an opaque A => Unit interface that resists type checking and composition. Eric Torreborre argues that actor systems "don't scale in terms of complexity" — behavior is a side effect, so you have to read every actor's internals to know what the system does, and refactoring becomes fraught. Academic work on actor-based programs has cataloged an entire bug taxonomy unique to the model, including behavioral deadlocks — actors that aren't blocked but silently stop making progress — which are among the hardest defects to diagnose precisely because no lock or thread dump will ever show them.
There's also a less philosophical, more operational gap: delivery guarantees. Both Akka.NET and Orleans default to at-most-once, non-durable message delivery — durable delivery is opt-in machinery you bolt on. Wolverine's partitioning composes with its durable inbox and outbox, so the sequencing guarantee survives node crashes without any extra moving parts.
With Wolverine, the programming model stays exactly what it was: a message type, a handler method, plain old testable code. You get the actor model's marquee concurrency guarantee — serialized execution per logical identity, distributed across a cluster — as a messaging configuration, not as an architectural commitment.
Or lean on the transport: Azure Service Bus sessions
Azure Service Bus is in our opinion, much more usable now that their Emulator is genuinely useful for local development and test automation
If you're on Azure Service Bus, you might reasonably prefer to let the broker do this work. ASB sessions are exactly this concept at the transport level — a session id acts as the group, and the broker guarantees FIFO within a session while demultiplexing sessions across concurrent receivers. Wolverine supports this directly: Envelope.GroupId maps straight onto the ASB SessionId, in both directions.
var host = await Host.CreateDefaultBuilder()
.UseWolverine(opts =>
{
opts.UseAzureServiceBus(connectionString).AutoProvision();
// Require session identifiers on this queue --
// the broker now guarantees FIFO within each session
opts.ListenToAzureServiceBusQueue("incoming")
.RequireSessions()
// Force Wolverine to process messages sequentially
// as well
.Sequential();
}).StartAsync();And on the sending side, the session id is just the group id:
// Explicitly at send time
await bus.SendAsync(new ProcessPayment(paymentId),
new DeliveryOptions { GroupId = orderId.ToString() });
// Or on cascading messages from inside a handler
public static IEnumerable<object> Handle(ApproveInvoice command)
{
yield return new PayInvoice(command.Id).WithGroupId(command.OrderId);
}The trade-off versus Wolverine's global partitioning: sessions are ASB-only, and the burden of assigning the session id is on you at every send point, whereas UseInferredMessageGrouping() derives the group from your sagas and aggregates automatically and follows the message through cascades. But it's a perfectly good option, it's broker-enforced, and Wolverine makes it a one-liner.
And yes, Kafka too
Kafka's whole design is partitioned ordering — messages with the same key land on the same partition, and a partition is consumed in order by one consumer in a group. Wolverine plays this two ways.
First, the global partitioning feature works over Kafka exactly like it does over Rabbit, sharding the message groups across a set of topics with sticky group-to-topic assignment and cluster-aware consumption:
var host = await Host.CreateDefaultBuilder()
.UseWolverine(opts =>
{
opts.UseKafka("localhost:9092").AutoProvision();
opts.MessagePartitioning
.UseInferredMessageGrouping()
.GlobalPartitioned(topology =>
{
// Shards message groups across topics
// orders1 through orders4
topology.UseShardedKafkaTopics("orders", 4);
topology.MessagesImplementing<IOrderCommand>();
});
}).StartAsync();Second — if you'd rather use Kafka's native per-topic partitioning — Wolverine can put your message group id directly onto the Kafka message key, so the broker's own hash-by-key partition assignment keeps every group on a consistent partition:
var host = await Host.CreateDefaultBuilder()
.UseWolverine(opts =>
{
opts.UseKafka("localhost:9092").AutoProvision();
opts.MessagePartitioning
.ByMessage<IOrderCommand>(x => x.OrderId);
// Copy the Wolverine group id onto the outgoing
// Kafka message key so Kafka's partition assignment
// keeps each group on one partition, in order
opts.Policies.PropagateGroupIdToPartitionKey();
opts.PublishAllMessages().ToKafkaTopic("orders");
// On the consuming side, you can even fan work out
// across parallel workers *within* a partition while
// still preserving per-key ordering
opts.ListenToKafkaTopic("orders")
.ProcessConcurrentlyByKey(PartitionSlots.Five);
}).StartAsync();You can also set the key explicitly per message with new DeliveryOptions { PartitionKey = "..." } when you want full control. Either way, the ordering key flows from the same MessagePartitioning rules that drive everything else — define the grouping once, and it works whether the enforcement mechanism is Wolverine's own sharded topology, an ASB session, or a Kafka partition.
Wrapping up
Sequential processing of related messages is one of those requirements that looks niche until you hit it, and then it's the only thing that matters. The actor frameworks solved it years ago at the cost of adopting a whole different programming model. The mainstream .NET message buses mostly tell you to design around it or hand you the parts to build it yourself. Wolverine's bet is that this belongs in the messaging framework itself — inferred from the domain model you already have, enforced by topology instead of locks, durable by default, and portable across every transport we support, including the transport-native mechanisms like ASB sessions and Kafka keys when those fit better.
If ordered or contention-free message processing has been a pain point for you, I'd love to hear about your scenario — and as always, come find us in the Critter Stack Discord.
