Event sourcing means storing every change to your application’s state as a sequence of immutable events, rather than just the current state.

Let’s say we have a simple shopping cart.

Instead of just storing the final state like:

{
  "CartId": "abc-123",
  "Items": [
    {"ProductId": "prod-A", "Quantity": 2},
    {"ProductId": "prod-B", "Quantity": 1}
  ],
  "Status": "Active"
}

With event sourcing, we store the actions that led to this state:

  1. CartCreated { CartId: "abc-123" }
  2. ItemAdded { CartId: "abc-123", ProductId: "prod-A", Quantity: 2 }
  3. ItemAdded { CartId: "abc-123", ProductId: "prod-B", Quantity: 1 }
  4. CartCheckedOut { CartId: "abc-123" } (This might change the status to "CheckedOut")

To get the current state of the cart, we "replay" these events from the beginning.

Here’s a simplified C# representation of what this looks like:

// The event itself
public abstract record DomainEvent
{
    public Guid AggregateId { get; init; }
    public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
}

public record CartCreated(Guid CartId) : DomainEvent;
public record ItemAdded(Guid CartId, string ProductId, int Quantity) : DomainEvent;
public record ItemRemoved(Guid CartId, string ProductId) : DomainEvent;
public record CartCheckedOut(Guid CartId) : DomainEvent;

// The aggregate root (our shopping cart)
public class ShoppingCart
{
    public Guid Id { get; private set; }
    private readonly List<DomainEvent> _uncommittedEvents = new();
    private readonly Dictionary<string, int> _items = new();
    public bool IsCheckedOut { get; private set; } = false;

    public IEnumerable<DomainEvent> UncommittedEvents => _uncommittedEvents.AsReadOnly();

    public ShoppingCart(Guid id)
    {
        Apply(new CartCreated(id));
    }

    // Private constructor for loading from events
    private ShoppingCart() { }

    public void AddItem(string productId, int quantity)
    {
        if (IsCheckedOut) throw new InvalidOperationException("Cannot add items to a checked-out cart.");
        if (quantity <= 0) throw new ArgumentOutOfRangeException(nameof(quantity), "Quantity must be positive.");

        var @event = new ItemAdded(Id, productId, quantity);
        Apply(@event);
        _uncommittedEvents.Add(@event);
    }

    public void RemoveItem(string productId)
    {
        if (IsCheckedOut) throw new InvalidOperationException("Cannot remove items from a checked-out cart.");
        if (!_items.ContainsKey(productId)) return; // Or throw

        var @event = new ItemRemoved(Id, productId);
        Apply(@event);
        _uncommittedEvents.Add(@event);
    }

    public void Checkout()
    {
        if (IsCheckedOut) return; // Already checked out

        var @event = new CartCheckedOut(Id);
        Apply(@event);
        _uncommittedEvents.Add(@event);
    }

    // This is the core of event sourcing: applying events to change state
    private void Apply(DomainEvent @event)
    {
        switch (@event)
        {
            case CartCreated created:
                Id = created.CartId;
                break;
            case ItemAdded added:
                _items.TryGetValue(added.ProductId, out var currentQuantity);
                _items[added.ProductId] = currentQuantity + added.Quantity;
                break;
            case ItemRemoved removed:
                _items.Remove(removed.ProductId);
                break;
            case CartCheckedOut checkedOut:
                IsCheckedOut = true;
                break;
        }
    }

    // Method to load state from a stream of events
    public void LoadFromEvents(IEnumerable<DomainEvent> history)
    {
        foreach (var @event in history)
        {
            Apply(@event);
        }
    }

    public int GetItemQuantity(string productId) => _items.TryGetValue(productId, out var quantity) ? quantity : 0;
}

The EventStore is where these events live. It’s responsible for appending new events and retrieving the history of events for a given aggregate.

// A very basic in-memory event store
public class InMemoryEventStore
{
    private readonly Dictionary<Guid, List<DomainEvent>> _store = new();

    public void AppendEvents(Guid aggregateId, IEnumerable<DomainEvent> events)
    {
        if (!_store.ContainsKey(aggregateId))
        {
            _store[aggregateId] = new List<DomainEvent>();
        }
        _store[aggregateId].AddRange(events);
        Console.WriteLine($"Appended {events.Count()} events for aggregate {aggregateId}.");
    }

    public IEnumerable<DomainEvent> GetEvents(Guid aggregateId)
    {
        if (_store.TryGetValue(aggregateId, out var events))
        {
            return events.OrderBy(e => e.Timestamp).ToList(); // Ensure chronological order
        }
        return Enumerable.Empty<DomainEvent>();
    }
}

Here’s how you’d use it:

var eventStore = new InMemoryEventStore();
var cartId = Guid.NewGuid();

// Create a new cart - this generates a CartCreated event
var cart = new ShoppingCart(cartId);
eventStore.AppendEvents(cartId, cart.UncommittedEvents);
cart.ClearUncommittedEvents(); // In a real scenario, you'd clear after successful persistence

// Simulate loading the cart later
var loadedCart = new ShoppingCart(); // Need a way to initialize with ID or load directly
loadedCart.Id = cartId; // Or a private constructor that takes ID.
loadedCart.LoadFromEvents(eventStore.GetEvents(cartId));

// Add items to the loaded cart
loadedCart.AddItem("prod-X", 3);
loadedCart.AddItem("prod-Y", 1);
eventStore.AppendEvents(cartId, loadedCart.UncommittedEvents);
loadedCart.ClearUncommittedEvents();

// Checkout
loadedCart.Checkout();
eventStore.AppendEvents(cartId, loadedCart.UncommittedEvents);
loadedCart.ClearUncommittedEvents();

// Replay to get the final state
var finalCart = new ShoppingCart();
finalCart.Id = cartId;
finalCart.LoadFromEvents(eventStore.GetEvents(cartId));

Console.WriteLine($"Final cart state: IsCheckedOut = {finalCart.IsCheckedOut}, Item prod-X quantity = {finalCart.GetItemQuantity("prod-X")}");

The most surprising thing about event sourcing is that it fundamentally shifts your thinking from "what is the state now?" to "what happened to get here?". This makes auditing, debugging, and understanding the history of your data incredibly straightforward, as the event log is a complete, immutable record of all state changes.

The Apply method is the heart of the aggregate. It’s a pure function that takes an event and transitions the aggregate’s internal state accordingly. It’s crucial that Apply only modifies the aggregate’s state and does not perform side effects or throw exceptions based on the event itself (though it can throw exceptions if the event is invalid for the current state, like adding to a checked-out cart).

The EventStore can be much more sophisticated. It might use a database like PostgreSQL (with JSONB for events), a dedicated event store like EventStoreDB, or even distributed logs like Kafka. The key is that it provides atomic appends and reliable retrieval of event streams.

When you load an aggregate, you’re not fetching a single row from a database; you’re fetching a sequence of events and replaying them. This "rehydration" process can become a performance bottleneck with very long event streams. Techniques like snapshotting (periodically saving the aggregate’s state at a specific event version) are used to mitigate this, allowing you to load the latest snapshot and then replay only the events that occurred after that snapshot.

The next concept you’ll likely encounter is CQRS (Command Query Responsibility Segregation), which often goes hand-in-hand with event sourcing.

Want structured learning?

Take the full Csharp course →