Skip to content

Event Store Migration: Marten → Whizbang

Verified by tests

EventStoreContractTests, InMemoryEventStoreTests, EventStoreAppendBatchTests, DapperPostgresEventStoreTests, EventStoreTransformerTests — library CI run #31657041675 (2026-08-13)

This guide covers migrating from Marten's document store and event sourcing to Whizbang's IEventStore.

Key Differences

Aspect Marten Whizbang
Session IDocumentSession Direct IEventStore injection
Append session.Events.Append() eventStore.AppendAsync<T>()
Save session.SaveChangesAsync() Implicit (per-append)
Stream ID Inferred or explicit Explicit parameter (or [StreamId] property)
Envelope Automatic Automatic (MessageEnvelope<T> created/retrieved for you)
Versioning ExpectedVersion Monotonic sequence numbers assigned per stream

Basic Event Store Operations

Appending Events

Marten:

Appending Events

public class OrderService {
    private readonly IDocumentStore _store;

    public async Task CreateOrderAsync(CreateOrderCommand cmd) {
        await using var session = _store.LightweightSession();

        var orderId = Guid.NewGuid();
        var @event = new OrderCreated(orderId, cmd.CustomerId, cmd.Items);

        session.Events.Append(orderId, @event);
        await session.SaveChangesAsync();
    }
}

Whizbang:

Appending Events - CreateOrderReceptor

public class CreateOrderReceptor : IReceptor<CreateOrder, OrderCreated> {
    private readonly IEventStore _eventStore;

    public CreateOrderReceptor(IEventStore eventStore) {
        _eventStore = eventStore;
    }

    public async ValueTask<OrderCreated> HandleAsync(
        CreateOrder message,
        CancellationToken ct = default) {

        Guid streamId = TrackedGuid.NewMedo();  // time-ordered UUIDv7
        var @event = new OrderCreated(streamId, message.CustomerId, message.Items);

        // The message overload creates the envelope for you. If the message was
        // dispatched through IDispatcher, its existing envelope is retrieved from
        // IEnvelopeRegistry so tracing context (hops, correlation, causation) is preserved.
        await _eventStore.AppendAsync(streamId, @event, ct);
        return @event;
    }
}

Reading Events

Marten:

Reading Events

public async Task<Order> RehydrateOrderAsync(Guid orderId) {
    await using var session = _store.QuerySession();

    var events = await session.Events
        .FetchStreamAsync(orderId);

    var order = new Order();
    foreach (var @event in events) {
        order.Apply(@event.Data);
    }
    return order;
}

Whizbang:

Reading Events (2)

public async Task<Order> RehydrateOrderAsync(Guid orderId, CancellationToken ct) {
    var order = new Order();

    // ReadAsync returns IAsyncEnumerable<MessageEnvelope<T>> - no await on the call itself
    await foreach (var envelope in _eventStore.ReadAsync<IOrderEvent>(orderId, fromSequence: 0, ct)) {
        order.Apply(envelope.Payload);
    }
    return order;
}

Multiple Events in One Append

Marten:

Multiple Events in One Append

await using var session = _store.LightweightSession();

session.Events.Append(orderId,
    new OrderCreated(orderId),
    new OrderItemAdded(orderId, item1),
    new OrderItemAdded(orderId, item2)
);

await session.SaveChangesAsync();

Whizbang:

Multiple Events in One Append (2)

// Append multiple events individually (ordered per stream)
await _eventStore.AppendAsync(orderId, new OrderCreated(orderId), ct);
await _eventStore.AppendAsync(orderId, new OrderItemAdded(orderId, item1), ct);
await _eventStore.AppendAsync(orderId, new OrderItemAdded(orderId, item2), ct);

// Or use AppendBatchAsync for bulk appends (single round-trip on
// backends that override it; entries land in the supplied order)
await _eventStore.AppendBatchAsync(
    new[] {
        (orderId, envelope1),
        (orderId, envelope2),
        (orderId, envelope3)
    },
    ct);

Stream Management

Starting a Stream

Marten (implicit stream creation):

Starting a Stream

// Marten creates stream automatically
session.Events.Append(newStreamId, firstEvent);

Whizbang (explicit stream):

Starting a Stream (2)

// Whizbang also creates the stream on first append
Guid streamId = TrackedGuid.NewMedo();  // UUIDv7 for time-ordering
await _eventStore.AppendAsync(streamId, firstEvent, ct);

Checking Stream Existence

Marten:

Checking Stream Existence

var state = await session.Events.FetchStreamStateAsync(streamId);
var exists = state != null;

Whizbang:

Checking Stream Existence (2)

// GetLastSequenceAsync returns the highest sequence number in the stream,
// or -1 when the stream doesn't exist or is empty
var lastSequence = await _eventStore.GetLastSequenceAsync(streamId, ct);
var exists = lastSequence >= 0;

Concurrency Control

Marten Expected Version

Marten Expected Version

// Marten: Optimistic concurrency via expected version
session.Events.Append(orderId, expectedVersion: 5, newEvent);
await session.SaveChangesAsync();  // Throws if version != 5

Whizbang Sequence-Based

Whizbang Sequence-Based

// Whizbang: Sequence-based concurrency
// Events get monotonic sequence numbers per stream, assigned at append time
// by the store's internal sequence provider - there is no expectedVersion parameter.

await _eventStore.AppendAsync(streamId, @event, ct);

// Concurrent appends to the same stream are resolved internally:
// the PostgreSQL stores retry on sequence conflicts with backoff and
// only surface an exception after exhausting the retry budget.

Session Patterns

Marten Unit of Work

Marten Unit of Work

// Marten: Batch multiple operations
await using var session = _store.LightweightSession();

session.Events.Append(order1Id, event1);
session.Events.Append(order2Id, event2);
session.Store(document);

await session.SaveChangesAsync();  // All-or-nothing

Whizbang Batched Append

Whizbang Batched Append

// Whizbang: each AppendAsync commits independently - there is no session.
// For multi-event writes, use AppendBatchAsync. Entries land in the supplied
// order; backends MAY execute the batch in a single transaction (the default
// implementation loops serially and makes no atomicity guarantee).
await _eventStore.AppendBatchAsync(
    new[] {
        (order1Id, envelope1),
        (order2Id, envelope2)
    },
    ct);

Note: In typical Whizbang applications, atomicity between business writes and event persistence is provided by the work coordinator (outbox pattern) rather than a user-managed transaction — see Outbox Migration.

Query Patterns

Marten Query Session

Marten Query Session

// Marten: Query events directly
await using var session = _store.QuerySession();

var recentOrders = await session.Events
    .QueryRawEventDataOnly<OrderCreated>()
    .Where(e => e.Timestamp > cutoff)
    .ToListAsync();

Whizbang Event Queries

Whizbang Event Queries

// Whizbang: range queries are per stream, bounded by event IDs
// (afterEventId is exclusive; pass null to start from the beginning)
List<MessageEnvelope<OrderCreated>> events =
    await _eventStore.GetEventsBetweenAsync<OrderCreated>(
        streamId,
        afterEventId: lastCheckpointEventId,
        upToEventId: currentEventId,
        ct);

foreach (var envelope in events) {
    // Process event
}

// Cross-type reads for one stream use ReadPolymorphicAsync:
await foreach (var envelope in _eventStore.ReadPolymorphicAsync(
    streamId, fromEventId: null, eventTypes: new[] { typeof(OrderCreated), typeof(OrderShipped) }, ct)) {
    // envelope.Payload is IEvent
}

There is no cross-stream LINQ query surface on IEventStore — for query-shaped access, use a perspective + lens (see Projection Migration).

Envelopes Are Created For You

You rarely construct MessageEnvelope<T> by hand. It has several required members (MessageId, Payload, DispatchContext, Hops) that the framework populates:

  • Dispatched messages: IDispatcher creates the envelope and registers it with IEnvelopeRegistry.
  • Direct appends: the AppendAsync(streamId, message, ct) overload looks up the message's existing envelope via IEnvelopeRegistry (preserving hops, correlation, and causation) or creates a minimal one if none exists.

Envelope-Free Append

// Preferred: append the message; envelope handling is automatic
await _eventStore.AppendAsync(streamId, orderCreatedEvent, ct);

The envelope-taking overload AppendAsync(streamId, envelope, ct) exists for advanced scenarios where you already hold a MessageEnvelope<T> (for example, relaying events received from a transport).

Migration Checklist

  • [ ] Replace IDocumentStore with IEventStore
  • [ ] Replace IDocumentSession with direct IEventStore injection
  • [ ] Replace session.Events.Append() with eventStore.AppendAsync<T>() (message overload — envelopes are automatic)
  • [ ] Use TrackedGuid.NewMedo() for new stream IDs (time-ordered UUIDv7)
  • [ ] Remove session.SaveChangesAsync() (each append commits; use AppendBatchAsync for bulk)
  • [ ] Remove expectedVersion arguments (sequences are assigned automatically)
  • [ ] Move cross-stream event queries to perspectives + lenses

Previous: Projection Migration | Next: Transport Configuration