Articles · Event Sourcing ·005
Event Sourcing Basics
Runnable sample on GitHub
Sample:
samples/005-event-sourcing-basics— an event-sourced bank ledger. Run its tests withdotnet test(needs Docker for PostgreSQL).Prerequisites: 002 — First Aggregate, 003 — Domain Events.
Overview
Every sample so far stored state: the current product, the current order, the current balance. Event
sourcing stores something different — the sequence of events that produced that state — and rebuilds
the state by replaying them. An account is not a row with a Balance column; it is AccountOpened,
then MoneyDeposited(100), then MoneyWithdrawn(30), and “balance = 70” is derived, not stored.
This article turns the apply-only Account aggregate from article 002 into a real event-sourced one —
without changing the domain code — by wiring Relay’s event store, unit of work, and event-sourced
repository, and letting the transactional pipeline you have heard about since article 001 finally do
its job: append the aggregate’s new events and commit them atomically.
Why this exists
State-oriented storage throws away history. When you UPDATE accounts SET balance = 70, the fact that
it was 100 and why it changed is gone — overwritten. For a great many systems that loss is
catastrophic the first time someone asks a question the current state cannot answer:
- “What was this account’s balance on March 3rd?” — unanswerable from a
balancecolumn. - “Why is this order in this weird state?” — the path that led there was overwritten at each step.
- “Prove this payment was authorised before it was captured.” — auditors want the sequence, not the endpoint.
- “We need to add a fraud-detection read model over the last two years of activity.” — there is no activity to read; only the current snapshot survived.
Event sourcing keeps the events as the source of truth, so the answers are always derivable. The current state becomes one projection of the events (the simplest one — fold them up), and you can build any number of other views from the same history (article 007). You also get, for free, a complete audit trail (the events are the audit log), the ability to debug by replaying exactly what happened, and a clean story for evolving your read models without migrating data — you just reproject.
Relay provides the machinery: an append-only IEventStore with optimistic concurrency, an
IEventSourcedRepository that loads aggregates by replay and persists their new events, and a
transactional pipeline that makes “save the aggregate” mean “append its events atomically.”
When to use this
Event-source an aggregate when history is part of the requirement, not an afterthought:
- Payments & ledgers. A balance must be auditable and reconstructable; “how did we get here” is a compliance question, not a nice-to-have. The sample is a ledger for exactly this reason.
- Order management. The lifecycle (placed → paid → picked → shipped → delivered, with the exceptions) is the valuable information; stakeholders constantly ask about transitions, not just the final state.
- Anything audited or regulated. Healthcare, finance, insurance — where “who changed what, when, and in what order” is legally required, the event stream is the cheapest possible audit story because it is the data model, not a parallel log that can drift.
- Domains where you will want new read models later. If you suspect you’ll be asked for analytics, projections, or views you can’t predict today, keeping the events means you can build those views retroactively from full history.
- Temporal / “as of” requirements. “Show the state as of a past time” falls out of replaying events up to that point.
The honest signal: if you find yourself reaching for an audit-log table, a history table, soft
deletes, or “status change” rows alongside your aggregate, the domain is already asking for event
sourcing — those are all hand-rolled, lossy approximations of an event stream.
When not to use this
Event sourcing is a serious commitment. Do not event-source when:
- It is CRUD. Reference data, settings, a product catalog you only ever overwrite — there is no meaningful history and no behavior. A row and an ORM are simpler, faster, and entirely sufficient (articles 001–004 stay state-oriented for this reason).
- The aggregate has no interesting lifecycle. If “the current values” is the whole story and nobody will ever ask “how did it get like this,” events are pure overhead.
- The team is new to it and the deadline is tight. Event sourcing has a real learning curve (modelling events well, projections, schema evolution, eventual consistency on the read side). Adopt it where it pays, not everywhere, and not under a crunch as a first outing.
- You need ad-hoc queries over current state and nothing else. The event store is not a query database. You query projections (article 007), not the events. If all you ever do is query current state, you are paying for a projection layer to get back what a table gave you for free.
The costs are substantial and worth naming plainly: operational complexity (an append-only store, projections to keep current, snapshots to bound replay, schema evolution for events that live forever); a learning curve the whole team must climb; eventual consistency on the read side (projections lag the write); and the discipline that events are immutable forever — a badly designed event is a permanent liability. Reach for it where history is the point; avoid it where it is decoration.
Concepts
Event store. An append-only log of events. IEventStore.AppendAsync adds events for an aggregate
with an expected version (optimistic concurrency); GetEventsAsync(aggregateId, fromVersion) reads a
stream back. Each stored EventData carries the stream, the per-aggregate Version (0, 1, 2…), a
global Position, the inserting transaction id (for gap-safe catch-up reads — article 007), a
timestamp, the event type name, and the JSON payload.
Replay. Loading an aggregate means reading its events and applying them in order. Because the
aggregate from article 002 mutates state only in ApplyEvent, replaying produces exactly the state
the original commands produced. The repository does this for you.
Event-sourced repository. IEventSourcedRepository<TAggregate, TId>: GetByIdAsync rehydrates by
replay (returning null if the aggregate has no events); Add/Update track an aggregate so its
new, uncommitted events are appended when the transaction commits.
The transactional pipeline. The TransactionBehavior (priority 1000, the innermost wrapper from
article 001) begins a transaction, runs the handler, dispatches the aggregate’s domain events
(article 003), appends its new events to the store with the optimistic-concurrency check, and commits —
all atomically. Events, read-model writes from domain-event handlers, and (later) outbox rows
either all commit or all roll back. There is no dual-write window.
Optimistic concurrency. Appending events asserts the aggregate’s expected version. If another
transaction advanced the stream in the meantime, the append is rejected with a
ConcurrencyConflictException (article 006).
Architecture
sequenceDiagram
participant H as WithdrawCommandHandler
participant R as IEventSourcedRepository
participant Tx as TransactionExecutor (priority 1000)
participant ES as IEventStore (relay_events)
participant DB as PostgreSQL
Tx->>Tx: BEGIN
Tx->>H: Handle(WithdrawCommand)
H->>R: GetByIdAsync(id)
R->>ES: GetEventsAsync(id, fromVersion=0)
ES-->>R: [AccountOpened v0, MoneyDeposited v1]
R-->>H: Account (replayed, balance=100, version=1)
H->>H: account.Withdraw(30) → raises MoneyWithdrawn (v2)
H->>R: Update(account)
Tx->>ES: AppendMany([MoneyWithdrawn v2], expectedVersion=1)
Tx->>DB: COMMIT (events + any read-model writes, atomic)
Note over Tx,DB: invariant violated or version stale ⇒ ROLLBACK, nothing persisted
The write side stores events. The read side (current state, or any other view) is derived — by the repository’s replay for the write model, or by projections for query models (article 007). That separation is the heart of why event sourcing pairs so naturally with CQRS.
Building it step by step
The full sample is in samples/005-event-sourcing-basics.
1. The aggregate — unchanged from article 002’s style
[AggregateType("ledger.account")]
public sealed class Account : AggregateRoot<Guid>
{
public decimal Balance { get; private set; }
protected override bool ApplyEventsOnRaise => true;
private Account() { } // for rehydration
public static Account Open(Guid id, string owner) { /* Guard; RaiseEvent(new AccountOpened(...)) */ }
public void Deposit(decimal amount) { Guard.AgainstNegativeOrZero(amount, nameof(amount)); RaiseEvent(new MoneyDeposited(Id, amount)); }
public void Withdraw(decimal amount)
{
Guard.AgainstNegativeOrZero(amount, nameof(amount));
Guard.Against(amount > Balance, "Insufficient funds.");
RaiseEvent(new MoneyWithdrawn(Id, amount));
}
protected override void ApplyEvent(IDomainEvent e) => /* AccountOpened/MoneyDeposited/MoneyWithdrawn → mutate Balance */;
}
The [AggregateType("ledger.account")] pins a stable name for the stream so renaming the class never
fragments its history. Event-sourced aggregates must be keyed by Guid — the framework enforces it.
2. Handlers that persist by tracking — no [SkipTransaction]
public sealed class WithdrawCommandHandler(IEventSourcedRepository<Account, Guid> repository)
: ICommandHandler<WithdrawCommand, Account>
{
public async Task<Account> Handle(WithdrawCommand command, CancellationToken ct)
{
var account = await repository.GetByIdAsync(command.AccountId, ct)
?? throw new InvalidOperationException("not found");
account.Withdraw(command.Amount); // throws → whole transaction rolls back
repository.Update(account); // track; new event appended on commit
return account;
}
}
Creating is even simpler — a handler that returns an aggregate has it tracked automatically:
public Task<Account> Handle(OpenAccountCommand command, CancellationToken ct)
=> Task.FromResult(Account.Open(command.Id, command.Owner)); // AccountOpened appended on commit
3. Map the relay tables onto your DbContext
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.ApplyRelayEventStore(); // relay_events
modelBuilder.ApplyRelaySnapshots(); // relay_snapshots (required even with snapshots off — see article 006)
}
4. Wire the event store, unit of work, and repository
services.AddDbContext<LedgerDbContext>((sp, o) => o.UseNpgsql(connectionString),
contextLifetime: ServiceLifetime.Scoped, optionsLifetime: ServiceLifetime.Singleton);
services.AddRelayEventStoreEfCore<LedgerDbContext>(); // event store + snapshot store + serializer
services.AddRelayUnitOfWorkEfCore<LedgerDbContext>(); // the transaction the pipeline uses
services.AddRelayEventSourcedRepositoryEfCore(); // IEventSourcedRepository<,>
services.AddRelay(typeof(Program).Assembly); // buses + the TransactionBehavior
Order matters slightly: register the EF Core event-store stack before AddRelay, so AddRelay’s
event-type registry (which scans your events) takes precedence over the empty fallback.
Complete source code
| File | Contents |
|---|---|
Accounts/Account.cs |
The event-sourced aggregate and its events |
Accounts/Commands.cs |
Commands + handlers (no [SkipTransaction]) |
LedgerDbContext.cs |
ApplyRelayEventStore() + ApplyRelaySnapshots() |
LedgerFixture.cs |
Testcontainers Postgres + the production DI wiring |
Running the example
dotnet test samples/005-event-sourcing-basics/Ledger.EventSourcing.Tests
This needs Docker — the test fixture starts a real postgres:16 via Testcontainers, applies the
schema with EnsureCreatedAsync(), and runs the full stack. That is deliberate: event sourcing is a
storage pattern, and the only honest demonstration is against a real store. In production you replace
EnsureCreatedAsync() with EF migrations (or the baseline SQL in
libraries/nuvora-nexus-relay/docs/migrations/) and add the AddRelaySchema() readiness check so a
deploy that shipped code without its schema fails its probe instead of erroring under load.
Testing
The sample’s EventSourcingTests.cs
proves the two properties that make event sourcing trustworthy.
The stream is the truth, and replay reproduces state:
await Bus.Execute<OpenAccountCommand, Account>(new OpenAccountCommand(id, "Ada"), default);
await Bus.Execute<DepositCommand, Account>(new DepositCommand(id, 100m), default);
await Bus.Execute<WithdrawCommand, Account>(new WithdrawCommand(id, 30m), default);
var events = await eventStore.GetEventsAsync(id, 0, default);
events.Select(e => e.Version).Should().Equal(0, 1, 2); // contiguous
var account = await repository.GetByIdAsync(id, default);
account!.Balance.Should().Be(70m); // derived by replay
A rejected command appends nothing — the atomic transaction is the whole point, so it is tested directly:
var act = () => Bus.Execute<WithdrawCommand, Account>(new WithdrawCommand(id, 50m), default);
await act.Should().ThrowAsync<DomainException>();
(await eventStore.GetEventsAsync(id, 0, default)).Should().HaveCount(2); // the overdraw rolled back
These are integration tests against real PostgreSQL, not mocks, because the guarantees being verified — atomic append, optimistic concurrency, replay fidelity — only exist at the database level. Mocking the event store would test the mock.
Production considerations
- The event store only grows. Events are never deleted. Plan storage accordingly, and use snapshots (article 006) to bound replay cost as streams get long — storage and replay are different problems with different solutions.
- Never query the event store for reads. It answers “give me this aggregate’s stream,” not “give me all accounts over $1000.” Those queries come from projections (article 007). Treating the event store as a read database is the cardinal beginner mistake.
- Events are immutable and live forever, so versioning is non-optional. The day you need to change
an event’s shape, you cannot edit history — you upcast old events on read (article 024). Design
events conservatively (additive changes, stable names via
[AggregateType]/[EventType]) from the start. - Schema is a deploy-time concern. Use EF migrations or the canonical baseline script, and wire the
AddRelaySchema()health check so a missing table fails readiness rather than the first request. - The visibility horizon keeps catch-up correct. Relay reads the log on a gap-safe
(tx_id, position)cursor so a late-committing transaction can’t make a projection skip events. You don’t configure this, but knowing it exists explains why catch-up is reliable under concurrency (article 007). - Idempotency on the read side. Because delivery to projections is at-least-once, projections must be idempotent. The write side is exactly-once (the atomic append), but the read side is eventually consistent — design read models accordingly.
Common mistakes
- CRUD-with-events. Emitting
AccountUpdated { Balance = 70 }instead ofMoneyWithdrawn { 30 }. The first is a state snapshot wearing an event costume; it throws away the why and makes every projection re-derive intent. Events should capture decisions, not field diffs. - Mutating state outside
ApplyEvent. If a command method changes a field directly (and you have not enabledApplyEventsOnRaise), the live aggregate and a replayed one diverge — a maddening bug. KeepApplyEventthe only mutator (article 002 belabours this for exactly this payoff). - Querying the event store for lists/filters. See above — that’s a projection’s job. The event store has no index for “all accounts where balance > X” and never will.
- Forgetting
ApplyRelaySnapshots(). The repository checks for a snapshot on every load; without the table mapped, loads fail. Map it even when snapshots are off. - Event-sourcing everything. Reference data, config, and CRUD do not benefit. Scoping event sourcing to the aggregates whose history matters is a design skill; applying it everywhere is a tax.
Tradeoffs
Benefits. A complete, tamper-evident audit trail for free; the ability to build new read models from full history retroactively; time-travel / “as of” queries; debugging by replay; and a write model that is, by construction, never out of sync with its own history.
Costs. Operational complexity (append-only store, projections, snapshots, event versioning); a learning curve; eventual consistency on reads; ever-growing storage; and the permanence of events — poor event design is a long-lived liability.
Alternatives
- State storage with an ORM (articles 001–004). Simplest, fastest, and correct for CRUD and behavior-light domains. Loses history. The right default; reach past it only when history matters.
- A separate audit-log table. Keep current state, write a parallel “what changed” log. Cheap to add, but it is a lossy, hand-maintained shadow of an event stream that will drift from the truth (someone updates state without logging), and it cannot rebuild state or feed projections.
- Temporal tables / system-versioning. The database keeps row history. Good for “as of” queries on state, but it versions rows, not business events — you get “the balance was 70 at noon,” not “money was withdrawn, here’s why.” No projections, no behavioral history.
- Change Data Capture (CDC). Stream the database’s change log downstream. Great for integration and analytics, but it captures row mutations, not domain intent, and it is an operational pipeline, not a domain model. Complementary to, not a substitute for, event sourcing.
Objectively: event sourcing wins when business history and derivable views are first-class requirements. For everything else, simpler state storage is not a compromise — it is the correct choice.
Lessons from production systems
- The hard part is the read side, not the write side. Teams underestimate that “current state” now comes from projections that lag, must be rebuilt, and must be idempotent. Budget for the projection machinery (articles 007–008) from day one; the append-only write store is the easy half.
- Event design debt compounds. A vague or overly-specific event you ship today is replayed forever.
The teams that thrive invest early in naming events for business facts and keeping payloads minimal
and additive; the ones that struggle ship
ThingChanged { Json blob }and pay interest for years. - Snapshots are a performance tool, not a correctness one. New teams sometimes treat snapshots as the source of truth or panic about replay cost prematurely. Replay is cheap until streams are long; add snapshots when a stream’s length actually hurts (article 006), and keep the events authoritative.
- Scope it deliberately. The most successful adoptions event-source a handful of aggregates where history is the product (the ledger, the order) and leave the rest CRUD. “Event source the whole system” is how teams end up with a slow, complex CRUD app that happens to have a log.
Should you use this?
| Situation | Recommendation |
|---|---|
| Ledgers, payments, anything audited/regulated | Strong fit — history is a requirement, not a feature |
| Rich lifecycles where transitions are the valuable data (orders) | Strong fit |
| You’ll want read models / analytics you can’t predict yet | Strong fit — keep the events, reproject later |
| “As of” / temporal queries over business activity | Strong fit |
| CRUD, reference data, config | Avoid — a row and an ORM are correct |
| Aggregate with no meaningful history | Avoid — pure overhead |
| New team, tight deadline, first outing | Lean against — adopt where it pays, not under crunch |
Next steps
Replaying an account with three events is instant. Replaying one with fifty thousand is not — and two requests racing to change the same account need a referee. The next article adds the two tools that make event sourcing practical at scale: optimistic concurrency (so concurrent writes are detected, not silently lost) and snapshots (so long streams don’t have to replay from the beginning).