Design PatternsData Management

Event Sourcing: A History Lesson Your Database Actually Wants

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

A senior architect at a healthcare platform was staring at her laptop screen at 2 AM, trying to explain to an auditor why a patient's prescription history showed conflicting records. The database had the current state: Patient X was prescribed Medication Y. But somewhere between the initial prescription, the dosage adjustment, and the insurance override, the trail went cold. The auditor wanted to know who changed what, when, and why. The architect had a sinking feeling as she realized their traditional CRUD database was essentially shrugging its shoulders, saying "I dunno, this is what we have now."

This scenario plays out countless times across industries: financial services trying to reconstruct how an account balance reached a certain number, e-commerce platforms attempting to understand why an order shows "cancelled" when the customer swears they never cancelled it, or compliance teams desperately seeking an audit trail that simply doesn't exist. When you store only the current state of your data, you're essentially burning your history books after every update. And as our architect discovered at 2 AM, sometimes you really, really need those history books.

The State of Affairs (Understanding the Problem)

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 diplomatic envoy traveling between countries. If you only track where the envoy is right now, you know their current location. But if you need to understand their journey, investigate a security breach at one of the stops, or prove they were never in a particular country, you're out of luck. You need the itinerary, not just the current hotel.

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 aren't requests or commands; they're historical records, as unchangeable as yesterday's weather.

The Event Log Chronicles (Core Concepts)

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

Events as First-Class Citizens

In Event Sourcing, events aren't side effects or notifications. They're 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 List<OrderLineItem> Items { get; init; }
    public decimal TotalAmount { get; init; }
    public 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 List<OrderLineItem> Items { get; private set; }
    
    // Reconstruct state by applying events
    public static Order FromEvents(IEnumerable<object> events)
    {
        var order = new Order();
        foreach (var @event in events)
        {
            order.Apply(@event);
        }
        return order;
    }
    
    private void Apply(object @event)
    {
        switch (@event)
        {
            case OrderPlacedEvent e:
                Id = e.OrderId;
                Status = OrderStatus.Placed;
                Items = e.Items;
                break;
            case OrderShippedEvent e:
                Status = OrderStatus.Shipped;
                break;
            case OrderCancelledEvent e:
                Status = OrderStatus.Cancelled;
                break;
        }
    }
}

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 (When and How to Apply Event Sourcing)

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
    public OrderStatus Status { get; init; }
    public List<OrderLineItem> Items { get; init; }
    public DateTime SnapshotCreatedAt { get; init; }
}

public class OrderRepository
{
    public Order GetOrder(Guid orderId)
    {
        // Get latest snapshot
        var snapshot = _snapshotStore.GetLatestSnapshot(orderId);
        
        // Get events since snapshot
        var events = _eventStore.GetEvents(orderId, snapshot.EventVersion);
        
        // Rebuild from snapshot + new events
        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 (Implementation Approach)

Implementing Event Sourcing requires careful attention to several key areas.

1. Design Your Events Thoughtfully

Events are your contract with the future. Design them to be:

  • Self-contained: Include all data needed to understand the event
  • Immutable: Once written, never changed
  • Versioned: Plan for event schema evolution
  • Business-focused: Named for domain concepts, not technical operations
// Good: Business-focused, clear intent
public record CustomerRelocatedEvent
{
    public Guid CustomerId { get; init; }
    public Address PreviousAddress { get; init; }
    public Address NewAddress { get; init; }
    public DateTime RelocatedAt { get; init; }
    public string Reason { get; init; }
}

// Less ideal: Technical, loses business meaning
public record CustomerUpdatedEvent
{
    public Guid CustomerId { get; init; }
    public Dictionary<string, object> Changes { get; init; }
}

2. Choose Your Event Store

You can build on existing infrastructure or use specialized tools:

  • Relational databases: Simple append-only table works for small scale
  • EventStoreDB: Purpose-built for Event Sourcing, supports projections
  • Azure Event Store / AWS DynamoDB: Cloud-native options with good scaling
  • Kafka: Works well when events drive broader system integration
// Simple SQL-based event store table
CREATE TABLE EventStore (
    EventId UNIQUEIDENTIFIER PRIMARY KEY,
    AggregateId UNIQUEIDENTIFIER NOT NULL,
    AggregateType VARCHAR(255) NOT NULL,
    EventType VARCHAR(255) NOT NULL,
    EventData NVARCHAR(MAX) NOT NULL,  -- JSON serialized
    EventVersion INT NOT NULL,
    OccurredAt DATETIME2 NOT NULL,
    UserId VARCHAR(255),
    INDEX IX_Aggregate (AggregateId, EventVersion)
);

3. Implement Projections for Read Models

Event Sourcing handles writes beautifully, but reading current state from events can be slow. Build projections: read-optimized views derived from events.

public class OrderSummaryProjection
{
    private readonly IDatabase _readDatabase;
    
    public async Task Handle(OrderPlacedEvent @event)
    {
        await _readDatabase.ExecuteAsync(@"
            INSERT INTO OrderSummary (OrderId, CustomerId, Status, TotalAmount)
            VALUES (@OrderId, @CustomerId, 'Placed', @TotalAmount)",
            new { @event.OrderId, @event.CustomerId, @event.TotalAmount });
    }
    
    public async Task Handle(OrderShippedEvent @event)
    {
        await _readDatabase.ExecuteAsync(@"
            UPDATE OrderSummary 
            SET Status = 'Shipped', ShippedAt = @ShippedAt
            WHERE OrderId = @OrderId",
            new { @event.OrderId, @event.ShippedAt });
    }
}

This gives you CQRS (Command Query Responsibility Segregation) naturally: write to the event store, read from projections.

4. Handle Event Schema Evolution

Events live forever, but your domain evolves. Plan for versioning:

public interface IEvent
{
    int Version { get; }
}

public record CustomerAddressChangedEvent_V1 : IEvent
{
    public int Version => 1;
    public string NewAddress { get; init; }
}

public record CustomerAddressChangedEvent_V2 : IEvent
{
    public int Version => 2;
    public Address NewAddress { get; init; }  // Structured type
    public Address PreviousAddress { get; init; }  // Added context
}

// Upcaster handles old events
public class CustomerAddressChangedUpcaster
{
    public IEvent Upcast(IEvent @event)
    {
        if (@event is CustomerAddressChangedEvent_V1 v1)
        {
            return new CustomerAddressChangedEvent_V2
            {
                NewAddress = Address.Parse(v1.NewAddress),
                PreviousAddress = null  // Not available in V1
            };
        }
        return @event;
    }
}

5. Test with Event Scenarios

Event Sourcing makes testing delightful. Given a sequence of events, assert the resulting state or behavior:

[Fact]
public void CancelledOrder_CannotBeShipped()
{
    // Given these events occurred
    var events = new object[]
    {
        new OrderPlacedEvent { OrderId = orderId, Items = items },
        new OrderCancelledEvent { OrderId = orderId, Reason = "Customer request" }
    };
    
    var order = Order.FromEvents(events);
    
    // When trying to ship
    var result = order.Ship();
    
    // Then it should fail
    Assert.False(result.IsSuccess);
    Assert.Equal("Cannot ship cancelled order", result.Error);
}

The Payoff (Key Takeaways)

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

Here's the question that should guide your decision: If you woke up at 2 AM to investigate a production issue, would having a complete, immutable history of every state change help you sleep better? If the answer is yes, Event Sourcing might be your new best friend.

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's 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