Building an Event Sourcing Architecture with .NET 8, Azure Cosmos DB, and MediatR

Mahmut Sarıkaya 5 dk okuma 13 Görüntülenme 0
Building an Event Sourcing Architecture with .NET 8, Azure Cosmos DB, and MediatR

Why Event Sourcing Matters

Imagine a financial system that can replay every transaction from day one to diagnose a discrepancy. Event sourcing makes that possible by treating every state change as an immutable event. A 2023 survey by the Cloud Native Computing Foundation showed that 42% of enterprises adopting event-driven architectures reported faster incident resolution, precisely because they could trace the exact sequence of events.

In a .NET environment, event sourcing aligns naturally with domain‑driven design. It separates write concerns from read concerns, paving the way for CQRS, and it gives you a reliable audit log without extra plumbing.

Setting Up .NET 8 and Azure Cosmos DB

Before writing code, ensure your development machine runs .NET 8 SDK (released November 2023) and you have an Azure subscription. Create a Cosmos DB account with the Core (SQL) API; the default RU/s of 400 is enough for a proof‑of‑concept.

Use the following CLI commands to scaffold a new solution and add required packages:

dotnet new sln -n EventSourcingDemo && cd EventSourcingDemo && dotnet new webapi -n Api && dotnet new classlib -n Domain && dotnet sln add **/*.csproj && dotnet add Api/Api.csproj reference Domain/Domain.csproj && dotnet add Api package Microsoft.Azure.Cosmos && dotnet add Api package MediatR.Extensions.Microsoft.DependencyInjection && dotnet add Domain package MediatR

This creates a clean separation: the API layer will host MediatR handlers, while the Domain project holds aggregates and event definitions.

Modeling Events and Aggregates

Start with a simple aggregate, for example a BankAccount. Every change is expressed as an event class that implements a marker interface IEvent. Use generic lists of Event<T> to store untyped events.

public interface IEvent { DateTimeOffset Timestamp { get; } } <br/> public record MoneyDeposited(Guid AccountId, decimal Amount, DateTimeOffset Timestamp) : IEvent; <br/> public record MoneyWithdrawn(Guid AccountId, decimal Amount, DateTimeOffset Timestamp) : IEvent; <br/> public class BankAccount { public Guid Id { get; private set; } public decimal Balance { get; private set; } private readonly List<IEvent> _changes = new(); <br/> public BankAccount(Guid id) { Id = id; } <br/> public void Apply(IEvent @event) { switch(@event) { case MoneyDeposited d: Balance += d.Amount; break; case MoneyWithdrawn w: Balance -= w.Amount; break; } _changes.Add(@event); } <br/> public IReadOnlyCollection<IEvent> GetUncommittedChanges() => _changes.AsReadOnly(); <br/> public void ClearUncommittedChanges() => _changes.Clear(); }

The Apply method rehydrates the aggregate from stored events, while GetUncommittedChanges returns events ready for persistence.

Integrating MediatR for CQRS

MediatR decouples command handling from the API controller. Define a command that represents the intent to deposit money, then let a handler invoke the aggregate and persist the resulting events.

public record DepositMoneyCommand(Guid AccountId, decimal Amount) : IRequest; <br/> public class DepositMoneyHandler : IRequestHandler<DepositMoneyCommand> { private readonly IEventStore _store; public DepositMoneyHandler(IEventStore store) { _store = store; } public async Task<Unit> Handle(DepositMoneyCommand request, CancellationToken cancellationToken) { var account = await _store.LoadAsync(request.AccountId, cancellationToken) ?? new BankAccount(request.AccountId); var @event = new MoneyDeposited(request.AccountId, request.Amount, DateTimeOffset.UtcNow); account.Apply(@event); await _store.SaveAsync(account, cancellationToken); return Unit.Value; } }

Register MediatR in Program.cs with builder.Services.AddMediatR(typeof(DepositMoneyHandler).Assembly);. The same pattern works for query objects that read from a projection built by a separate read model.

Persisting Events in Cosmos DB

Implement a lightweight IEventStore that writes each event as a separate document. Cosmos DB’s partition key should be the aggregate identifier to keep all events of an account together.

public interface IEventStore { Task<BankAccount?> LoadAsync(Guid id, CancellationToken ct); Task SaveAsync(BankAccount aggregate, CancellationToken ct); } <br/> public class CosmosEventStore : IEventStore { private readonly Container _container; public CosmosEventStore(CosmosClient client, string databaseId, string containerId) { _container = client.GetContainer(databaseId, containerId); } public async Task<BankAccount?> LoadAsync(Guid id, CancellationToken ct) { var query = new QueryDefinition("SELECT * FROM c WHERE c.accountId = @id ORDER BY c.timestamp") .WithParameter("@id", id.ToString()); var iterator = _container.GetItemQueryIterator<IEvent>(query); var events = new List<IEvent>(); while (iterator.HasMoreResults) { foreach (var e in await iterator.ReadNextAsync(ct)) { events.Add(e); } } if (!events.Any()) return null; var account = new BankAccount(id); foreach (var e in events) account.Apply(e); return account; } public async Task SaveAsync(BankAccount aggregate, CancellationToken ct) { foreach (var e in aggregate.GetUncommittedChanges()) { var doc = new { id = Guid.NewGuid().ToString(), accountId = aggregate.Id.ToString(), timestamp = e.Timestamp, type = e.GetType().Name, payload = e }; await _container.CreateItemAsync(doc, new PartitionKey(aggregate.Id.ToString()), cancellationToken: ct); } aggregate.ClearUncommittedChanges(); } }

Notice the use of Guid.NewGuid() for the document id and the explicit partition key. With a provisioned throughput of 400 RU/s, a typical write of 5 events costs roughly 0.02 USD per 1 000 requests, according to Azure pricing tables.

Testing and Scaling Tips

Unit‑test your handlers by injecting an in‑memory IEventStore implementation that stores events in a List<IEvent>. Verify that the aggregate’s balance matches the expected value after a sequence of commands.

When you move to production, enable Cosmos DB’s Change Feed to build read‑side projections automatically. Combine it with Azure Functions to keep the write model lean while the read model scales horizontally. Monitoring RU consumption via Azure Monitor helps you adjust throughput before you hit throttling limits.

Conclusion

By coupling .NET 8’s performance improvements with Azure Cosmos DB’s globally distributed storage and MediatR’s clean CQRS pipeline, you can build an event‑sourced system that is both auditable and highly responsive. Start with a single aggregate, evolve your event schema using versioned records, and let the Change Feed keep your queries fast. The result is a resilient architecture that scales with the same code base you already use for classic CRUD applications.

Author: Mahmut Sarıkaya — sarikayadev.com

Sources

Microsoft Docs – Azure Cosmos DB SQL API
Microsoft Docs – MediatR Integration for .NET
Cloud Native Computing Foundation 2023 Survey Report

Etiketler: #.NET 8 #event sourcing #Azure Cosmos DB #MediatR #CQRS
Paylaş:
M

Yazar

Mahmut Sarıkaya

yazılım Geliştirici

Yorumlar

Henüz yorum yok. İlk yorumu siz yapın!

Yorum Bırakın

7 + 1 =