Ledger Component¶
Verified by tests
InMemoryEventStoreTests, EventStoreOrderingInvariantTests, EventStoreAppendBatchTests — library CI run #37346231411 (2026-10-05)
Overview¶
The ledger is Whizbang's append-only event store: an immutable, replayable record of every event in your system. It is implemented by the IEventStore interface — events are appended to streams (one stream per aggregate/entity, keyed by a UUIDv7 streamId) and can never be altered or deleted.
Updated
Early drafts of this page described a standalone ILedger interface with global positions. The shipped design is stream-based: the ledger is the IEventStore abstraction, with per-stream sequence numbers and UUIDv7 event-id ordering. This page describes the shipped behavior; see Event Store for the full API surface.
What is a Ledger?¶
A Ledger: - Stores events in append-only fashion, organized into streams - Preserves the complete history of state changes - Provides an immutable audit trail - Enables event replay (perspectives, rebuilds) and debugging
Think of the ledger as your system's permanent memory - every significant action is recorded and can never be altered or deleted.
Core Interface¶
The ledger contract is IEventStore (Whizbang.Core.Messaging). The essential members:
Core Interface
public interface IEventStore {
// Append an envelope to a stream
Task AppendAsync<TMessage>(Guid streamId, MessageEnvelope<TMessage> envelope,
CancellationToken cancellationToken = default);
// Append a raw message (envelope resolved from IEnvelopeRegistry, or a minimal one is created)
Task AppendAsync<TMessage>(Guid streamId, TMessage message,
CancellationToken cancellationToken = default) where TMessage : notnull;
// Read a stream from a sequence number (inclusive)
IAsyncEnumerable<MessageEnvelope<TMessage>> ReadAsync<TMessage>(Guid streamId,
long fromSequence, CancellationToken cancellationToken = default);
// Read a stream starting after an event ID (UUIDv7 time-ordering); null = from beginning
IAsyncEnumerable<MessageEnvelope<TMessage>> ReadAsync<TMessage>(Guid streamId,
Guid? fromEventId, CancellationToken cancellationToken = default);
// Read mixed event types, deserializing each to its concrete type
IAsyncEnumerable<MessageEnvelope<IEvent>> ReadPolymorphicAsync(Guid streamId,
Guid? fromEventId, IReadOnlyList<Type> eventTypes,
CancellationToken cancellationToken = default);
// Last (highest) sequence number for a stream; -1 if the stream is empty
Task<long> GetLastSequenceAsync(Guid streamId, CancellationToken cancellationToken = default);
// ... batch append, checkpoint-range reads, and AppendAndWaitAsync -
// see the Event Store page for the full surface
}
There is no global "position" counter — ordering is per stream (monotonic sequence numbers) and by event ID (UUIDv7 is time-ordered), which is what enables partitioned, scalable storage backends.
Implementations¶
| Implementation | Package | Use |
|---|---|---|
InMemoryEventStore |
Whizbang.Core |
Testing and single-process scenarios. Thread-safe; NOT for multi-process production use. |
| Postgres event stores (Dapper / EF Core) | Whizbang.Data.* |
Production. Events land in the wh_event_store table via the work coordinator; event bodies (payload + metadata) live in the companion wh_event_body table since the full-body split (migrations 077/078). |
In-Memory Implementation
// Thread-safe in-memory ledger for tests / single-process apps
var store = new InMemoryEventStore();
// Or, with envelope-registry support so raw-message appends keep tracing context
var store = new InMemoryEventStore(envelopeRegistry);
Event Storage¶
Record Structure¶
Relational backends persist one EventStoreRecord per event:
Record Structure
public sealed class EventStoreRecord {
public Guid Id { get; set; } // Event ID (UUIDv7)
public required Guid StreamId { get; set; } // Stream (aggregate) identity
public required Guid AggregateId { get; set; } // Back-compat alias of StreamId
public required string AggregateType { get; set; } // CLR type name (no assembly)
public required int Version { get; set; } // Per-stream optimistic-concurrency version
public required string EventType { get; set; } // "Namespace.Type, Assembly"
public required JsonElement? EventData { get; set; } // Event payload as JSON; NULL on Postgres
// post-077 (body lives in wh_event_body)
public required EnvelopeMetadata? Metadata { get; set; } // Hops, correlation, causation; NULL on
// Postgres post-077 (in wh_event_body)
public PerspectiveScope? Scope { get; set; } // Security scope (tenant/user/...)
public DateTime CreatedAt { get; set; }
public long? CommitSequence { get; set; } // Global commit stamp (async)
// ... origin fields for cross-service provenance
}
Appending Events¶
Events reach the ledger automatically: receptors return events, and the dispatch pipeline stores them (outbox → work coordinator → event store). You can also append directly:
Appending Events
// Direct append - envelope (with tracing context) is looked up automatically
await eventStore.AppendAsync(order.StreamId, new OrderShipped {
OrderId = order.Id,
ShippedAt = timeProvider.GetUtcNow()
});
Reading Events¶
Reading Events
// Read a whole stream, strongly typed
await foreach (var envelope in eventStore.ReadAsync<OrderCreated>(streamId, fromSequence: 0)) {
Console.WriteLine($"{envelope.Payload.OrderId}");
}
// Read from a checkpoint (events AFTER this event ID)
await foreach (var envelope in eventStore.ReadAsync<OrderCreated>(streamId, fromEventId: lastSeenEventId)) {
ProcessEvent(envelope.Payload);
}
// Polymorphic read - mixed event types in one stream
var eventTypes = new[] { typeof(OrderCreated), typeof(OrderShipped) };
await foreach (var envelope in eventStore.ReadPolymorphicAsync(streamId, fromEventId: null, eventTypes)) {
switch (envelope.Payload) {
case OrderCreated created: /* ... */ break;
case OrderShipped shipped: /* ... */ break;
}
}
Testing with the Ledger¶
InMemoryEventStore makes ledger-level assertions easy:
Testing with the Ledger
[Test]
public async Task Append_ShouldIncrementSequenceAsync() {
// Arrange
var store = new InMemoryEventStore();
var streamId = (Guid)TrackedGuid.New();
// Empty stream reports -1
await Assert.That(await store.GetLastSequenceAsync(streamId)).IsEqualTo(-1);
// Act
await store.AppendAsync(streamId, new TestEvent());
await store.AppendAsync(streamId, new TestEvent());
// Assert - sequences are per stream and monotonic
await Assert.That(await store.GetLastSequenceAsync(streamId)).IsEqualTo(1);
var events = new List<MessageEnvelope<TestEvent>>();
await foreach (var e in store.ReadAsync<TestEvent>(streamId, fromSequence: 0)) {
events.Add(e);
}
await Assert.That(events.Count).IsEqualTo(2);
}
Characteristics¶
- Append-only - no update or delete operations exist on the interface
- Per-stream ordering - monotonic sequence numbers within each stream
- Time-ordered event IDs - UUIDv7 (
TrackedGuid.New()) makes event IDs sortable across streams - AOT-compatible - generic append/read with source-generated JSON contexts; no reflection
- Thread-safe -
InMemoryEventStoreuses concurrent collections; Postgres backends rely on transactional inserts with retry
Best Practices¶
- Events are immutable - Never modify events after creation
- Use meaningful event names - OrderCreated not Event1
- Include all relevant data - Events should be self-contained
- Keep events small - Large payloads impact performance
- Version your events - Plan for schema evolution (see Event Upcasting)
- Test with the ledger - Use
InMemoryEventStoreto verify event flow in tests
Related Documentation¶
- Event Store - Full
IEventStoreAPI,AppendAndWaitAsync, decorator stack - Event Store Query - Querying the ledger
- Event Streams - Stream organization and
StreamId - Stream ID - How stream identity is assigned
- Event Upcasting - Evolving stored events
- Receptors - Where events come from
- Perspectives - How events update views