What Is Event Sourcing? Storing What Happened Instead of State
Event Sourcing stores what happened in an append-only event stream instead of the latest state, and derives state by folding those events. Using Bookstore's stock ledger: rehydration, audit, temporal queries, optimistic concurrency with an expected version, why an event store is not a message broker, why it goes together with CQRS, and its real costs.
Version note: The code in this post was built and run with C# 14 and .NET 10.
Guess First
On the first day of November the warehouse team did a stock count: there are 37 copies of Dune on the shelf, and the system says 40. Customer service is asking too, because last week they couldn't ship an order that showed "in stock". Today the StockItem in Bookstore's Inventory context is stored like this:
namespace Bookstore.Inventory.StateBased;
// The classic approach: only the latest state in the table
public sealed class StockItem(Guid id, Guid bookId)
{
public Guid Id { get; } = id;
public Guid BookId { get; } = bookId;
public int OnHand { get; private set; }
public int Reserved { get; private set; }
public int Available => OnHand - Reserved;
public void Receive(int quantity) => OnHand += quantity;
public bool TryReserve(int quantity)
{
if (quantity > Available)
return false;
Reserved += quantity;
return true;
}
public void Release(int quantity) => Reserved -= quantity;
public void Ship(int quantity) => (OnHand, Reserved) = (OnHand - quantity, Reserved - quantity);
// Count correction: overwrites the old value
public void CorrectCount(int counted) => OnHand = counted;
}
The database has a single row for this book: OnHand = 40, Reserved = 0. Which of these questions can you answer from that row? When did the three-copy difference appear? Did someone correct the count by hand, and if so, when and why? On 22 October at noon, when we told a customer "in stock", how many copies were actually available? How many reservations were released in October because they expired?
Facts Instead of State: Events, Streams, and an Append-Only Log
The answer: none of them. The row only knows the latest state; every UPDATE erased how it got there. CorrectCount is the sneakiest: it overwrites the old value and leaves no trace.
Event Sourcing turns this around. In his 2005 article Martin Fowler defines it as capturing all changes to application state as a sequence of events. What is stored is not the latest state but what happened; the latest state is derived from those facts when needed. In CQRS (Command Query Responsibility Segregation) Documents Greg Young gives the most familiar example: an accounting ledger. Each transaction is a line in the ledger, the balance is the sum of those lines, and a wrong entry is not erased; it is corrected by a new entry that reverses it (a reversal). For Dune's stock the facts are these:
namespace Bookstore.Inventory;
public readonly record struct StockItemId(Guid Value);
public readonly record struct BookId(Guid Value);
public readonly record struct OrderId(Guid Value);
public enum ReleaseReason { OrderCancelled, Expired }
public enum AdjustmentReason { CountCorrection, Damaged, Lost }
// Events that tell what happened in business language; none says "the new value is"
public abstract record StockEvent(StockItemId StockItemId, DateTimeOffset At);
public sealed record StockItemRegistered(StockItemId StockItemId, BookId BookId, DateTimeOffset At) : StockEvent(StockItemId, At);
public sealed record StockReceived(StockItemId StockItemId, int Quantity, DateTimeOffset At) : StockEvent(StockItemId, At);
public sealed record StockReserved(StockItemId StockItemId, OrderId OrderId, int Quantity, DateTimeOffset At) : StockEvent(StockItemId, At);
public sealed record OutOfStock(StockItemId StockItemId, OrderId OrderId, int Requested, int Available, DateTimeOffset At) : StockEvent(StockItemId, At);
public sealed record ReservationReleased(StockItemId StockItemId, OrderId OrderId, int Quantity, ReleaseReason Reason, DateTimeOffset At) : StockEvent(StockItemId, At);
public sealed record ReservedStockShipped(StockItemId StockItemId, OrderId OrderId, int Quantity, DateTimeOffset At) : StockEvent(StockItemId, At);
public sealed record StockAdjusted(StockItemId StockItemId, int Delta, AdjustmentReason Reason, DateTimeOffset At) : StockEvent(StockItemId, At);
Each event is a past-tense fact and speaks the business language. The Event Sourcing pattern page on Microsoft Learn explains this distinction with a reservation example: an event saying "two seats were reserved" is far more valuable than one saying "remaining seats changed to 42"; the first tells you what happened, the second only its result. We have StockReserved(OrderId, Quantity: 2), not StockLevelChanged(NewAvailable: 42). An event store built from result-only events turns into a change log with no business meaning.
The ordered sequence of events belonging to one StockItem is called a stream. A stream only accepts appends at the end: something that happened does not change later and is not deleted. Each event of an aggregate, here a stock item, gets a version number in the stream.
Rehydration: Deriving State from Events
Deriving state from events is a fold: you start from the initial state and apply the events in order. This is called rehydration:
using System.Collections.Immutable;
namespace Bookstore.Inventory;
public sealed record StockState(int OnHand, ImmutableDictionary<OrderId, int> Reservations)
{
public static readonly StockState Initial = new(0, ImmutableDictionary<OrderId, int>.Empty);
public int Reserved => Reservations.Values.Sum();
public int Available => OnHand - Reserved;
// Rehydration: state is derived by applying the events in order (a fold)
public static StockState From(IEnumerable<StockEvent> history) => history.Aggregate(Initial, Evolve);
public static StockState Evolve(StockState state, StockEvent e) => e switch
{
StockReceived r => state with { OnHand = state.OnHand + r.Quantity },
StockReserved r => state with { Reservations = state.Reservations.Add(r.OrderId, r.Quantity) },
ReservationReleased r => state with { Reservations = state.Reservations.Remove(r.OrderId) },
ReservedStockShipped s => new StockState(state.OnHand - s.Quantity, state.Reservations.Remove(s.OrderId)),
StockAdjusted a => state with { OnHand = state.OnHand + a.Delta },
_ => state // StockItemRegistered, OutOfStock: do not change the numbers
};
}
Evolve is a pure function: it takes a state and an event and returns a new state, changes nothing and asks nothing of the outside world. StockItemRegistered and OutOfStock don't change the numbers, so they have no effect on the state, but they stay in the stream; both are facts someone will want to know. Let's write Dune's October into the stream and read it back:
IReadOnlyList<StoredEvent> history = store.ReadStream(dune.Value);
foreach (StoredEvent s in history)
Console.WriteLine($"v{s.Version,-2} {s.Data.At:yyyy-MM-dd HH:mm} {Describe(s.Data)}");
StockState now = StockState.From(history.Select(s => s.Data));
Console.WriteLine($"Now: OnHand={now.OnHand}, Reserved={now.Reserved}, Available={now.Available}");
v1 2026-10-01 08:00 StockItemRegistered
v2 2026-10-01 08:05 StockReceived +50
v3 2026-10-14 18:30 StockAdjusted +3 (CountCorrection)
v4 2026-10-20 10:00 StockReserved 5 for order 4302
v5 2026-10-21 09:00 ReservedStockShipped 5 for order 4302
v6 2026-10-22 11:00 StockReserved 2 for order 3dc8
v7 2026-10-22 11:30 ReservationReleased 2 (Expired)
v8 2026-10-25 14:00 StockReserved 4 for order a95c
v9 2026-10-26 09:00 ReservedStockShipped 4 for order a95c
v10 2026-10-28 16:00 StockReserved 3 for order 8f32
v11 2026-10-28 16:20 ReservationReleased 3 (OrderCancelled)
v12 2026-10-30 12:00 StockReserved 4 for order 00dc
v13 2026-10-31 09:00 ReservedStockShipped 4 for order 00dc
Now: OnHand=40, Reserved=0, Available=40
The latest state is still 40, but now it is clear where it came from. The third line answers the first of the opening questions: on the evening of 14 October someone added 3 copies to stock with a count correction. 37 copies on the shelf suggests that correction was wrong; the right fix will be a new StockAdjusted(-3, CountCorrection) event, and v3 will not be deleted.
What Event Sourcing Gives You
From the same stream, two more questions, neither of them planned in advance:
// Temporal query: the same fold, up to a given moment
StockState then = StockState.From(history.Select(s => s.Data).Where(x => x.At <= T("2026-10-22 11:15")));
Console.WriteLine($"As of 2026-10-22 11:15: OnHand={then.OnHand}, Reserved={then.Reserved}");
// A question nobody planned for, answered from existing events
ReservationReleased[] expired = [.. history.Select(s => s.Data).OfType<ReservationReleased>().Where(r => r.Reason == ReleaseReason.Expired)];
Console.WriteLine($"Expired reservations in October: {expired.Length} ({expired.Sum(r => r.Quantity)} copies)");
Bookstore.Inventory.StateBased.StockItem row = new(dune.Value, Guid.CreateVersion7());
row.Receive(50); row.CorrectCount(53);
row.TryReserve(5); row.Ship(5); row.TryReserve(2); row.Release(2); row.TryReserve(4); row.Ship(4);
row.TryReserve(3); row.Release(3); row.TryReserve(4); row.Ship(4);
Console.WriteLine($"State-based row: OnHand={row.OnHand}, Reserved={row.Reserved}");
As of 2026-10-22 11:15: OnHand=48, Reserved=2
Expired reservations in October: 1 (2 copies)
State-based row: OnHand=40, Reserved=0
A temporal query, as Fowler calls it, is nothing more than running the same fold up to a given moment: at 11:15 on 22 October, 48 copies were on the shelf and 2 of them were set aside for an order. The question that counts expired reservations is answered from existing events even though nobody asked for that screen. If someone asks tomorrow "at which hours did reservations drop during campaign week", you can write a new read model and replay the whole history into it; the data is already there. Apply the same operations to the state-based StockItem and all you are left with is two numbers.
Three of these gains stand out: audit (the answer to who did what and when is the data itself), temporal queries, and the ability to derive new read models from history. Testing and debugging get easier too: to reproduce a bug, copying that stream's events into a test is enough. We will do exactly that with Given-When-Then tests in the next post.
The Event Store and Optimistic Concurrency
The place where events are stored is called an event store. Three things are expected of an event store: reading a stream by id in order, appending to a stream, and accepting an append only if the version you read is still current. The last one is optimistic concurrency, and it is the key to event sourcing working without locks. Let's write an in-memory event store for teaching purposes:
namespace Bookstore.Inventory;
public sealed record StoredEvent(Guid StreamId, long Version, long Position, StockEvent Data);
public sealed class WrongExpectedVersionException(Guid streamId, long expected, long actual)
: Exception($"Stream {streamId}: expected version {expected}, actual {actual}.");
// An in-memory event store for teaching: append-only, a version per stream, a global order
public sealed class InMemoryEventStore
{
private readonly Lock _lock = new();
private readonly Dictionary<Guid, List<StoredEvent>> _streams = [];
private readonly List<StoredEvent> _all = [];
public IReadOnlyList<StoredEvent> ReadStream(Guid streamId)
{
lock (_lock)
return _streams.TryGetValue(streamId, out List<StoredEvent>? stream) ? [.. stream] : [];
}
public IReadOnlyList<StoredEvent> ReadAll()
{
lock (_lock)
return [.. _all];
}
// expectedVersion: the version when you read; if someone wrote in between, the append is rejected
public long Append(Guid streamId, long expectedVersion, params IReadOnlyList<StockEvent> events)
{
lock (_lock)
{
List<StoredEvent> stream = _streams.TryGetValue(streamId, out List<StoredEvent>? s) ? s : _streams[streamId] = [];
if (stream.Count != expectedVersion)
throw new WrongExpectedVersionException(streamId, expectedVersion, stream.Count);
foreach (StockEvent e in events)
{
StoredEvent stored = new(streamId, stream.Count + 1, _all.Count + 1, e);
stream.Add(stored);
_all.Add(stored);
}
return stream.Count;
}
}
}
A command handler that wants to reserve stock works in four steps: read the stream, derive the state, decide, and append with the version you read:
namespace Bookstore.Inventory;
public sealed class ReserveStockHandler(InMemoryEventStore store, TimeProvider clock)
{
public StockEvent Handle(StockItemId id, OrderId orderId, int quantity)
{
// 1) Read the stream, 2) derive the state, 3) decide, 4) append with the version you read
IReadOnlyList<StoredEvent> stream = store.ReadStream(id.Value);
StockState state = StockState.From(stream.Select(e => e.Data));
StockEvent result = quantity <= state.Available
? new StockReserved(id, orderId, quantity, clock.GetUtcNow())
: new OutOfStock(id, orderId, quantity, state.Available, clock.GetUtcNow());
store.Append(id.Value, expectedVersion: stream.Count, result);
return result;
}
}
Let's act out the busiest moment of the campaign: the last 2 copies of The Hobbit are left, and Ada and Alan each ask for two at the same time.
namespace Bookstore.Inventory;
public static class Concurrency
{
public static void Run()
{
InMemoryEventStore store = new();
StockItemId hobbit = new(Guid.CreateVersion7());
store.Append(hobbit.Value, 0, new StockItemRegistered(hobbit, new BookId(Guid.CreateVersion7()), DateTimeOffset.UtcNow),
new StockReceived(hobbit, 2, DateTimeOffset.UtcNow));
// Two requests read at the same time: both see version 2 and 2 available copies
IReadOnlyList<StoredEvent> seenByAda = store.ReadStream(hobbit.Value);
IReadOnlyList<StoredEvent> seenByAlan = store.ReadStream(hobbit.Value);
Console.WriteLine($"Both see version {seenByAda.Count} with {StockState.From(seenByAda.Select(s => s.Data)).Available} available");
store.Append(hobbit.Value, seenByAda.Count, new StockReserved(hobbit, new OrderId(Guid.CreateVersion7()), 2, DateTimeOffset.UtcNow));
try
{
store.Append(hobbit.Value, seenByAlan.Count, new StockReserved(hobbit, new OrderId(Guid.CreateVersion7()), 2, DateTimeOffset.UtcNow));
}
catch (WrongExpectedVersionException ex)
{
Console.WriteLine(ex.Message);
}
// Retry: read again, decide again
StockEvent retried = new ReserveStockHandler(store, TimeProvider.System).Handle(hobbit, new OrderId(Guid.CreateVersion7()), 2);
Console.WriteLine($"Retry result: {retried.GetType().Name}");
}
}
Both see version 2 with 2 available
Stream 01a1b846-3cf4-7f69-bf0b-83eec7d0431e: expected version 2, actual 3.
Retry result: OutOfStock
Both requests saw version 2 and 2 available copies. Ada's was written first and the stream moved to version 3; Alan's append was rejected with "I expected version 2 and found 3". The retry read the stream again, saw that no copies were left, and this time wrote an OutOfStock event. No row locks, no waiting for the length of a transaction; a conflict only appears when two writes really hit the same stream at the same time. This is the event store's counterpart of what we did with the Version concurrency token in Entity Framework Core (EF Core) in the DDD (Domain-Driven Design) series.
A real event store provides this contract in a durable, distributed setting. In the .NET world there are two common options: Marten, open source and running on PostgreSQL, and KurrentDB (formerly EventStoreDB), a purpose-built database. In the sixth post of the series we will move to a real event store with Marten.
An Event Store Is Not a Message Broker
Because they share the word "event", an event store is often confused with a message broker like Kafka. Microsoft Learn gives a clear warning here: brokers such as Kafka typically lack per-entity stream queries and optimistic concurrency; they are a good layer for fanning events out to projections and external consumers, but they are not a substitute for an event store. In practice the difference is this: in Kafka you cannot say "write to this topic only if this stock item's latest version is still 2", and to read the history of a single stock item you have to search for its events across the whole topic. A team that tries to solve the conflict scenario above with a broker ends up building the lock in another database.
The reverse holds too: the events in an event store are a bounded context's internal data. Exposing them as-is to other contexts as an integration contract makes it impossible to change your internal model. Separate, stable integration events are published to the outside; the upcoming Messaging and Event-Driven Architecture series will cover that distinction in detail.
Why Together with CQRS?
An event store is very good at reading a stream by id and at nothing else. You cannot ask the event store "which books have fallen below 5 available copies"; you would have to read and fold every stock item's stream. That is why almost every system that uses event sourcing also uses CQRS: the write side is the event store, the read side is projections fed by events. It is the same projector we built in the third post of the series, except that this time its source is the event store itself rather than an event log on the write side. Greg Young describes the relationship as symbiotic in his Documents: in an event-sourced domain the repository's only query is fetching by id, so read needs inevitably go to a separate model. The CQRS page on Microsoft Learn describes the same combination: the event store is the write model and the single source of truth, and the read model produces materialized views from the events.
The Candidate in Bookstore: The Stock Ledger
The one place in Bookstore that is a genuine candidate for event sourcing is Inventory's stock ledger. The reasons are in this post's opening: questions about count differences and missing copies call for an audit; most of the ~120 orders a minute at the campaign peak pile onto a few popular books, and the fine-grained, stream-level conflict detection of optimistic concurrency is made for exactly that; and the events were already discovered in the DDD series (StockReserved, OutOfStock). For Order in Ordering, CRUD (Create, Read, Update, Delete) plus domain events is enough; there is no business need that asks for the order's history, and keeping the latest state in a table makes every report easy. Book records in Catalog are almost static reference data. In Payments the money ledger is already kept by the payment provider; our side is an adapter.
The costs are real too. Because events live forever, changing their schema needs a versioning strategy. There are no ad-hoc queries; every new question is a projection, and projections are eventually consistent. When personal data has to be deleted, an append-only log creates tension with KVKK (the Turkish Personal Data Protection Law) and GDPR (General Data Protection Regulation). And the team relearns its habits for testing, debugging and fixing data in production: "let's fix that row with an UPDATE" is no longer an option. Each of these topics is covered in the eighth and ninth posts of the series.
Common Pitfalls
Events that state the result
Events like StockLevelChanged(NewOnHand: 40) or StockItemUpdated(...) dress CRUD up as events. When you want to derive a new read model, all you have is a series of numbers; the answer to "why did it change" is lost again. An event name should fit into a domain expert's sentence: "a count correction was made on 14 October".
Updating an event to fix it
Turning a wrong StockAdjusted(+3) event into +0 with an UPDATE destroys the whole value of the audit and silently leaves the projections that already consumed that event inconsistent. A fix is a new event, what Young calls a reversal: StockAdjusted(-3, CountCorrection).
Touching the outside world during rehydration
Sending an e-mail or calling another service inside Evolve means repeating that call on every rehydration. Fowler explained back in 2005 that calls to external systems need to be suppressed by a gateway during replay. Evolve stays pure; side effects are the job of a separate consumer that listens to the event.
Event sourcing for the whole system
The warning on Microsoft Learn is clear: event sourcing is not an all-or-nothing decision; it is applied to the parts where it pays off most, such as a payment ledger or an order-processing pipeline, while places like user profiles or application settings stay classic CRUD.
When Not to Use It
Microsoft Learn explicitly lists when event sourcing may not be suitable: simple CRUD operations that don't need audit, replay or temporal queries; prototypes, MVPs (minimum viable products) and short-lived systems; systems that require real-time consistency in their views; mostly static reference data; and teams without experience in event-driven architecture. The same page stresses that moving into and out of an event-sourced solution is expensive. For Bookstore's team of three developers, that means applying event sourcing to the stock ledger only and still keeping the first version in a single PostgreSQL. If your question is not "who changed it, when and why", and a log table is enough for audit, you don't need event sourcing.
A Small Experiment
★☆☆ Add to the stream the correct event reflecting the warehouse team's 1 November count, and see the latest state drop to 37 in the output. Even though you didn't delete v3, are the answers to both questions ("what happened on 14 October?", "how many copies are there now?") still there?
★★☆ Write a projection that computes "copies shipped in October" for all stock items: read the global order with InMemoryEventStore.ReadAll() and sum only the ReservedStockShipped events. What would you have had to design up front to answer this question in the state-based model?
★★★ Add automatic retries to ReserveStockHandler: when a WrongExpectedVersionException arrives, it should read the stream again and retry at most three times. Then send one-copy reservations to the same stock item from 50 parallel Tasks; check that the total of StockReserved never exceeds OnHand, and measure how many retries happened.
In this post we put the decision logic inside the handler as an if. In the next post we will move it where it belongs: the decider pattern, made of two pure functions called Decide and Evolve, how it compares with an OOP (Object-Oriented Programming) aggregate, and Given-When-Then tests that never touch a database.