Azure Service Bus (Queue) + Cosmos DB + .NET 10
Step 1 — Infrastructure setup
The app starts by creating a CosmosClient pointed at the local emulator (https://localhost:8081). Because the emulator uses a self-signed certificate, SSL validation is disabled with (_, _, _, _) => true.
Step 2 — Service Bus queue provisioning
A ServiceBusAdministrationClient checks if the queue bank-account-events exists in the real Azure namespace testing-gabgreg.servicebus.windows.net. If not, it creates it. Then a ServiceBusSender is created for that queue.
Step 3 — Store initialization
CosmosDbEventStore.InitializeAsync() calls CreateDatabaseIfNotExistsAsync("event-sourcing") and then CreateContainerIfNotExistsAsync("events", partitionKey: "/aggregateId"). The snapshot store does the same for the "snapshots" container (partitioned by /id). Both containers are created only if missing — safe to call on every startup.
Step 4 — Business logic runs in memory
BankAccount.Deposit(amount) creates a MoneyDepositedEvent, calls ApplyChanges(event), which calls When(event) to mutate state (Balance += amount, Version++) and adds the event to the private _changes list. Nothing is written to any store yet.
Step 5 — Save is triggered every 5 events
repository.SaveAsync(account) is called. It reads account.GetChanges() (the 5 uncommitted events) and calculates expectedVersion = account.Version - changes.Count.
Step 6 — Optimistic concurrency check
CosmosDbEventStore.SaveAsync() runs a COUNT query against Cosmos:
SELECT VALUE COUNT(1) FROM c WHERE c.aggregateId = @id
If the count doesn't match expectedVersion, it throws — meaning another writer already added events. This protects against concurrent writes.
Step 7 — Each event is inserted as a document
For each event in the batch, an EventDocument is created:
{
"id": "<new-guid>",
"aggregateId": "<account-guid>",
"version": 3,
"eventType": "MoneyDepositedEvent",
"payload": "{\"Amount\":10}",
"occurredAt": "2026-03-30T..."
}payload is the event serialized with System.Text.Json. The document itself (its field names) is serialized by the Cosmos SDK using Newtonsoft.Json (hence the [JsonProperty] attributes on EventDocument).
Step 8 — Snapshot check
After saving events, the repository reads the current snapshot from Cosmos and checks:
account.Version - snapshotVersion >= 50
If true, a snapshot document is upserted (insert or replace) into the snapshots container capturing the current Balance and Version.
Step 9 — Publish after persistence
Only after Cosmos has confirmed the write does _publisher.PublishAsync() run. This is the save-first, publish-after guarantee: if the process crashes before publishing, the events are still safe in Cosmos.
Step 10 — Message construction
For each event, a ServiceBusMessage is created wrapping an EventEnvelope:
{
"EventType": "MoneyDepositedEvent",
"AggregateId": "<guid>",
"Version": 1,
"OccurredAt": "2026-03-30T...",
"Payload": "{\"Amount\":10}"
}The message also sets ApplicationProperties["EventType"] = "MoneyDepositedEvent" — a header that would allow consumers to filter messages without deserializing the body.
Step 11 — Batch send
All messages are assembled into a ServiceBusMessageBatch and sent in a single call to sender.SendMessagesAsync(batch). The queue now holds the messages waiting for a consumer.
Step 12 — Check for snapshot
repository.LoadAsync(id) first calls CosmosDbSnapshotStore.GetAsync(id) — a direct ReadItemAsync by document ID (O(1) point read). If a snapshot exists, account.LoadFromSnapshot(snapshot) sets Balance and Version without replaying any events.
Step 13 — Replay only post-snapshot events
CosmosDbEventStore.GetEventsAsync(id, fromVersion: snapshot.Version) runs:
SELECT * FROM c WHERE c.aggregateId = @id AND c.version > @fromVersion ORDER BY c.version
Only the events after the snapshot are returned and replayed via account.LoadFromHistory(events).
Step 14 — Polymorphic deserialization
For each EventDocument, DeserializeEvent() does:
Type.GetType("EventSourcingTranning.Events.MoneyDepositedEvent, EventSourcingTranning")
It looks up the C# type at runtime by name, then uses System.Text.Json to deserialize the payload string into a concrete MoneyDepositedEvent object.
┌─────────────────────────────────────────────────────────────────┐
│ Program.cs │
│ BankAccount.Deposit() / Withdrawn() │
│ │ │
│ ▼ │
│ BankAccountRepository.SaveAsync() │
│ │ │
│ ┌────┴──────────────────────────────┐ │
│ │ │ │
│ ▼ ▼ │
│ [1] CosmosDbEventStore [2] CosmosDbSnapshotStore │
│ SaveAsync() SaveAsync() (if ≥50) │
│ INSERT document UPSERT document │
│ │ │
│ ┌────┘ │
│ ▼ │
│ [3] ServiceBusEventPublisher │
│ PublishAsync() │
│ SendMessagesAsync(batch) │
└───────────────────────┬─────────────────────────────────────────┘
│
┌──────────────┴──────────────┐
▼ ▼
┌─────────────┐ ┌────────────────────┐
│ Cosmos DB │ │ Azure Service Bus │
│ (local │ │ (real Azure) │
│ emulator) │ │ │
│ │ │ Queue: │
│ Database: │ │ bank-account- │
│ event- │ │ events │
│ sourcing │ │ │
│ │ │ [msg1][msg2][msg3] │
│ ┌────────┐ │ │ waiting for │
│ │events │ │ │ a consumer │
│ │(append)│ │ └────────────────────┘
│ └────────┘ │
│ ┌─────────┐│
│ │snapshot ││
│ │(upsert) ││
│ └─────────┘│
└─────────────┘
LOAD PATH:
BankAccountRepository.LoadAsync()
│
├──► CosmosDbSnapshotStore.GetAsync() [point read - fast]
│ │
│ snapshot? ──YES──► LoadFromSnapshot() → set Balance + Version
│ │
│ NO──────────► start from version 0
│
└──► CosmosDbEventStore.GetEventsAsync(fromVersion)
│
replay remaining events
│
BankAccount state restored ✓
| Component | File | Responsibility |
|---|---|---|
| BankAccount | Entities/BankAccount.cs |
Domain aggregate. Holds business rules (overdraft check). Applies events to mutate state. Tracks uncommitted changes in _changes. |
| Event | Entities/Event.cs |
Base class for all domain events. Carries a timestamp. |
| MoneyDepositedEvent / MoneyWithdrawnEvent | Events/ |
Concrete facts that something happened. Immutable records of intent. |
| BankAccountRepository | Store/BankAccountRepository.cs |
Orchestrator. Coordinates save (persist → snapshot check → publish) and load (snapshot → replay). The only class that knows all three stores. |
| CosmosDbEventStore | Store/CosmosDbEventStore.cs |
Append-only event log in Cosmos. Handles optimistic concurrency, ordered storage, and polymorphic deserialization of events on read. |
| CosmosDbSnapshotStore | Store/CosmosDbSnapshotStore.cs |
Stores one snapshot per aggregate. Uses upsert — always replaces with the latest. Enables fast load by skipping old events. |
| IEventPublisher | Messaging/IEventPublisher.cs |
Interface. Decouples the repository from the concrete broker. Could be swapped for an in-memory publisher in tests. |
| ServiceBusEventPublisher | Messaging/ServiceBusEventPublisher.cs |
Wraps events in EventEnvelope, serializes to JSON, batches them, and sends to Service Bus queue. |
| EventEnvelope | Messaging/EventEnvelope.cs |
Wire format. Adds EventType, AggregateId, Version, OccurredAt metadata to the raw event payload. |
| BankAccountProjections | Projections/BankAccountProjections.cs |
In-memory read model builder. Exists from the earlier version of the project — not currently wired into the main flow. |
| ConnectionStrings | Config/ConnectionStrings.cs |
Holds both connection strings as constants. (Dev-only pattern.) |
What happens today: The messages sit in the bank-account-events queue. Azure Service Bus has a built-in lock mechanism: when a consumer picks up a message, it becomes invisible to others for a configurable lock duration (default 60 seconds). If the consumer crashes without completing or abandoning, the lock expires and the message becomes visible again. After a configurable max delivery count (default 10), the message moves to the Dead Letter Queue (DLQ).
Current gap: This project has no consumer. The messages are published and just accumulate. There is no dead-letter handling or monitoring.
What happens: Service Bus guarantees at-least-once delivery — not exactly-once. If the publisher sends a batch and the acknowledgement is lost (network blip), the SDK retries and the same messages could be sent twice.
Current gap: There is no deduplication key set on ServiceBusMessage. The MessageId property (which Service Bus uses for duplicate detection on Standard tier) is not populated. Consumers must be idempotent — meaning processing the same event twice must produce the same result.
Cosmos DB unavailable:
CreateDatabaseIfNotExistsAsyncorCreateItemAsyncwill throw aCosmosException(503 or timeout).- There is no retry policy configured on the
CosmosClient. The SDK has some built-in retries, but they are conservative. - The app will crash without a clear user-facing error.
Service Bus unavailable:
SendMessagesAsyncwill throw. Because events were already written to Cosmos, the publish step fails but the data is safe. However, downstream consumers will not be notified.- This creates a silent gap: Cosmos is consistent, Service Bus is not. This is the classic problem the Outbox Pattern solves.
The save-then-publish gap: The code does:
CosmosDbEventStore.SaveAsync()← succeedsCosmosDbSnapshotStore.SaveAsync()← succeedsIEventPublisher.PublishAsync()← CRASHES HERE Events are durable in Cosmos but the Service Bus message was never sent. Consumers never learn the events happened. The event log and the message queue are now out of sync.
The concurrency check gap: The optimistic concurrency check is a COUNT query followed by individual CreateItemAsync calls — not atomic. In theory, two writers could both pass the count check before either inserts, leading to duplicate version numbers.
Snapshot vs. event store divergence: The snapshot is saved after events but in a separate container and a separate Cosmos operation. If the process crashes between inserting events and upserting the snapshot, the next load will replay more events than expected — which is correct and safe, just slower.
| # | Issue | Production Fix |
|---|---|---|
| 1 | Secrets in source code | Move connection strings to environment variables, Azure Key Vault, or appsettings.json + User Secrets. |
| 2 | No message consumer | Implement a ServiceBusProcessor or Azure Function with ServiceBusTrigger to consume and process messages. |
| 3 | Publish gap (no Outbox) | Implement the Transactional Outbox Pattern: store pending messages in Cosmos alongside events, have a background worker poll and publish them reliably. |
| 4 | No idempotency | Set message.MessageId = Guid.NewGuid().ToString() and design consumers to be idempotent (check if event was already processed). |
| 5 | No retry/resilience | Add Polly for retry policies on Cosmos and Service Bus calls; configure CosmosClientOptions.MaxRetryAttemptsOnRateLimitedRequests. |
| 6 | SSL disabled globally | In production use a real certificate; for dev, trust the emulator cert properly instead of disabling all validation. |
| 7 | No dead-letter monitoring | Add alerting when messages land in the Dead Letter Queue (Azure Monitor or Application Insights). |
| 8 | Non-atomic concurrency | Replace the COUNT + INSERT pattern with Cosmos ETags / optimistic concurrency using ItemRequestOptions.IfMatchEtag, or use a Cosmos stored procedure for atomic append. |
| 9 | Upgrade Service Bus tier | Move to Standard tier to unlock Topics + Subscriptions, enabling multiple independent consumers per event type. |
| 10 | No structured logging | Replace Console.WriteLine with Serilog or Microsoft.Extensions.Logging with correlation IDs per aggregate operation. |
- CosmosClient created with Gateway mode (required for emulator)
- SSL validation bypassed for local emulator
- Service Bus queue created if missing (via AdministrationClient)
- Cosmos database + containers created if missing (InitializeAsync)
- Command runs on BankAccount in memory (no I/O)
- Event is applied immediately (state mutates + added to
_changes) - Version increments with every event applied
-
SaveAsync: optimistic concurrency check (COUNTquery) -
SaveAsync: each event inserted as document in Cosmos (append-only) -
SaveAsync: snapshot upserted ifversion - snapshotVersion >= 50 -
SaveAsync: events published to Service Bus queue AFTER Cosmos write -
_changescleared after successful save
-
LoadAsync: point-read snapshot from Cosmos (fast, O(1)) -
LoadAsync: if snapshot found, set Balance + Version from snapshot -
LoadAsync: query only events AFTERsnapshot.Version -
LoadAsync: if no snapshot, query all events from beginning -
LoadAsync: polymorphic deserialization by event type name -
LoadAsync: replay events viaWhen()to rebuild state
- Each event wrapped in
EventEnvelope(type + metadata + payload) - Messages batched with
ServiceBusMessageBatch -
ApplicationProperties["EventType"]set for future filtering - No consumer implemented
- No
MessageIdset for deduplication - No dead-letter handling
- Secrets management
- Retry / resilience policies
- Outbox pattern (publish gap safety)
- Idempotent consumers
- Structured logging
- Dead-letter monitoring
- Why are events applied to the aggregate in memory first, before anything is saved to Cosmos DB? What would go wrong if you wrote to Cosmos before mutating the aggregate state?
BankAccountRepository.SaveAsync()saves to Cosmos first and publishes to Service Bus second. What failure scenario does this ordering protect against, and what failure scenario does it not protect against?- The
EventDocumentclass uses[JsonProperty("id")]fromNewtonsoft.Json, while the event payload is serialized withSystem.Text.Json. Why are two different serializers used, and what would break if you used onlySystem.Text.Jsonfor both? - The Cosmos partition key for the events container is
/aggregateId. What advantage does this give when querying all events for a single bank account? What would happen if you used/idas the partition key instead? - The optimistic concurrency check uses a
COUNTquery followed by individualCreateItemAsynccalls. Why is this not fully atomic, and in what scenario could it allow duplicate version numbers? - Why does
CosmosDbSnapshotStore.SaveAsync()useUpsertItemAsyncinstead ofCreateItemAsync? What would happen if you usedCreateItemAsyncfor snapshots? - When loading a
BankAccount, the code callsGetEventsAsync(id, snapshot.Version)which queriesWHERE c.version > @fromVersion. Why>and not>=? What would happen with>=? - The
EventEnvelopewraps the event payload with anEventTypefield. Why can't a consumer just look at the JSON payload alone to figure out what type of event it received? message.ApplicationProperties["EventType"]is set on every Service Bus message. What is this used for, and why would it be especially valuable if the namespace was upgraded to Standard tier with Topics?- The snapshot threshold is set to 50. If an account has 149 events and a snapshot at version 50, how many events does
LoadAsyncneed to replay? How many would it replay if there were no snapshot at all? - Why does
ServiceBusEventPublisheruse aServiceBusMessageBatchinstead of sending messages one by one? What problem doesTryAddMessagereturning false indicate? - The
DeserializeEventmethod usesType.GetType("EventSourcingTranning.Events.MoneyDepositedEvent, EventSourcingTranning"). What happens at runtime if you rename theMoneyDepositedEventclass or move it to a different namespace, but old documents still reference the old name? - The
IEventPublisherinterface is injected intoBankAccountRepositoryinstead of usingServiceBusEventPublisherdirectly. What are two concrete benefits of this design decision? - The Cosmos DB emulator requires
ServerCertificateCustomValidationCallback = (_, _, _, _) => true. Why would this be dangerous in a production environment, and what should you use instead? - The
_changeslist is cleared at the end ofSaveAsync. What would happen ifClearChanges()was not called after saving, andSaveAsyncwas called a second time on the same aggregate?
If you wanna help improve this training or even teach me talk to me https://www.gabrielgregori.com/