Event-driven design has gained tremendous popularity for building scalable, maintainable, and resilient systems. Event Sourcing is a powerful pattern within this space that stores application state as a sequence of immutable events rather than overwriting data directly.
In this post, we’ll explore how to build event-sourced systems using .NET Core, introduce EventStoreDB as an event store solution, and look at practical code examples demonstrating this architecture.
What is Event Sourcing?
Traditional applications typically persist the current state of entities in a database (e.g., updating rows). In contrast, Event Sourcing stores a series of events that represent every state change that has occurred.
For example, instead of storing a user’s current profile data, you store events like:
UserCreated
UserEmailUpdated
UserAddressChanged
These events are appended to an immutable event log, which can be replayed to rebuild the current state at any time.
Benefits of Event Sourcing
- Auditability: Full history of changes is preserved.
- Flexibility: New projections or views can be created by replaying events.
- Scalability: Append-only event stores are highly performant.
- Resilience: Easier to implement CQRS and reactive architectures.
Introducing EventStoreDB
EventStoreDB is a popular open-source database designed specifically for event sourcing. It provides:
- Append-only immutable event streams.
- Efficient event querying.
- Support for subscriptions and projections.
- Strong consistency guarantees.
Building an Event-Sourced System in .NET Core
Let’s build a simple example: an event-sourced Order system.
1. Defining Events
We’ll start by defining domain events representing order actions:
public interface IEvent {}
public record OrderCreated(Guid OrderId, DateTime CreatedAt) : IEvent;
public record ItemAdded(Guid OrderId, string ProductName, int Quantity) : IEvent;
public record OrderCancelled(Guid OrderId, DateTime CancelledAt) : IEvent;
2. The Aggregate Root
Our Order
aggregate replays events to reconstruct its state:
public class Order
{
private readonly List<IEvent> _changes = new();
private readonly List<Item> _items = new();
public Guid Id { get; private set; }
public bool IsCancelled { get; private set; }
public IReadOnlyList<IEvent> GetUncommittedChanges() => _changes.AsReadOnly();
public void MarkChangesAsCommitted() => _changes.Clear();
public Order(IEnumerable<IEvent> eventStream)
{
foreach (var @event in eventStream)
Apply(@event);
}
public static Order Create(Guid id)
{
var order = new Order(Enumerable.Empty<IEvent>());
order.ApplyChange(new OrderCreated(id, DateTime.UtcNow));
return order;
}
public void AddItem(string productName, int quantity)
{
if (IsCancelled) throw new InvalidOperationException("Order is cancelled");
ApplyChange(new ItemAdded(Id, productName, quantity));
}
public void Cancel()
{
if (IsCancelled) throw new InvalidOperationException("Order already cancelled");
ApplyChange(new OrderCancelled(Id, DateTime.UtcNow));
}
private void ApplyChange(IEvent @event)
{
Apply(@event);
_changes.Add(@event);
}
private void Apply(IEvent @event)
{
switch (@event)
{
case OrderCreated e:
Id = e.OrderId;
IsCancelled = false;
break;
case ItemAdded e:
_items.Add(new Item { ProductName = e.ProductName, Quantity = e.Quantity });
break;
case OrderCancelled e:
IsCancelled = true;
break;
}
}
private class Item
{
public string ProductName { get; set; }
public int Quantity { get; set; }
}
}
3. Persisting Events with EventStoreDB
We can persist and retrieve events from EventStoreDB using its .NET client.
public class OrderRepository
{
private readonly EventStoreClient _client;
public OrderRepository(EventStoreClient client)
{
_client = client;
}
public async Task SaveAsync(Order order)
{
var events = order.GetUncommittedChanges()
.Select(e => new EventData(
Uuid.NewUuid(),
e.GetType().Name,
JsonSerializer.SerializeToUtf8Bytes(e)
));
await _client.AppendToStreamAsync(
$"order-{order.Id}",
StreamState.Any,
events
);
order.MarkChangesAsCommitted();
}
public async Task<Order> GetByIdAsync(Guid orderId)
{
var events = new List<IEvent>();
var result = _client.ReadStreamAsync(
Direction.Forwards,
$"order-{orderId}",
StreamPosition.Start
);
await foreach (var resolvedEvent in result)
{
var eventType = resolvedEvent.Event.EventType;
var data = resolvedEvent.Event.Data.ToArray();
IEvent @event = eventType switch
{
nameof(OrderCreated) => JsonSerializer.Deserialize<OrderCreated>(data),
nameof(ItemAdded) => JsonSerializer.Deserialize<ItemAdded>(data),
nameof(OrderCancelled) => JsonSerializer.Deserialize<OrderCancelled>(data),
_ => throw new InvalidOperationException("Unknown event type")
};
events.Add(@event);
}
return new Order(events);
}
}
4. Using the Repository in Your Application
var repository = new OrderRepository(eventStoreClient);
// Create order
var order = Order.Create(Guid.NewGuid());
order.AddItem("Laptop", 1);
await repository.SaveAsync(order);
// Later: load order and add item
var loadedOrder = await repository.GetByIdAsync(order.Id);
loadedOrder.AddItem("Mouse", 2);
await repository.SaveAsync(loadedOrder);
Summary and Next Steps
Event Sourcing in .NET Core offers an elegant way to build audit-friendly, scalable, and maintainable systems. With EventStoreDB, persisting events is straightforward, allowing replay and projections for different views.
This approach pairs very well with CQRS and microservices, enabling event-driven communication and eventual consistency.
Next steps: Explore projections, subscribing to event streams, and integrating with messaging systems like Kafka or Azure Event Grid.