Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/Eventuous/eventuous/llms.txt

Use this file to discover all available pages before exploring further.

Eventuous separates persistence concerns into focused interfaces: IEventReader for reading events out of streams, IEventWriter for appending new events, and IEventStore for the full lifecycle including stream management. Every concrete event store adapter — KurrentDB, PostgreSQL, MongoDB — implements these interfaces, so your domain and application code stays independent of the underlying storage engine.

Interface hierarchy

IEventStore extends both IEventReader and IEventWriter, making it the single type to register when you want full read-write-manage access:
public interface IEventStore : IEventReader, IEventWriter
{
    Task<bool>  StreamExists(StreamName stream, CancellationToken cancellationToken = default);
    Task TruncateStream(StreamName stream, StreamTruncatePosition truncatePosition,
                        ExpectedStreamVersion expectedVersion, CancellationToken cancellationToken = default);
    Task DeleteStream(StreamName stream, ExpectedStreamVersion expectedVersion,
                      CancellationToken cancellationToken = default);
}
You can also depend on IEventReader or IEventWriter in isolation when a component only needs one capability (e.g., a read model rebuilder needs only IEventReader).

Reading events

IEventReader exposes two streaming methods that return an IAsyncEnumerable<StreamEvent>:
// Read events forward from a given position
IAsyncEnumerable<StreamEvent> ReadEvents(
    StreamName         stream,
    StreamReadPosition start,
    int                count,
    CancellationToken  cancellationToken);

// Read events backward from a given position
IAsyncEnumerable<StreamEvent> ReadEventsBackwards(
    StreamName         stream,
    StreamReadPosition start,
    int                count,
    CancellationToken  cancellationToken);
StreamReadPosition wraps a long revision number. Two well-known sentinels are available:
SentinelMeaning
StreamReadPosition.StartBegin reading from revision 0
StreamReadPosition.EndSeek to the last event
Both methods throw StreamNotFound if the stream does not exist.

Helper extensions

The static class StoreFunctions adds higher-level helpers on top of IEventReader:
// Read all events in a stream, paged automatically (500 events per page)
Task<StreamEvent[]> ReadStream(
    this IEventReader  eventReader,
    StreamName         streamName,
    StreamReadPosition start,
    bool               failIfNotFound    = true,
    CancellationToken  cancellationToken = default);

// Read a fixed window into an array (forward or backward)
Task<StreamEvent[]> ReadEvents(
    this IEventReader  eventReader,
    StreamName         stream,
    StreamReadPosition start,
    int                count,
    bool               failIfNotFound,
    CancellationToken  cancellationToken);
Setting failIfNotFound to false returns an empty array instead of throwing when the stream is absent.

Loading aggregates

AggregatePersistenceExtensions adds LoadAggregate directly on IEventReader:
// Load by explicit stream name
Task<TAggregate> LoadAggregate<TAggregate, TState>(
    StreamName                streamName,
    bool                      failIfNotFound    = true,
    AggregateFactoryRegistry? factoryRegistry   = null,
    CancellationToken         cancellationToken = default)
    where TAggregate : Aggregate<TState>
    where TState     : State<TState>, new();

// Load by aggregate identity (resolves stream name automatically)
Task<TAggregate> LoadAggregate<TAggregate, TState, TId>(
    TId                       aggregateId,
    StreamNameMap?            streamNameMap     = null,
    bool                      failIfNotFound    = true,
    AggregateFactoryRegistry? factoryRegistry   = null,
    CancellationToken         cancellationToken = default)
    where TAggregate : Aggregate<TState>
    where TState     : State<TState>, new()
    where TId        : Id;

Writing events

IEventWriter has one core method and a default multi-stream overload:
// Append events to a single stream
Task<AppendEventsResult> AppendEvents(
    StreamName                          stream,
    ExpectedStreamVersion               expectedVersion,
    IReadOnlyCollection<NewStreamEvent> events,
    CancellationToken                   cancellationToken);

// Append to multiple streams (stores that support atomic multi-stream writes override this)
Task<AppendEventsResult[]> AppendEvents(
    IReadOnlyCollection<NewStreamAppend> appends,
    CancellationToken                    cancellationToken);

Storing aggregates

The StoreAggregate extension on IEventWriter serialises an aggregate’s pending changes and persists them:
// Store by stream name
await writer.StoreAggregate<Booking, BookingState>(
    streamName, booking, amendEvent, cancellationToken);

// Store by aggregate identity (stream name resolved via StreamNameMap)
await writer.StoreAggregate<Booking, BookingState, BookingId>(
    booking, bookingId, streamNameMap, amendEvent, cancellationToken);

Event record types

NewStreamEvent

Represents an event that is about to be written:
public record struct NewStreamEvent(Guid Id, object? Payload, Metadata Metadata);
PropertyTypeDescription
IdGuidUnique identifier for this event instance
Payloadobject?The domain event object
MetadataMetadataKey/value bag attached to the event

StreamEvent

Represents an event that was read back from the store:
public record struct StreamEvent(
    Guid     Id,
    object?  Payload,
    Metadata Metadata,
    string   ContentType,
    long     Revision,
    DateTime Created     = default,
    bool     FromArchive = false);
Revision is the zero-based position of the event within its stream. FromArchive is set when the event was loaded via a tiered/archive reader.

AppendEventsResult

Returned from every successful append:
public record AppendEventsResult(ulong GlobalPosition, long NextExpectedVersion);
PropertyTypeDescription
GlobalPositionulongGlobal log position of the last written event
NextExpectedVersionlongStream version to use in the next conditional append
AppendEventsResult.NoOp is returned when there are no changes to persist.

ExpectedStreamVersion

ExpectedStreamVersion enforces optimistic concurrency. Pass it to any append or truncate/delete call:
// New stream — fails if the stream already exists
ExpectedStreamVersion.NoStream   // Value = -1

// Unconditional write — always succeeds regardless of current version
ExpectedStreamVersion.Any        // Value = -2

// Conditional write at a known version
new ExpectedStreamVersion(42)    // Must match current stream revision
If the stream’s current version does not match the expected version, the store throws an OptimisticConcurrencyException.

AmendEvent delegate

AmendEvent is a hook that lets you inject additional metadata into every NewStreamEvent before it is written to the store. This is typically used to attach correlation IDs, causation IDs, or user identity from the current request context:
// Simple amendment — no extra context
public delegate NewStreamEvent AmendEvent(NewStreamEvent originalEvent);

// Context-aware amendment
public delegate NewStreamEvent AmendEvent<in T>(NewStreamEvent originalEvent, T context);
Example — stamping a correlation ID from the HTTP context:
AmendEvent amendEvent = original => original with {
    Metadata = original.Metadata.With("correlationId", correlationId)
};
Pass the delegate to StoreAggregate or Store helpers; it is applied to every event in the batch.

Dependency injection

Eventuous.Extensions.DependencyInjection provides three registration helpers. Each one automatically wires up OpenTelemetry tracing when diagnostics are enabled:
// Register a full IEventStore (also registers IEventReader and IEventWriter)
builder.Services.AddEventStore<KurrentDBEventStore>();

// Register separate reader / writer implementations
builder.Services.AddEventReader<MyEventReader>();
builder.Services.AddEventWriter<MyEventWriter>();

// Register a single class that implements both reader and writer
builder.Services.AddEventReaderWriter<MyReadWriteStore>();
AddEventReader, AddEventWriter, and AddEventReaderWriter use TryAddSingleton, so a previously registered implementation is not overwritten. AddEventStore registers IEventStore, IEventReader, and IEventWriter together; when OpenTelemetry diagnostics are enabled it uses AddSingleton for the traced wrappers, so call it only once.
// Factory overload — useful when the constructor needs runtime configuration
builder.Services.AddEventStore<KurrentDBEventStore>(
    sp => new KurrentDBEventStore(sp.GetRequiredService<EventStoreClient>()));

Implementing a custom event store

Implement IEventStore to integrate any storage backend. The minimum surface is:
public class MyEventStore : IEventStore {
    public IAsyncEnumerable<StreamEvent> ReadEvents(
        StreamName stream, StreamReadPosition start, int count, CancellationToken ct) { /* ... */ }

    public IAsyncEnumerable<StreamEvent> ReadEventsBackwards(
        StreamName stream, StreamReadPosition start, int count, CancellationToken ct) { /* ... */ }

    public Task<AppendEventsResult> AppendEvents(
        StreamName stream, ExpectedStreamVersion expectedVersion,
        IReadOnlyCollection<NewStreamEvent> events, CancellationToken ct) { /* ... */ }

    public Task<bool>  StreamExists(StreamName stream, CancellationToken ct) { /* ... */ }
    public Task TruncateStream(StreamName stream, StreamTruncatePosition pos,
                               ExpectedStreamVersion ver, CancellationToken ct) { /* ... */ }
    public Task DeleteStream(StreamName stream, ExpectedStreamVersion ver,
                             CancellationToken ct) { /* ... */ }
}

Build docs developers (and LLMs) love