"Should we use Service Bus or Event Hub?" gets asked on nearly every financial .NET system that touches more than one downstream consumer, and it gets answered wrong more often than you'd expect — usually by picking whichever one a team used on a previous project, or by defaulting to Event Hub because it sounds more scalable.
Both are first-class Azure messaging services. Neither is "better." They solve different problems, and using the wrong one for a financial workload either creates correctness bugs (out-of-order processing, duplicate side effects) or unnecessary operational cost. This article gives you the decision framework: what each service actually guarantees, where teams get it wrong, and how to combine them when a single pipeline genuinely needs both.
The Decision Most Teams Get Wrong
The mistake isn't choosing the "weaker" service for a given job — it's not asking the question that actually decides it: does this message need to be processed exactly once, in a guaranteed order, with the broker tracking whether you're done with it? If yes, you have a Service Bus problem, regardless of volume. If the message is one of millions of similar, independent events that several systems want to read without affecting each other, you have an Event Hub problem, regardless of how "important" each individual event feels.
Teams get this backwards in two predictable ways: they put transactional commands (payments, statement triggers, compliance approvals) onto Event Hub because they expect future volume to be huge, and they put high-volume telemetry onto Service Bus because each event individually "matters" for audit purposes. Both choices work at small scale and fail differently as volume and consumer count grow — Event Hub silently drops the ordering and exactly-once guarantees the command path was relying on; Service Bus chokes on throughput it was never built to clear cheaply.
Service Bus: Ordered, Transactional, Lower-Volume Messaging
Azure Service Bus is a broker. It tracks every message's state — locked, completed, abandoned, dead-lettered — and only removes a message once a consumer explicitly acknowledges it. Combined with sessions, it gives you a strict per-session FIFO ordering guarantee and exactly-once processing semantics that hold even under consumer failures and retries. That combination is exactly what a financial command path needs: a statement-generation trigger, a payment instruction, a compliance approval workflow step.
- Use it when: each message represents a discrete business transaction that must be processed once, in order relative to other messages for the same entity, with a durable record of whether it succeeded.
- Guarantees you get: FIFO ordering within a session, at-least-once delivery
with dedup via
MessageId, dead-lettering for poison messages, and scheduled / deferred delivery for workflow timing. - Throughput ceiling: thousands of messages per second per entity, not millions per second across a hub. That's a feature here — Service Bus isn't trying to be a log, it's trying to be a reliable mailbox.
// Session-based processor: ordered, exactly-once handling per account
public class StatementCommandProcessor
{
private readonly ServiceBusSessionProcessor _processor;
private readonly IStatementService _statementService;
private readonly ILogger<StatementCommandProcessor> _logger;
public StatementCommandProcessor(ServiceBusClient client, IStatementService statementService, ILogger<StatementCommandProcessor> logger)
{
_statementService = statementService;
_logger = logger;
_processor = client.CreateSessionProcessor("statement-commands", new ServiceBusSessionProcessorOptions
{
MaxConcurrentSessions = 20,
AutoCompleteMessages = false // we complete only after the downstream write succeeds
});
_processor.ProcessMessageAsync += HandleMessageAsync;
_processor.ProcessErrorAsync += args =>
{
_logger.LogError(args.Exception, "Service Bus session processor fault: {Source}", args.ErrorSource);
return Task.CompletedTask;
};
}
private async Task HandleMessageAsync(ProcessSessionMessageEventArgs args)
{
var command = args.Message.Body.ToObjectFromJson<StatementCommand>();
var ct = args.CancellationToken;
try
{
await _statementService.GenerateAsync(command, ct); // idempotent on AccountId + PeriodId
await args.CompleteMessageAsync(args.Message, ct); // only ack after the write succeeds
}
catch (Exception ex)
{
_logger.LogError(ex, "Statement command failed for account {AccountId}", command.AccountId);
await args.AbandonMessageAsync(args.Message, cancellationToken: ct); // redelivered, session order preserved
}
}
public Task StartAsync(CancellationToken ct) => _processor.StartProcessingAsync(ct);
}
The session guarantees that two commands for the same account are never processed out of order or concurrently, even with twenty sessions running in parallel across other accounts. That's the property Event Hub cannot give you without extra application-level work.
Event Hub: High-Throughput Telemetry and Event Streaming
Event Hub is a partitioned, append-only log — closer to Kafka than to a traditional message broker. There's no per-message acknowledgment and no broker-tracked delivery state; instead, consumers read from a partition at an offset and checkpoint their own progress. That model is built for sustained high-throughput ingestion — telemetry, audit events, market data ticks, pricing updates — where the value is in the aggregate stream, and where multiple independent consumer groups need to read the same data without affecting each other.
- Use it when: you're ingesting a high-volume, append-only stream of events that several independent systems each want to read in full, and per-event ordering only needs to hold within a logical grouping (a partition key), not globally.
- Guarantees you get: ordering within a partition, at-least-once delivery, independent consumer groups (a dashboard and a data warehouse can both read the same stream from their own checkpoint without interfering with each other), and throughput that scales linearly with partition count.
- What you give up: no broker-side dead-lettering, no per-message acknowledgment, and no ordering guarantee across partitions — your producer's partition-key strategy is doing all the ordering work that Service Bus would otherwise do for you.
// Partition-key strategy is what preserves per-account ordering here —
// the Event Hub client itself makes no such promise across partitions.
public class AuditEventPublisher
{
private readonly EventHubProducerClient _producer;
public async Task PublishAsync(AuditEvent evt, CancellationToken ct)
{
using var batch = await _producer.CreateBatchAsync(new CreateBatchOptions
{
PartitionKey = evt.AccountId.ToString() // same key -> same partition -> preserved order for this account
}, ct);
var data = new EventData(JsonSerializer.SerializeToUtf8Bytes(evt));
if (!batch.TryAdd(data))
throw new InvalidOperationException("Audit event exceeds max batch size");
await _producer.SendAsync(batch, ct);
}
}
// Each consumer group reads the same stream independently — checkpointing
// its own progress, with no coordination with other groups required.
public class AuditEventConsumer
{
private readonly BlobContainerClient _checkpointStore;
private readonly string _connectionString;
private readonly IWarehouseLoader _warehouseLoader;
private readonly ILogger<AuditEventConsumer> _logger;
public AuditEventConsumer(
BlobContainerClient checkpointStore, string connectionString,
IWarehouseLoader warehouseLoader, ILogger<AuditEventConsumer> logger)
{
_checkpointStore = checkpointStore;
_connectionString = connectionString;
_warehouseLoader = warehouseLoader;
_logger = logger;
}
public async Task RunAsync(CancellationToken ct)
{
var processor = new EventProcessorClient(
_checkpointStore, "warehouse-loader", _connectionString, "audit-events");
processor.ProcessEventAsync += async args =>
{
var evt = JsonSerializer.Deserialize<AuditEvent>(args.Data.EventBody.ToArray());
await _warehouseLoader.WriteAsync(evt, args.CancellationToken);
await args.UpdateCheckpointAsync(args.CancellationToken); // our progress, not the broker's
};
processor.ProcessErrorAsync += args =>
{
_logger.LogError(args.Exception, "Event Hub processor error on partition {PartitionId}", args.PartitionId);
return Task.CompletedTask;
};
await processor.StartProcessingAsync(ct);
}
}
Hybrid Patterns — When You Actually Need Both
Most real financial systems above a certain size end up with both, not because of indecision but because they have two genuinely different message shapes flowing through them: a transactional command path and a high-volume event/telemetry path. Forcing both onto one technology means either over-paying for Event Hub's throughput model on low-volume commands, or trying to bolt fan-out and high-throughput ingestion onto Service Bus topics, which were never designed to carry that load cheaply.
The pattern that holds up: Service Bus carries the commands that drive a workflow forward (something must happen exactly once, in order, with a tracked outcome). Each processing step that succeeds then emits an event onto Event Hub for anyone who wants to observe what happened — a real-time dashboard, an anomaly-detection job, a downstream data warehouse — without those observers being coupled to, or capable of interfering with, the transactional path. The command path stays small and reliable; the observability path scales independently and can add new consumers without touching the producer.
Comparing the Two
| Property | Service Bus | Event Hub |
|---|---|---|
| Ordering guarantee | Strict, per session (FIFO) | Per partition only |
| Delivery model | Broker-tracked, ack/abandon/dead-letter | Consumer-tracked checkpoint offset |
| Throughput profile | Thousands/sec per entity | Millions/hour, scales with partitions |
| Multiple independent readers | Via topics/subscriptions, each consumed once | Native — multiple consumer groups read the full stream |
| Best fit | Commands, workflow steps, transactions | Telemetry, audit streams, market data, metrics |
What This Framework Doesn't Settle
This is a messaging-layer decision, not a full architecture. It doesn't tell you how to structure the consumers that sit behind either service, how to version message schemas as a system evolves, or how to handle exactly-once processing at the application level when a consumer crashes mid-write — those are separate, real problems regardless of which broker you pick. It also assumes you've already concluded that a message broker is the right tool at all; for systems that don't yet have multiple independent consumers or a real ordering requirement, a direct API call or a database-backed outbox pattern is often simpler and should be ruled out first.
For more on choosing event-driven architecture over alternatives in the first place, see our article on replacing batch statement generation with event-driven .NET, and our broader case study on a real-time financial statements pipeline for what the surrounding system looks like once the messaging layer is in place.
Frequently Asked Questions
- Can Event Hub guarantee message order like Service Bus?
- Event Hub guarantees order only within a single partition, not across the hub. If you need a strict, global ordering guarantee, Service Bus sessions give you that directly. Event Hub can approximate per-entity ordering by always routing the same partition key to the same partition, but that only holds as long as the partition count never changes and every producer hashes the key the same way.
- Is Event Hub cheaper than Service Bus at scale?
- At high throughput, yes — Event Hub's pricing model is built for millions of small events per hour. At low-to-moderate volume with large or complex messages, Service Bus's per-operation pricing is often cheaper. The crossover point depends on message size and volume — model both before deciding on cost alone.
- Can I migrate from Service Bus to Event Hub later if volume grows?
- Not as a drop-in swap. Service Bus consumers expect ordered, transactional delivery; Event Hub consumers read an append-only, partitioned log with checkpointing. Moving between them means rewriting delivery and retry semantics, not just changing a connection string — plan the messaging choice around projected scale up front.
- Do I need both Service Bus and Event Hub in the same system?
- It's common in financial systems with both a transactional command path and a high-volume observability path. Service Bus carries commands that must be processed exactly once and in order; Event Hub carries the telemetry or audit stream generated by that processing, fanned out to multiple downstream consumers without coupling them to the transactional path.
When to Bring in External Help
Picking the wrong messaging primitive early is expensive to unwind once consumers, retry logic, and dead-letter handling are built around it. A short architecture review before the first message ever ships catches this class of mistake while it's still a design decision, not a production incident.
A two-week Discovery Sprint reviews your event-driven design against the actual ordering, throughput, and delivery requirements of your workload before code commits you to one broker's assumptions.