Design PatternsData Management

Event Sourcing: A History Lesson Your Database Actually Wants

OL
Oscar van der Leij
14 min read
Event Sourcing: A History Lesson Your Database Actually Wants

Over the past few years I've repeatedly landed in the same conversation with clients. A project is going well, the architecture is solid, and then someone in the governance or compliance team asks the question: "Can you show us a full audit trail of who changed what, and when?" Sometimes it comes from a security officer doing a pen test review. Sometimes it comes from a regulator. Once it came from a legal team, mid-incident, needing to reconstruct a chain of events before a court deadline. Every time, the answer from the development team was some variation of "we store the current state, so we can see what it is now, but not how it got there."

That gap, between what your system knows now and what actually happened, is exactly the problem Event Sourcing is designed to close. I started recommending it not as an architectural curiosity, but as a practical response to a requirement I kept encountering: full, tamper-evident, queryable history of every state change in the application. This article walks through the pattern, when to use it, and how to implement it in a way you can follow on your own workstation.

The State of Affairs

Traditional databases operate on a simple principle: store the current truth. Your customer's address is "123 Main Street." Update it to "456 Oak Avenue," and poof, "123 Main Street" disappears into the digital ether. This works brilliantly for many scenarios, but it creates a fundamental problem: you've lost context.

Think of it like a whiteboard in a meeting room. A traditional database works the way most whiteboards do: you erase the old content and write the new state. At any moment the board shows one thing, the current truth. But if someone walks in after the meeting and asks what decisions were made, or who changed the plan halfway through, the whiteboard has no answer. Event Sourcing is like replacing that whiteboard with a roll of paper that keeps feeding forward. Nothing gets erased. Every change is written as a new line, in order, with a timestamp and a name. The current state is still visible, but so is everything that led to it.

Event Sourcing flips this model on its head. Instead of storing the current state, you store every state change as an immutable event. Your database becomes a ledger, a chronicle, a time machine. Want to know the current state? Read all the events and replay them. Need to know what happened last Tuesday? Read the events up to that point. Suspicious about a change? The event is right there, timestamped, immutable, and waiting to tell its story.

The pattern treats your application's state as the sum of all events that have occurred, captured in an append-only log. Each event represents a fact, something that happened in the past tense: "CustomerAddressChanged," "OrderPlaced," "PaymentProcessed." These are historical records, as unchangeable as yesterday's weather.

The Event Log Chronicles

Event Sourcing rests on several foundational principles that distinguish it from traditional state management.

Events as the Source of Truth

In Event Sourcing, events are the source of truth. Each event captures:

  • What happened: The business fact (OrderPlaced, InventoryAdjusted)
  • When it happened: Precise timestamp
  • Who made it happen: User or system identifier
  • Relevant details: The data needed to understand and replay the change
public record OrderPlacedEvent
{
    public Guid OrderId { get; init; }
    public Guid CustomerId { get; init; }
    public DateTime OccurredAt { get; init; }
    public decimal TotalAmount { get; init; }
    public required string PlacedBy { get; init; }
}

The Append-Only Log

Your event store is write-only in the sense that events are never updated or deleted. You append new events, period. If you need to correct a mistake, you don't edit the erroneous event; you add a new compensating event.

Made an accounting error? Don't change the original transaction. Add an "AccountingCorrectionApplied" event. This preserves the complete history, including the mistake and its correction, which is often exactly what auditors and regulators want to see.

State Reconstruction Through Replay

Current state is derived, not stored (well, not primarily stored). To determine an order's current status, you replay all events for that order:

public class Order
{
    public Guid Id { get; private set; }
    public OrderStatus Status { get; private set; }
    public decimal TotalAmount { get; private set; }

    public static Order FromEvents(IEnumerable<(string EventType, string EventData, int Version, string OccurredAt)> events)
    {
        var order = new Order();
        foreach (var (eventType, eventData, _, _) in events)
        {
            switch (eventType)
            {
                case nameof(OrderPlacedEvent):
                    var placed = JsonSerializer.Deserialize<OrderPlacedEvent>(eventData)!;
                    order.Id = placed.OrderId;
                    order.Status = OrderStatus.Placed;
                    order.TotalAmount = placed.TotalAmount;
                    break;
                case nameof(OrderShippedEvent):
                    order.Status = OrderStatus.Shipped;
                    break;
                case nameof(OrderCancelledEvent):
                    order.Status = OrderStatus.Cancelled;
                    break;
            }
        }
        return order;
    }
}

Temporal Queries

Because you have the complete history, you can query state at any point in time. What did the customer's profile look like six months ago? Replay events up to that date. This temporal dimension transforms debugging, auditing, and analytics from guesswork into precision.

The Architect's Toolkit

Event Sourcing shines in specific scenarios. Understanding these use cases helps you avoid the trap of applying it everywhere (spoiler: you shouldn't).

Ideal Candidates

Scenario Why Event Sourcing Helps Example
Audit Requirements Complete, immutable history for compliance Financial transactions, healthcare records
Complex Business Processes Track state transitions through multi-step workflows Order fulfillment, loan applications
Temporal Analysis Understand how state evolved over time Pricing history, inventory trends
Debugging Production Issues Replay events to reproduce exact conditions Investigating why an account balance is incorrect
Event-Driven Architecture Events are already core to your design Microservices communicating via events

When to Think Twice

Event Sourcing adds complexity. Skip it when:

  • Your domain is simple CRUD with no audit requirements
  • You don't need historical state
  • Your team lacks experience with event-driven patterns
  • Performance of simple queries is critical (event replay has overhead)
  • You're dealing with frequently updated aggregates with thousands of events

The Snapshot Strategy

Replaying 10,000 events every time you need current state gets expensive. Enter snapshots: periodic captures of current state that serve as replay starting points.

public class OrderSnapshot
{
    public Guid OrderId { get; init; }
    public int EventVersion { get; init; }  // last event included in this snapshot
    public OrderStatus Status { get; init; }
    public decimal TotalAmount { get; init; }
    public DateTime SnapshotCreatedAt { get; init; }
}

public class OrderRepository
{
    public Order GetOrder(Guid orderId)
    {
        var snapshot = _snapshotStore.GetLatestSnapshot(orderId);
        var events = _eventStore.GetEvents(orderId, fromVersion: snapshot.EventVersion);
        var order = Order.FromSnapshot(snapshot);
        foreach (var @event in events)
            order.Apply(@event);
        return order;
    }
}

Snapshot every 100 events, or every hour, or based on aggregate size. The strategy depends on your read patterns and performance requirements.

Rolling Up Your Sleeves

The following steps walk you through building a minimal but complete Event Sourcing implementation in C# on your local machine. You need the .NET 9 SDK installed. No cloud account or external service is required at this stage.

Step 1: Create the project

Open a terminal and run:

dotnet new console -n EventSourcingDemo
cd EventSourcingDemo
dotnet add package Microsoft.Data.Sqlite --version 10.0.8
dotnet add package Dapper --version 2.1.79

This gives you a console app with a local SQLite database as the event store. SQLite requires no server and stores everything in a single file, which makes it ideal for learning the pattern before introducing a production-grade store.

Step 2: Define your events

Create a file Events.cs. Events are the contract you make with the future: once written, they never change. Name them in the past tense and include everything a reader needs to understand what happened.

// Events.cs
public record OrderPlacedEvent
{
    public Guid OrderId { get; init; }
    public Guid CustomerId { get; init; }
    public DateTime OccurredAt { get; init; }
    public decimal TotalAmount { get; init; }
    public required string PlacedBy { get; init; }
}

public record OrderShippedEvent
{
    public Guid OrderId { get; init; }
    public DateTime OccurredAt { get; init; }
    public required string TrackingNumber { get; init; }
}

public record OrderCancelledEvent
{
    public Guid OrderId { get; init; }
    public DateTime OccurredAt { get; init; }
    public required string Reason { get; init; }
    public required string CancelledBy { get; init; }
}

Notice that CustomerUpdatedEvent does not appear here. A generic "something changed" event loses business meaning and makes projections much harder to build later.

Step 3: Create the event store table

Create a file EventStore.cs. The store is an append-only table: rows are inserted, never updated or deleted.

// EventStore.cs
using Microsoft.Data.Sqlite;
using Dapper;
using System.Text.Json;

public class EventStore
{
    private readonly string _connectionString;

    public EventStore(string connectionString)
    {
        _connectionString = connectionString;
        InitialiseSchema();
    }

    private void InitialiseSchema()
    {
        using var conn = new SqliteConnection(_connectionString);
        conn.Execute(@"
            CREATE TABLE IF NOT EXISTS EventStore (
                EventId     TEXT PRIMARY KEY,
                AggregateId TEXT NOT NULL,
                EventType   TEXT NOT NULL,
                EventData   TEXT NOT NULL,
                EventVersion INTEGER NOT NULL,
                OccurredAt  TEXT NOT NULL,
                UserId      TEXT
            );
            CREATE INDEX IF NOT EXISTS IX_Aggregate
                ON EventStore (AggregateId, EventVersion);
        ");
    }

    public void Append(string aggregateId, object @event, string? userId = null)
    {
        using var conn = new SqliteConnection(_connectionString);

        var nextVersion = conn.ExecuteScalar<int>(
            "SELECT COALESCE(MAX(EventVersion), 0) + 1 FROM EventStore WHERE AggregateId = @aggregateId",
            new { aggregateId });

        conn.Execute(@"
            INSERT INTO EventStore (EventId, AggregateId, EventType, EventData, EventVersion, OccurredAt, UserId)
            VALUES (@EventId, @AggregateId, @EventType, @EventData, @EventVersion, @OccurredAt, @UserId)",
            new
            {
                EventId = Guid.NewGuid().ToString(),
                AggregateId = aggregateId,
                EventType = @event.GetType().Name,
                EventData = JsonSerializer.Serialize(@event),
                EventVersion = nextVersion,
                OccurredAt = DateTime.UtcNow.ToString("o"),
                UserId = userId
            });
    }

    public IEnumerable<(string EventType, string EventData, int Version, string OccurredAt)> GetEvents(string aggregateId)
    {
        using var conn = new SqliteConnection(_connectionString);
        return conn.Query<(string, string, int, string)>(
            "SELECT EventType, EventData, EventVersion, OccurredAt FROM EventStore WHERE AggregateId = @aggregateId ORDER BY EventVersion",
            new { aggregateId });
    }
}

Step 4: Build the aggregate with state reconstruction

Create a file Order.cs. The aggregate does not store state in the database; it rebuilds state by replaying its events.

// Order.cs
using System.Text.Json;

public enum OrderStatus { Placed, Shipped, Cancelled }

public class Order
{
    public Guid Id { get; private set; }
    public OrderStatus Status { get; private set; }
    public decimal TotalAmount { get; private set; }

    public static Order FromEvents(IEnumerable<(string EventType, string EventData, int Version, string OccurredAt)> events)
    {
        var order = new Order();
        foreach (var (eventType, eventData, _, _) in events)
        {
            switch (eventType)
            {
                case nameof(OrderPlacedEvent):
                    var placed = JsonSerializer.Deserialize<OrderPlacedEvent>(eventData)!;
                    order.Id = placed.OrderId;
                    order.Status = OrderStatus.Placed;
                    order.TotalAmount = placed.TotalAmount;
                    break;
                case nameof(OrderShippedEvent):
                    order.Status = OrderStatus.Shipped;
                    break;
                case nameof(OrderCancelledEvent):
                    order.Status = OrderStatus.Cancelled;
                    break;
            }
        }
        return order;
    }
}

Step 5: Wire it up in Program.cs

Replace the contents of Program.cs with the following. This simulates placing, shipping, and then attempting to cancel an already-shipped order.

// Program.cs
var store = new EventStore("Data Source=events.db");

var orderId = Guid.NewGuid().ToString();
var customerId = Guid.NewGuid().ToString();

// Place an order
store.Append(orderId, new OrderPlacedEvent
{
    OrderId = Guid.Parse(orderId),
    CustomerId = Guid.Parse(customerId),
    OccurredAt = DateTime.UtcNow,
    TotalAmount = 149.99m,
    PlacedBy = "alice@example.com"
}, userId: "alice@example.com");

Console.WriteLine("Order placed.");

// Ship the order
store.Append(orderId, new OrderShippedEvent
{
    OrderId = Guid.Parse(orderId),
    OccurredAt = DateTime.UtcNow,
    TrackingNumber = "TRK-20260608-001"
}, userId: "warehouse-system");

Console.WriteLine("Order shipped.");

// Reconstruct state and check before attempting cancel
var events = store.GetEvents(orderId);
var order = Order.FromEvents(events);

Console.WriteLine($"\nCurrent order status: {order.Status}");

if (order.Status == OrderStatus.Shipped)
{
    Console.WriteLine("Cannot cancel: order already shipped. A compensating event would be appended instead.");
}

// Print the full audit trail directly from the event store
Console.WriteLine("\n--- Audit Trail ---");
foreach (var (eventType, _, version, occurredAt) in store.GetEvents(orderId))
{
    Console.WriteLine($"v{version}  {occurredAt}  {eventType}");
}

Run the application:

dotnet run

You should see output like:

Order placed.
Order shipped.

Current order status: Shipped
Cannot cancel: order already shipped. A compensating event would be appended instead.

--- Audit Trail ---
v1  2026-06-08T09:14:22.000Z  OrderPlacedEvent
v2  2026-06-08T09:14:22.001Z  OrderShippedEvent

The audit trail comes directly from the event store. There is no separate logging system, no trigger, no after-the-fact reconstruction: the history is the data.

Step 6: Add a read model with a projection

Reading from the event log on every query gets expensive at scale. Build a projection: a separate, denormalised table that stays in sync with the event stream and answers read queries instantly.

Add a Projection.cs file:

// Projection.cs
using Microsoft.Data.Sqlite;
using Dapper;
using System.Text.Json;

public class OrderSummaryProjection
{
    private readonly string _connectionString;


    public OrderSummaryProjection(string connectionString)
    {
        _connectionString = connectionString;
        using var conn = new SqliteConnection(connectionString);
        conn.Execute(@"
            CREATE TABLE IF NOT EXISTS OrderSummary (
                OrderId     TEXT PRIMARY KEY,
                CustomerId  TEXT NOT NULL,
                Status      TEXT NOT NULL,
                TotalAmount REAL NOT NULL,
                LastUpdated TEXT NOT NULL
            )");
    }


    public void Project(string eventType, string eventData)
    {
        using var conn = new SqliteConnection(_connectionString);
        switch (eventType)
        {
            case nameof(OrderPlacedEvent):
                var placed = JsonSerializer.Deserialize<OrderPlacedEvent>(eventData)!;
                conn.Execute(@"
                    INSERT OR REPLACE INTO OrderSummary (OrderId, CustomerId, Status, TotalAmount, LastUpdated)
                    VALUES (@OrderId, @CustomerId, 'Placed', @TotalAmount, @Now)",
                    new { OrderId = placed.OrderId.ToString(), CustomerId = placed.CustomerId.ToString(), placed.TotalAmount, Now = DateTime.UtcNow.ToString("o") });
                break;
            case nameof(OrderShippedEvent):
                var shipped = JsonSerializer.Deserialize<OrderShippedEvent>(eventData)!;
                conn.Execute("UPDATE OrderSummary SET Status = 'Shipped', LastUpdated = @Now WHERE OrderId = @OrderId",
                    new { OrderId = shipped.OrderId.ToString(), Now = DateTime.UtcNow.ToString("o") });
                break;
            case nameof(OrderCancelledEvent):
                var cancelled = JsonSerializer.Deserialize<OrderCancelledEvent>(eventData)!;
                conn.Execute("UPDATE OrderSummary SET Status = 'Cancelled', LastUpdated = @Now WHERE OrderId = @OrderId",
                    new { OrderId = cancelled.OrderId.ToString(), Now = DateTime.UtcNow.ToString("o") });
                break;
        }
    }
}

Now replace Program.cs with a version that wires up the projection alongside the event store:

// Program.cs
using Dapper;
using Microsoft.Data.Sqlite;

var store = new EventStore("Data Source=events.db");
var projection = new OrderSummaryProjection("Data Source=events.db");

var orderId = Guid.NewGuid().ToString();
var customerId = Guid.NewGuid().ToString();

void AppendAndProject(string aggregateId, object @event, string? userId = null)
{
    store.Append(aggregateId, @event, userId);
    projection.Project(@event.GetType().Name, System.Text.Json.JsonSerializer.Serialize(@event));
}

AppendAndProject(orderId, new OrderPlacedEvent
{
    OrderId = Guid.Parse(orderId),
    CustomerId = Guid.Parse(customerId),
    OccurredAt = DateTime.UtcNow,
    TotalAmount = 149.99m,
    PlacedBy = "alice@example.com"
}, userId: "alice@example.com");

Console.WriteLine("Order placed.");

AppendAndProject(orderId, new OrderShippedEvent
{
    OrderId = Guid.Parse(orderId),
    OccurredAt = DateTime.UtcNow,
    TrackingNumber = "TRK-20260608-001"
}, userId: "warehouse-system");

Console.WriteLine("Order shipped.");

// Reconstruct state from events and check before attempting cancel
var events = store.GetEvents(orderId);
var order = Order.FromEvents(events);

Console.WriteLine($"\nCurrent order status: {order.Status}");

if (order.Status == OrderStatus.Shipped)
{
    Console.WriteLine("Cannot cancel: order already shipped. A compensating event would be appended instead.");
}

// Write side: full audit trail from the event store
Console.WriteLine("\n--- Audit Trail (event store) ---");
foreach (var (eventType, _, version, occurredAt) in store.GetEvents(orderId))
{
    Console.WriteLine($"v{version}  {occurredAt}  {eventType}");
}

// Read side: current state from the projection table
using var conn = new SqliteConnection("Data Source=events.db");
var summary = conn.QueryFirst(
    "SELECT OrderId, Status, TotalAmount, LastUpdated FROM OrderSummary WHERE OrderId = @orderId",
    new { orderId });

Console.WriteLine("\n--- Read Model (projection) ---");
Console.WriteLine($"OrderId:     {summary.OrderId}");
Console.WriteLine($"Status:      {summary.Status}");
Console.WriteLine($"TotalAmount: {summary.TotalAmount}");
Console.WriteLine($"LastUpdated: {summary.LastUpdated}");

Run dotnet run and you will see both sections printed. The audit trail comes from the append-only event store; the read model comes from the OrderSummary projection table. Same data, two different shapes. That is CQRS in practice.

The AppendAndProject helper keeps them in sync in a single call here. In a production system this fan-out would be driven by a message broker or an event bus rather than an in-process helper, but for a local demo this is the most direct way to observe both sides together.

The Payoff

Event Sourcing transforms how you think about data persistence:

  • Capture complete history by storing every state change as an immutable event, giving you a full audit trail and the ability to reconstruct state at any point in time
  • Embrace events as truth rather than treating current state as the source of authority, enabling temporal queries and perfect debugging capabilities
  • Apply strategically to domains with complex workflows, audit requirements, or temporal analysis needs, but avoid over-engineering simple CRUD scenarios
  • Plan for projections to maintain read performance, accepting eventual consistency between your event store and read models

The question that should guide your decision is the one I now ask at the start of every project: "Will anyone ever need to prove what happened, who did it, and when?" If the answer is yes, or even maybe, Event Sourcing is worth the additional complexity. The audit trail is a structural property of the system, and retrofitting it onto a CRUD database after the fact is far more expensive than building it in from the start.

What aspects of your system's history are you currently losing? And more importantly, what will it cost you when you need that history and it is not there?

Share this article

Related articles

Bulkheads: Because One Sinking Service Shouldn't Sink Them All
Design PatternsResilience

Bulkheads: Because One Sinking Service Shouldn't Sink Them All

I watched one misconfigured service take down an entire platform because everything shared the same resource pools. Here is the bulkhead pattern that prevents it, with a working .NET 8 demo you can run locally in minutes.

10 min read
Spec-Driven Development: Let AI Read the Boring Stuff For You
Architecture PracticesDesign Patterns

Spec-Driven Development: Let AI Read the Boring Stuff For You

Most teams consult specifications when things break. Spec-driven development turns that around, making the spec the source of truth before a single line of code is written. This post covers the four pillars of SDD, a step-by-step Claude Code walkthrough using FHIR validation, the current tooling landscape, and an honest look at where the practice still falls short.

22 min read
Migrate or Cry Trying: How to Move Data Without the Drama
ModernizationData Management

Migrate or Cry Trying: How to Move Data Without the Drama

It started with one of the tasks on our project kanban board: "Data migration". We were building a new application for a telecom provider. The goal was to unify two old systems into a single custom-built platform. Development was already in motion. Sprints were delivering new features. But no one had touched the data. The legacy systems had mismatched schemas, overlapping records, and different definitions for the same business terms. Some orders were in one system, but not the other. Customer records had conflicting states. Worse, every new feature we built had to be fed by clean, compatible data. Any mismatch would break logic or trigger errors in production.

6 min read

Enjoyed this article?

Subscribe to get more insights delivered to your inbox monthly

Subscribe to Newsletter