Event Sourcing with Orleans 10: The 75% Easier Architecture

Event Sourcing with Orleans 10: The 75% Easier Architecture

Traditional event sourcing asks you to solve five problems independently: concurrency control, aggregate hydration, snapshot management, event persistence, and projection delivery. Five infrastructure concerns, five failure modes, and at least three NuGet packages you'll regret choosing.

Orleans already solved every one of those problems. It just solved them for the actor model, not for event sourcing specifically. JournaledGrain is the bridge. It takes what Orleans already guarantees (single-threaded activation, virtual actor lifecycle, cluster-wide distribution) and exposes it as a proper event sourcing API. The result: production event sourcing with CQRS in under 200 lines of grain code. No external event store. No concurrency locks. No separate projection service.


Why Orleans Is a Natural Fit for Event Sourcing

The alignment between Orleans concepts and event sourcing concepts isn't a coincidence.

Grains are aggregates. One grain instance per aggregate ID, activated on demand. The single-threaded activation model guarantees single-writer semantics without you writing a single lock or optimistic concurrency check. Two commands hitting the same OrderGrain("order-123") are processed sequentially by design. That's the hardest problem in event sourcing, solved at the framework level.

Virtual actor lifecycle is aggregate hydration. When a grain activates, Orleans loads its state. When it dehydrates, Orleans persists it. You never write "load events from stream, replay into state" boilerplate. The framework handles activation and deactivation transparently.

The silo cluster is your partitioned event processor. Orleans distributes grains across silos automatically. Scale out by adding nodes. Grain placement handles partitioning. No consumer groups, no partition rebalancing, no offset tracking.

Here's what you're replacing:

Traditional ES Stack Orleans Equivalent
Event Store (Kafka/EventStoreDB) Log Consistency Provider
Optimistic concurrency / locks Single-threaded grain activation
Snapshot service Built into CustomStorage
Projection service Orleans Streams + subscriber grains
Partition management Orleans grain placement
Aggregate repository Grain activation lifecycle

Six infrastructure concerns collapsed into features you already get by using Orleans.


JournaledGrain API: A Real Order Aggregate

Let's build something real. An OrderGrain that accepts commands, validates them, raises events, and maintains state. First, the events and state:

[GenerateSerializer]
public abstract record OrderEvent;

[GenerateSerializer]
public sealed record OrderPlacedEvent(
    [property: Id(0)] string CustomerId,
    [property: Id(1)] List<OrderLine> Lines,
    [property: Id(2)] DateTime PlacedAt) : OrderEvent;

[GenerateSerializer]
public sealed record OrderShippedEvent(
    [property: Id(0)] string TrackingNumber,
    [property: Id(1)] DateTime ShippedAt) : OrderEvent;

[GenerateSerializer]
public sealed record OrderCancelledEvent(
    [property: Id(0)] string Reason,
    [property: Id(1)] DateTime CancelledAt) : OrderEvent;

[GenerateSerializer]
public sealed record OrderLine(
    [property: Id(0)] string ProductId,
    [property: Id(1)] int Quantity,
    [property: Id(2)] decimal UnitPrice);

Now the state class. Orleans uses dynamic dispatch to route events to the correct Apply method. One method per event type, no switch statements:

[GenerateSerializer]
public sealed class OrderState
{
    [Id(0)] public string CustomerId { get; set; } = "";
    [Id(1)] public List<OrderLine> Lines { get; set; } = [];
    [Id(2)] public OrderStatus Status { get; set; } = OrderStatus.None;
    [Id(3)] public string? TrackingNumber { get; set; }
    [Id(4)] public DateTime? PlacedAt { get; set; }
    [Id(5)] public DateTime? ShippedAt { get; set; }

    public void Apply(OrderPlacedEvent e)
    {
        CustomerId = e.CustomerId;
        Lines = e.Lines;
        Status = OrderStatus.Placed;
        PlacedAt = e.PlacedAt;
    }

    public void Apply(OrderShippedEvent e)
    {
        TrackingNumber = e.TrackingNumber;
        Status = OrderStatus.Shipped;
        ShippedAt = e.ShippedAt;
    }

    public void Apply(OrderCancelledEvent e)
    {
        Status = OrderStatus.Cancelled;
    }
}

public enum OrderStatus { None, Placed, Shipped, Cancelled }

And the grain itself. This is where Orleans shines. Command validation, event raising, and confirmation in a clean, linear flow:

public interface IOrderGrain : IGrainWithStringKey
{
    Task PlaceOrder(string customerId, List<OrderLine> lines);
    Task ShipOrder(string trackingNumber);
    Task CancelOrder(string reason);
    Task<OrderState> GetState();
}

[LogConsistencyProvider(ProviderName = "CustomStorage")]
public sealed class OrderGrain :
    JournaledGrain<OrderState, OrderEvent>,
    IOrderGrain
{
    public async Task PlaceOrder(string customerId, List<OrderLine> lines)
    {
        if (State.Status != OrderStatus.None)
            throw new InvalidOperationException("Order already placed.");

        RaiseEvent(new OrderPlacedEvent(customerId, lines, DateTime.UtcNow));
        await ConfirmEvents();
    }

    public async Task ShipOrder(string trackingNumber)
    {
        if (State.Status != OrderStatus.Placed)
            throw new InvalidOperationException("Order must be in Placed status to ship.");

        RaiseEvent(new OrderShippedEvent(trackingNumber, DateTime.UtcNow));
        await ConfirmEvents();
    }

    public async Task CancelOrder(string reason)
    {
        if (State.Status is OrderStatus.Shipped or OrderStatus.Cancelled)
            throw new InvalidOperationException("Cannot cancel a shipped or already-cancelled order.");

        RaiseEvent(new OrderCancelledEvent(reason, DateTime.UtcNow));
        await ConfirmEvents();
    }

    public Task<OrderState> GetState() => Task.FromResult(State);
}

That's the entire aggregate. No repository. No unit-of-work. No event stream subscription. No snapshot loading code. The grain IS the aggregate, and Orleans handles the rest.

TentativeState vs State

State reflects only confirmed events (those persisted to storage). TentativeState includes unconfirmed events that have been raised but not yet persisted. This matters when you call RaiseEvent() without immediately calling ConfirmEvents():

RaiseEvent(new OrderPlacedEvent(customerId, lines, DateTime.UtcNow));

// TentativeState.Status == OrderStatus.Placed  ✓
// State.Status == OrderStatus.None             (not yet persisted)

await ConfirmEvents();

// State.Status == OrderStatus.Placed           ✓ (now confirmed)

Use TentativeState when you need to validate subsequent commands against pending but unconfirmed changes within the same activation.

RaiseConditionalEvent: Optimistic Concurrency Across Grains

For scenarios where multiple grains coordinate (rare, but real), RaiseConditionalEvent returns false if the underlying version changed since your last read:

bool success = await RaiseConditionalEvent(new OrderShippedEvent(tracking, DateTime.UtcNow));
if (!success)
{
    await RefreshNow(); // reload latest state
    // retry or fail
}

Within a single grain, you rarely need this. The single-threaded activation already prevents conflicts. It's useful when your CustomStorage implementation coordinates with external systems.


Log Consistency Providers: Picking the Right One

Orleans ships three log consistency providers. Each makes a different trade-off between simplicity and production fitness.

StateStorage (Snapshot Only)

Stores the current state snapshot, a version number, and an ETag. Events are applied in memory but not persisted individually. You cannot call RetrieveConfirmedEvents() because the event history is gone after confirmation.

silo.AddStateStorageBasedLogConsistencyProvider("StateStorage");

Use when: You want event sourcing's programming model (raise events, apply transitions) but don't need an audit log. Good for aggregates where current state is all that matters.

LogStorage (Full Event Log, Dev Only)

Stores the complete event sequence as a serialised list. Supports RetrieveConfirmedEvents(). The catch: the entire log is deserialised on every grain activation. An aggregate with 10,000 events loads all 10,000 events into memory on activation.

silo.AddLogStorageBasedLogConsistencyProvider("LogStorage");

Use when: Local development, integration tests, or prototyping. Never in production.

CustomStorage (Production Grade)

You implement ICustomStorageInterface<TState, TDelta> and control everything: how snapshots are stored, how events are appended, when to snapshot, and which database to use.

silo.AddCustomStorageBasedLogConsistencyProvider("CustomStorage");

Use when: Production. Always.

Decision Table

Provider Events Persisted Snapshots RetrieveEvents Production Ready
StateStorage No Yes No Yes (no audit)
LogStorage Yes No Yes No
CustomStorage Yes (you control) Yes (you control) Yes (you control) Yes

Production Snapshot Strategy with CustomStorage

Here's a complete ICustomStorageInterface implementation with a snapshot-every-50-events strategy. This is the pattern I use in production:

[LogConsistencyProvider(ProviderName = "CustomStorage")]
public sealed class OrderGrain :
    JournaledGrain<OrderState, OrderEvent>,
    IOrderGrain,
    ICustomStorageInterface<OrderState, OrderEvent>
{
    private readonly IEventStore _eventStore; // your DB abstraction
    private const int SnapshotInterval = 50;

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

    public async Task<KeyValuePair<int, OrderState>> ReadStateFromStorage()
    {
        // Try loading the latest snapshot
        var snapshot = await _eventStore.LoadSnapshotAsync<OrderState>(
            this.GetPrimaryKeyString());

        if (snapshot is null)
            return new KeyValuePair<int, OrderState>(0, new OrderState());

        // Replay events since the snapshot
        var events = await _eventStore.LoadEventsAsync<OrderEvent>(
            this.GetPrimaryKeyString(),
            fromVersion: snapshot.Version);

        foreach (var e in events)
            snapshot.State.Apply((dynamic)e);

        return new KeyValuePair<int, OrderState>(
            snapshot.Version + events.Count,
            snapshot.State);
    }

    public async Task<bool> ApplyUpdatesToStorage(
        IReadOnlyList<OrderEvent> updates,
        int expectedVersion)
    {
        // Append events
        bool success = await _eventStore.AppendEventsAsync(
            this.GetPrimaryKeyString(),
            updates,
            expectedVersion);

        if (!success)
            return false; // version conflict

        // Snapshot if we've crossed the interval threshold
        int newVersion = expectedVersion + updates.Count;
        if (newVersion / SnapshotInterval > expectedVersion / SnapshotInterval)
        {
            await _eventStore.SaveSnapshotAsync(
                this.GetPrimaryKeyString(),
                State, // confirmed state after apply
                newVersion);
        }

        return true;
    }

    // ... grain methods (PlaceOrder, ShipOrder, etc.) remain the same
}

The IEventStore abstraction is yours to implement against PostgreSQL, Azure Table Storage, CosmosDB, or whatever your team already runs. The key insight: Orleans handles all the concurrency and activation semantics. Your storage layer just needs to be an append-only log with version checks.

A minimal PostgreSQL schema for this:

CREATE TABLE order_events (
    aggregate_id   TEXT NOT NULL,
    version        INT NOT NULL,
    event_type     TEXT NOT NULL,
    payload        JSONB NOT NULL,
    created_at     TIMESTAMPTZ DEFAULT now(),
    PRIMARY KEY (aggregate_id, version)
);

CREATE TABLE order_snapshots (
    aggregate_id   TEXT NOT NULL PRIMARY KEY,
    version        INT NOT NULL,
    state          JSONB NOT NULL,
    created_at     TIMESTAMPTZ DEFAULT now()
);

The version column on order_events gives you the optimistic concurrency check in ApplyUpdatesToStorage. A simple INSERT with a unique constraint violation tells you the expected version was stale.


CQRS with Orleans Streams: Separating Read and Write

Event sourcing without CQRS is half the picture. You need read models. Orleans gives you two clean patterns:

Pattern A: Query Methods on the Same Grain

The simplest approach. Your grain is both the write model and the read model:

public interface IOrderGrain : IGrainWithStringKey
{
    // Commands (write)
    Task PlaceOrder(string customerId, List<OrderLine> lines);
    Task ShipOrder(string trackingNumber);

    // Queries (read)
    Task<OrderState> GetState();
    Task<IReadOnlyList<OrderEvent>> GetHistory(int fromVersion, int toVersion);
}

This works when your read model IS your aggregate state. No projection needed. For many domains, this is enough.

Pattern B: Orleans Streams for Separate Read Models

When you need denormalised read models, dashboard aggregations, or cross-aggregate views, publish confirmed events to an Orleans Stream and subscribe from dedicated read-side grains:

[LogConsistencyProvider(ProviderName = "CustomStorage")]
public sealed class OrderGrain :
    JournaledGrain<OrderState, OrderEvent>,
    IOrderGrain,
    ICustomStorageInterface<OrderState, OrderEvent>
{
    private IAsyncStream<OrderEvent>? _stream;

    public override Task OnActivateAsync(CancellationToken ct)
    {
        var provider = this.GetStreamProvider("StreamProvider");
        _stream = provider.GetStream<OrderEvent>(
            StreamId.Create("Orders", this.GetPrimaryKeyString()));
        return base.OnActivateAsync(ct);
    }

    protected override void OnStateChanged()
    {
        // Publish confirmed events to the stream
        foreach (var e in RetrieveConfirmedEvents(Version - 1, Version).Result)
        {
            _stream!.OnNextAsync(e).Ignore();
        }
    }

    // ... rest of grain implementation
}

A subscriber grain that builds a customer's order summary:

[ImplicitStreamSubscription("Orders")]
public sealed class CustomerOrdersProjectionGrain : Grain, IGrainWithStringKey
{
    private readonly List<OrderSummary> _orders = [];

    public override async Task OnActivateAsync(CancellationToken ct)
    {
        var provider = this.GetStreamProvider("StreamProvider");
        var stream = provider.GetStream<OrderEvent>(
            StreamId.Create("Orders", this.GetPrimaryKeyString()));

        await stream.SubscribeAsync((e, token) =>
        {
            if (e is OrderPlacedEvent placed)
            {
                _orders.Add(new OrderSummary(
                    this.GetPrimaryKeyString(),
                    placed.Lines.Sum(l => l.Quantity * l.UnitPrice),
                    placed.PlacedAt));
            }
            return Task.CompletedTask;
        });
    }
}

The stream subscription is the entire projection infrastructure. No Kafka. No dedicated worker service. No offset management. Orleans handles delivery guarantees within the cluster.


.NET 10 Setup & Configuration

Getting this running on .NET 10 with Orleans 10.2.1:

NuGet Packages

<ItemGroup>
    <PackageReference Include="Microsoft.Orleans.Server" Version="10.2.1" />
    <PackageReference Include="Microsoft.Orleans.EventSourcing" Version="10.2.1" />
    <PackageReference Include="Microsoft.Orleans.Streaming" Version="10.2.1" />
</ItemGroup>

Silo Configuration

var builder = Host.CreateApplicationBuilder(args);

builder.UseOrleans(silo =>
{
    silo.UseLocalhostClustering() // swap for Azure/ADO.NET clustering in prod
        .AddCustomStorageBasedLogConsistencyProvider("CustomStorage")
        .AddMemoryStreams("StreamProvider")
        .AddMemoryGrainStorage("PubSubStore");
});

builder.Services.AddSingleton<IEventStore, PostgresEventStore>();

var app = builder.Build();
await app.RunAsync();

For production, replace UseLocalhostClustering() with your preferred membership provider (Azure Table, ADO.NET with PostgreSQL, etc.) and AddMemoryStreams with a persistent stream provider if you need guaranteed delivery across silo restarts.

Minimal API Integration

Expose your grains through ASP.NET Core Minimal APIs:

var builder = WebApplication.CreateBuilder(args);

builder.UseOrleans(silo =>
{
    silo.UseLocalhostClustering()
        .AddCustomStorageBasedLogConsistencyProvider("CustomStorage")
        .AddMemoryStreams("StreamProvider")
        .AddMemoryGrainStorage("PubSubStore");
});

var app = builder.Build();

app.MapPost("/orders/{id}/place", async (
    string id,
    PlaceOrderRequest request,
    IGrainFactory grains) =>
{
    var grain = grains.GetGrain<IOrderGrain>(id);
    await grain.PlaceOrder(request.CustomerId, request.Lines);
    return Results.Created($"/orders/{id}", null);
});

app.MapPost("/orders/{id}/ship", async (
    string id,
    ShipOrderRequest request,
    IGrainFactory grains) =>
{
    var grain = grains.GetGrain<IOrderGrain>(id);
    await grain.ShipOrder(request.TrackingNumber);
    return Results.Ok();
});

app.MapGet("/orders/{id}", async (string id, IGrainFactory grains) =>
{
    var grain = grains.GetGrain<IOrderGrain>(id);
    return Results.Ok(await grain.GetState());
});

await app.RunAsync();

Each API call resolves to a single grain activation. The grain handles command validation, event persistence, and state transitions. Your API layer is just routing. No business logic, no concurrency management, no transaction scopes.


The 75% You're Not Writing

Let me be explicit about what Orleans eliminated from your architecture:

  1. No event store infrastructure. No EventStoreDB cluster, no Kafka brokers, no ZooKeeper.
  2. No concurrency code. No optimistic locking in your domain logic, no retry loops.
  3. No aggregate repository. No "load from stream" pattern, no hydration code.
  4. No snapshot service. Built into your CustomStorage implementation, about 10 lines of code.
  5. No projection worker service. Orleans Streams + subscriber grains, same deployment.
  6. No partition management. Orleans grain placement does this transparently.

If you're already running Orleans for other reasons (and if you're building distributed .NET systems at scale, you probably should be), then JournaledGrain is the event sourcing answer that's been sitting in your dependency tree the whole time.

Stop evaluating EventStoreDB vs. Marten vs. Axon. If your domain fits the actor model (most do, one aggregate instance = one grain), JournaledGrain<TState, TEvent> gives you event sourcing with the infrastructure cost of a single NuGet package.


What's Next

If you want to go deeper on Orleans patterns, I've covered building MCP servers in C# that could serve as tool interfaces for your grains, and the .NET rate limiting deep dive pairs well with protecting your grain endpoints. For the PostgreSQL event store implementation backing ICustomStorageInterface, I'll cover the full ADO.NET implementation in a follow-up post.

The challenge: if you're already running Orleans, pick your next aggregate and implement it as a JournaledGrain. You'll wonder why you ever considered anything else.