Event Sourcing for .NET,
without the friction

Volcano Blue is a NuGet library that abstracts how events are implemented and the EventStore layer that sits on top of your database.

Read the docs Get it on NuGet View source on GitHub

Two abstractions, one purpose

Focus on your business rules; the event infrastructure is handled for you.

Events

A base class for modeling and implementing domain events: immutable, descriptive, and telling the story of each aggregate.

EventStore

A layer over your database that appends and reads events, hiding persistence details from the domain.

NuGet package

Install it like any .NET dependency and plug it into your API in a few steps.

How it works

State is no longer overwritten: every change becomes an event appended to the history.

Command
→
Aggregate
→
Event
→
EventStore
→
Database

Documentation

A .NET library with abstractions and an Entity Framework Core event store to speed up Event Sourcing development. Model your aggregates, then load and save them with a single type: EventStore.

Installation

dotnet add package VolcanoBlue.EventSourcing

You also need an EF Core provider for your database (SQL Server, SQLite, PostgreSQL, ...).

Concepts↑ Top

TypeNamespacePurpose
EventStore(root)The main entry point. LoadAsync and SaveAsync for your aggregates.
EventSourcedEntityAbstractionsBase class for aggregates: tracks Version, uncommitted events, and applies events.
IEventAbstractionsContract for domain events. EventId is the sequence number in the stream and the entity version.
ISnapshotAbstractionsContract for a materialized state at a given EntityVersion. An entity can implement it to be its own snapshot and decide when one is taken (ShouldTakeSnapshot()).
EventSourcingOptions(root)Instance-based configuration: event types and TimeProvider.
EventStoreDbContext(root)The single EF Core context with the Events and Snapshots tables. It only stores rows (see Storage).
AddEventSourcingExtensionsDI registration of EventSourcingOptions and EventStore.

Versioning↑ Top

Quick start↑ Top

1. Define events.

using VolcanoBlue.EventSourcing.Abstractions;

public sealed record NameChanged(long EventId, string Name) : IEvent;

2. Define an entity. Entities loaded by the store need a parameterless constructor (it can be private or protected).

public sealed class Customer : EventSourcedEntity
{
    public string? Name { get; private set; }

    public void Rename(string name)
    {
        if (string.IsNullOrWhiteSpace(name))
            throw new ArgumentException("Name is required.", nameof(name));

        RaiseEvent(new NameChanged(NextEventId, name));
    }

    protected override void ProcessEvent(IEvent @event)
    {
        switch (@event)
        {
            case NameChanged e:
                Name = e.Name;
                break;
            default:
                throw new InvalidOperationException($"Unsupported event {@event.GetType().Name}.");
        }
    }
}

3. Register everything.

using VolcanoBlue.EventSourcing.Extensions;

// One context, one connection string, one set of migrations (tables Events and Snapshots).
services.AddDbContext<EventStoreDbContext>(o => o.UseSqlite("Data Source=events.db"));

// Registers EventSourcingOptions (singleton) and EventStore (scoped).
// Simplest: scans the calling assembly for every IEvent, so no manual registration is needed.
services.AddEventSourcing();

// Events in other assemblies:
services.AddEventSourcing(typeof(NameChanged).Assembly);

// Or register events one by one:
services.AddEventSourcing(options => options.AddEvent<NameChanged>());

Stored events identify their type by name, so every event type must be known when reading. Registration (scanning or AddEvent) happens at startup and is validated then: duplicate names fail on first use of the options, not when an entity happens to be loaded. The library does not guess the events of an entity, since events may live in another assembly.

Without DI:

var options = new EventSourcingOptions().AddEvent<NameChanged>();
var store = new EventStore(db, options); // db is an EventStoreDbContext

EventSourcingOptions is instance-based (no global state) and is shared as a singleton. It is frozen once an EventStore uses it: further changes throw. AddEvent<TEvent>(), AddEvent(Type) and AddEventsFrom(params Assembly[]) (scanning, skips open generic types) register event types; TimeProvider (default TimeProvider.System) is a settable property. Event types are resolved by name when reading (see Serialization).

4. Save and load. Inject EventStore and use the stream id of your aggregate:

public sealed class CustomerService(EventStore store)
{
    public async Task RenameAsync(string id, string name)
    {
        var streamId = $"Customer-{id}";

        var customer = await store.LoadAsync<Customer>(streamId);
        var entity = customer ?? new Customer();

        entity.Rename(name);
        await store.SaveAsync(streamId, entity); // throws ConcurrencyException if another writer got there first
    }
}

Recommended entity design↑ Top

The Customer above is the minimum. For a real aggregate, this is the shape that works best with the library:

public sealed record OrderPlaced(long EventId, string CustomerId) : IEvent;
public sealed record ItemAdded(long EventId, string Sku, int Quantity) : IEvent;
public sealed record OrderShipped(long EventId) : IEvent;

public sealed record OrderItem(string Sku, int Quantity);

public sealed class Order : EventSourcedEntity, ISnapshot
{
    private readonly List<OrderItem> _items = [];

    private Order() { } // used only by the EventStore

    public static Order Place(string customerId)
    {
        if (string.IsNullOrWhiteSpace(customerId))
            throw new ArgumentException("Customer is required.", nameof(customerId));

        var order = new Order();
        order.RaiseEvent(new OrderPlaced(order.NextEventId, customerId));
        return order;
    }

    public string CustomerId { get; private set; } = "";
    public bool Shipped { get; private set; }
    public IReadOnlyList<OrderItem> Items => _items;
    public long EntityVersion => Version;

    public void AddItem(string sku, int quantity)
    {
        if (Shipped) throw new InvalidOperationException("Order already shipped.");
        if (quantity <= 0) throw new ArgumentOutOfRangeException(nameof(quantity));

        RaiseEvent(new ItemAdded(NextEventId, sku, quantity));
    }

    public void Ship()
    {
        if (Shipped) return; // idempotent
        if (_items.Count == 0) throw new InvalidOperationException("Order has no items.");

        RaiseEvent(new OrderShipped(NextEventId));
    }

    public bool ShouldTakeSnapshot() => EntityVersion % 50 == 0; // every 50 events

    // Only applies state: no validation, no exceptions, no side effects.
    protected override void ProcessEvent(IEvent @event)
    {
        switch (@event)
        {
            case OrderPlaced e: CustomerId = e.CustomerId; break;
            case ItemAdded e: _items.Add(new(e.Sku, e.Quantity)); break;
            case OrderShipped: Shipped = true; break;
        }
    }
}

Why this design:

Store API (EventStore)↑ Top

MethodDescription
LoadAsync<T>(streamId, ct)Creates a T and rehydrates it. When T implements ISnapshot it starts from the last snapshot and replays only the later events; for any other type the Snapshots table is never queried. Without a snapshot, or when the stored one can no longer be read back (for example after refactoring the entity), it replays the whole stream. Returns T?; null when the stream is empty. Events are applied while they stream from the database, never materialized in memory.
SaveAsync<T>(streamId, entity, ct)Appends the uncommitted events and, if the entity implements ISnapshot and ShouldTakeSnapshot() returns true, its snapshot, in one transaction (a single SaveChanges), then calls CommitEvents(). Does nothing when there are no uncommitted events. See Snapshots.

Snapshots↑ Top

Snapshots avoid replaying long streams. The entity is its own snapshot: T : EventSourcedEntity, ISnapshot, where EntityVersion is the last event applied. LoadAsync<T> uses the last snapshot automatically.

public sealed class Customer : EventSourcedEntity, ISnapshot
{
    public long EntityVersion => Version; // restored from the snapshot row's EntityVersion

    public string? Name { get; private set; }

    public bool ShouldTakeSnapshot() => Version % 50 == 0; // optional; the default never snapshots

    // Rename and ProcessEvent as above
}

The snapshot is stored as JSON in the Snapshots table. EventSourcedEntity.Version and UncommittedEvents are [JsonIgnore]; Version is restored from the row's EntityVersion. The entity is serialized by its fields (see Serialization).

SaveAsync takes snapshots for you: if the entity implements ISnapshot and ShouldTakeSnapshot() returns true, the events and the snapshot are written in one transaction: both are stored or neither. Everything is serialized before anything is written, so a snapshot that cannot be serialized (or an unregistered event type) saves nothing and leaves the uncommitted events in place. EntityVersion must equal the last applied event (Version); otherwise SaveAsync throws InvalidOperationException before saving anything, because a wrong value would make loads skip or repeat an event. One snapshot is kept per entity: a stored row with the same version is overwritten, others are removed.

await store.SaveAsync(streamId, customer); // snapshots automatically when ShouldTakeSnapshot() is true

// Later: latest snapshot + only the newer events
var loaded = await store.LoadAsync<Customer>(streamId);

The snapshot id is the stream id. A snapshot that cannot be read back (JsonException) makes LoadAsync replay the whole stream.

Serialization↑ Top

Events and snapshots are serialized with System.Text.Json through reflection.

public sealed class Customer : EventSourcedEntity, ISnapshot
{
    private string? _name;

    public string? Name => _name;
    public long EntityVersion => Version;

    // Rename as above
    public bool ShouldTakeSnapshot() => true;

    protected override void ProcessEvent(IEvent @event) { /* ... */ }
}

Event names:

Native AOT and trimming↑ Top

The library is reflection based by design (entity fields, private constructors, emitted accessors) and is not compatible with trimming or Native AOT.

Storage↑ Top

EventStoreDbContext has public DbSet<Event> Events and DbSet<Snapshot> Snapshots and no read or append methods: serialization and persistence are done by EventStore.

Database setup↑ Top

The library ships no migrations; create the schema yourself. For tests, see the PostgreSQL setup in test/DatabaseFixture.cs:

db.Database.EnsureCreated(); // db is the EventStoreDbContext: creates Events and Snapshots

With migrations, the single EventStoreDbContext gives one migration set for both tables.

Mapping a legacy database↑ Top

If the events table already exists with its own names (database-first), keep the schema as is and map the library's Event entity onto it. Event and Snapshot are public, and EventStoreDbContext accepts an optional EventStoreModelConfiguration(ModelBuilder) callback resolved from DI, which maps both Event and Snapshot. It runs after the default configuration, so your mapping wins. EventStore works on top of the mapped context unchanged.

1. The existing table (DatabaseFirst.db).

CREATE TABLE event_log (
    event_id    INTEGER NOT NULL,   -- Event.Id (the event's EventId)
    stream_name TEXT    NOT NULL,   -- Event.StreamId
    created_utc TEXT    NOT NULL,   -- Event.OccurredAt
    type_name   TEXT    NOT NULL,   -- Event.EventType (full CLR type name)
    payload     TEXT    NOT NULL,   -- Event.Data (JSON)
    PRIMARY KEY (event_id, stream_name)
);

All five columns are required, and the primary key must be (Id, StreamId) to keep the optimistic concurrency behavior.

The snapshot table (only needed if you use snapshots):

CREATE TABLE snapshot_log (
    entity_name    TEXT    NOT NULL,   -- Snapshot.EntityId
    entity_version INTEGER NOT NULL,   -- Snapshot.EntityVersion
    created_utc    TEXT    NOT NULL,   -- Snapshot.Created
    state_json     TEXT    NOT NULL,   -- Snapshot.Data (JSON)
    PRIMARY KEY (entity_name, entity_version)
);

2. Map the columns when registering the context.

using Microsoft.EntityFrameworkCore;
using VolcanoBlue.EventSourcing;
using VolcanoBlue.EventSourcing.Extensions;

// The mapping callback is picked up by the context's constructor.
services.AddSingleton<EventStoreModelConfiguration>(modelBuilder =>
{
    modelBuilder.Entity<Event>(entity =>
    {
        entity.ToTable("event_log");
        entity.Property(e => e.Id).HasColumnName("event_id");
        entity.Property(e => e.StreamId).HasColumnName("stream_name");
        entity.Property(e => e.OccurredAt).HasColumnName("created_utc");
        entity.Property(e => e.EventType).HasColumnName("type_name");
        entity.Property(e => e.Data).HasColumnName("payload");
    });

    modelBuilder.Entity<Snapshot>(entity =>
    {
        entity.ToTable("snapshot_log");
        entity.Property(e => e.EntityId).HasColumnName("entity_name");
        entity.Property(e => e.EntityVersion).HasColumnName("entity_version");
        entity.Property(e => e.Created).HasColumnName("created_utc");
        entity.Property(e => e.Data).HasColumnName("state_json");
    });
});

services.AddDbContext<EventStoreDbContext>(options =>
    options.UseSqlite("Data Source=DatabaseFirst.db"));

services.AddEventSourcing(options => options.AddEvent<NameChanged>());

3. Use it as usual.

await store.SaveAsync("Customer-42", customer);
var loaded = await store.LoadAsync<Customer>("Customer-42");
Don't call EnsureCreated() or run migrations against a legacy database; the schema is owned by the database. Map Snapshot only if you use snapshots; the table must still exist for them to be stored. Existing rows must hold the [EventName] (or the full type name) of the event in the type column and JSON in the data column, as written by this library. EF caches the model per context type, so use a single mapping per application. A working example is in test/DatabaseFirstTests.cs.

Performance tips↑ Top

Built in: entity factories are compiled once per type, reads use AsNoTracking and are applied while streaming, SaveAsync persists the events and the snapshot in one round trip, LoadAsync queries Snapshots only for ISnapshot types, there is a (StreamId, Id) index, and change detection is disabled on EventStoreDbContext.

Recommended in your application:

Project layout↑ Top

src/
  Abstractions/             IEvent, EventSourcedEntity, ISnapshot
  (root)                    EventStore, EventStoreDbContext, EventSourcingOptions, Event, Snapshot, EventStoreModelConfiguration
  Serialization/            event and snapshot serializers, event type registry, PrivateConstructorConverter
  Extensions/               AddEventSourcing (DI registration)
test/                       xUnit tests against PostgreSQL (Testcontainers)

Build and test↑ Top

dotnet build
dotnet test   # the tests start a PostgreSQL container: Docker must be running

Released under the Unlicense.