Event Souring Library that allows storing Events into a Database. Comes with an implementation of a Azure CosmosDb EventStore.
The CosmosDb Implementation uses the following Microsoft libraries:
Microsoft.Azure.CosmosMicrosoft.Extensions.Configuration.AbstractionsMicrosoft.Extensions.Configuration.BinderMicrosoft.Extensions.Logging.AbstractionsMicrosoft.Extensions.Options
and comes with included compatability to the dependency injection of the .NET Core (Web)HostBuilder.
A Sample can be found in the samples directory.
The library brings a couple of already implemented EventStore packages:
- Papst.EventStore.FileSystem
Note: This is for testing purpose only! - Papst.EventStore.EntityFrameworkCore
- Papst.EventStore.AzureCosmos
- Papst.EventStore.MongoDB
- Events must be attributed with the
EventNameattribute. - EventAggregators, implementing the interface
IEventAggregatoror implementing the abstract classEventAggregatorBaseare automatically added to the Dependency Injection.
It is possible to have multiple EventName attributes on just one event. With the attribute IsWriteName it is possible to define which Identifier is used when writing the event.
[EventName(Name = "MyEventV1", IsWriteName = false)]
[EventName(Name = "MyEventV2")]
public class MyEventsourcingEvent
{
}Reading Events named MyEventV1 or MyEventV2 will deserialize them into a MyEventsourcingEvent.
Writing MyEventsourcingEvent to the Event Stream, will serialize them and name them MyEventV2.
Note: IsWriteName is true by default!
In addition to hand-written aggregators (deriving from EventAggregatorBase<TEntity, TEvent>), the code
generator can generate the aggregator for you from a single attribute on the event. This is opt-in and
works in parallel with the classic API — you can mix generated and hand-written aggregators freely, and an
event only gets a generated aggregator when it is annotated.
Annotate an event with [EventAggregation<TEntity>]. The generator emits an IEventAggregator<TEntity, TEvent>
that copies the event's properties onto the target entity by matching property name:
[EventName<Order>("OrderShipped")]
[EventAggregation<Order>]
public sealed record OrderShippedEvent(
OrderStatus Status,
string DeliveryTrackingCode,
DateTimeOffset PickupDate,
DateTimeOffset EstimatedArrivalDate);Every event property that has a settable, equally named property on the entity is assigned. As with any
generated event, the event still needs an EventName for type resolution, and the generated aggregator is
registered by the existing AddCodeGeneratedEvents() extension.
Use [AggregationIgnore] to exclude a property from aggregation, and [AggregationProperty("TargetName")] to
map a property onto a differently named property on the entity (Note: an EventName attribute is always necessary):
[EventAggregation<Order>]
public sealed record OrderRenamed(
[property: AggregationProperty(nameof(Order.CustomerName))] string DisplayName, // written to Order.CustomerName
[property: AggregationIgnore] string CorrelationId // never mapped
);By default null event values are skipped (the existing entity value is kept). Set
SkipNullValues = false on the attribute to always write the value (including null), or override the
behaviour for a single property with [SkipWhenNull(bool)]:
[EventAggregation<Order>(SkipNullValues = false)]
public sealed record OrderContactChanged(
string? Email, // written even when null
[property: SkipWhenNull(true)] string? Note // kept when null
);PropertyPath selects a nested object on the entity as the aggregation target (dot-separated). Intermediate
null links are instantiated automatically:
[EventAggregation<Order>(PropertyPath = nameof(Order.ShippingAddress))]
public sealed record ShippingAddressSet(string? City, string? Zip);When PropertyPath points at a dictionary or collection, mark the event property that identifies the entry.
Missing entries are created and added (upsert):
// Dictionary<string, OrderLine> Lines — upsert the entry under the given key
[EventAggregation<Order>(PropertyPath = nameof(Order.Lines))]
public sealed record LineUpserted([property: AggregationDictionaryKey] string Sku, int Quantity);
// List<OrderTag> Tags — upsert the item whose Id equals the event value
[EventAggregation<Order>(PropertyPath = nameof(Order.Tags))]
public sealed record TagUpserted([property: AggregationCollectionKey("Id")] string TagId, string? Label);A working example lives in the Orders module of
samples/SampleInMemoryAspNetApi/ (OrderShippedEvent).
Please refer to the documentation in the relevant implementation sources:
The ILowLevelEventStream interface provides a low-level append API that accepts a raw JObject together with an explicit event type name:
Task AppendAsync(
Guid id,
string eventType,
JObject evt,
EventStreamMetaData? metaData = null,
CancellationToken cancellationToken = default);This API is implemented by all EventStore providers (Azure Cosmos, Entity Framework Core, MongoDB, FileSystem and InMemory).
ILowLevelEventStream is not resolved directly from IEventStore. Instead, obtain an IEventStream first and then cast it to ILowLevelEventStream when the underlying provider supports it:
IEventStream stream = await eventStore.GetAsync(streamId, cancellationToken);
if (stream is ILowLevelEventStream lowLevelStream)
{
await lowLevelStream.AppendAsync(
Guid.NewGuid(),
"MyExternalEvent",
JObject.Parse("""{ \"value\": 42 }"""),
cancellationToken: cancellationToken);
}This is intended for scenarios where the event payload or event type is not known at compile time. If you work with strongly typed events, prefer IEventStream.AppendAsync<TEvent>().
The IEventStore interface offers a DeleteAsync method that permanently removes an event stream together with all of its documents (events, snapshots and the stream index):
Task DeleteAsync(
Guid streamId,
CancellationToken cancellationToken = default);await eventStore.DeleteAsync(streamId, cancellationToken);Semantics:
- Deletion is store-level — it removes the entire stream identified by
streamId. - If the stream does not exist, an
EventStreamNotFoundExceptionis thrown. - The deletion is permanent and irreversible. Every provider hard-deletes the underlying data (dictionary entry, database rows/documents or files) — there is no soft-delete or tombstone.
The action is implemented and logged by every provider (Azure Cosmos, Entity Framework Core, MongoDB, FileSystem and InMemory). See the provider READMEs for provider-specific details.
The Event Catalog provides a queryable registry of all events associated with a given entity type, including metadata (description, constraints) and a compile-time generated JSON Schema. This is useful for documentation, API discovery, and runtime introspection.
Use the generic EventNameAttribute<TEntity> to associate an event with an entity:
[EventName<User>("UserCreated", Description = "Raised when a new user is created", Constraints = new[] { "Create" })]
public record UserCreatedEvent(string Name, string Email);
[EventName<User>("UserRenamed", Description = "Raised when a user changes their name", Constraints = new[] { "Update" })]
public record UserRenamedEvent(string NewName);Events can also be discovered automatically via aggregator registrations (EventAggregatorBase<TEntity, TEvent> / IEventAggregator<TEntity, TEvent>).
The same event name may exist for different entity types — the catalog tracks them independently per entity.
When the Papst.EventStore.CodeGeneration package is referenced, the source generator automatically emits an AddCodeGeneratedEventCatalog() extension method alongside the existing AddCodeGeneratedEvents():
var services = new ServiceCollection();
services.AddCodeGeneratedEvents();
services.AddCodeGeneratedEventCatalog();Resolve IEventCatalog from DI and use the async API:
var catalog = serviceProvider.GetRequiredService<IEventCatalog>();
// List all events for an entity
IReadOnlyList<EventCatalogEntry> events = await catalog.ListEvents<User>();
// Filter by name and/or constraints
IReadOnlyList<EventCatalogEntry> filtered = await catalog.ListEvents<User>(
name: "UserCreated",
constraints: new[] { "Create" }
);
// Get event details including JSON Schema (global lookup)
EventCatalogEventDetails? details = await catalog.GetEventDetails("UserCreated");
// Get event details scoped to a specific entity (for duplicate event names across entities)
EventCatalogEventDetails? scoped = await catalog.GetEventDetails<User>("UserCreated");A full working sample is available at samples/SampleEventCatalog/.
For an end-to-end ASP.NET Core example using the in-memory event store, stream aggregation, and read-model repositories, see samples/SampleInMemoryAspNetApi/.
Adds an opt-in, attribute-based way to generate event aggregators, usable in parallel with the existing hand-written aggregator API.
- New
[EventAggregation<TEntity>]attribute on an event class/record makes thePapst.EventStore.CodeGenerationsource generator emit the aggregator and register it viaAddCodeGeneratedEvents(). Event properties are mapped onto the entity by name. PropertyPathtargets a nested object (intermediatenulllinks are instantiated), aDictionary<,>(mark the key with[AggregationDictionaryKey]) or a collection (mark the search key with[AggregationCollectionKey("Id")]); missing dictionary/collection items are upserted.- Null handling is controlled globally by
SkipNullValues(defaulttrue) and per property by[SkipWhenNull(bool)]. - Individual event properties can be excluded with
[AggregationIgnore]or mapped onto a differently named entity property with[AggregationProperty("TargetName")]. - If a hand-written aggregator already exists for the same
(entity, event)pair, generation is skipped and anEVTSRC0003diagnostic is reported, so the two approaches never double-register. - The code generator no longer fails when a project contains nested event/aggregator types; such types are skipped by the (name-based) discovery instead.
Aligns the event version numbering across all stores and lets the aggregator target the creation event.
- The
InMemoryandMongoDBstores now number the first event of a stream0, matching the Azure Cosmos, Entity Framework Core and FileSystem stores. Previously they were 1-based, which madeInMemoryan unreliable stand-in for Cosmos in tests (see #282). EventRegistrationEventAggregatorcan now aggregate up to and including version0(the creation event on 0-based stores). The stop condition compares the event version instead of the aggregate version, soAggregateAsync(stream, 0, ct)stops after the creation event instead of replaying the whole stream.- Breaking (MongoDB): existing persisted MongoDB streams created with an earlier version are 1-based;
their metadata gains a
NextVersionfield that defaults to0. Streams created before upgrading need to be migrated (or recreated) before appending further events.
Adds the ability to delete an entire event stream.
- Adds
IEventStore.DeleteAsync(Guid streamId, CancellationToken)which permanently removes a stream and all of its documents. - Implemented and logged across all providers (Azure Cosmos, Entity Framework Core, MongoDB, FileSystem and InMemory).
- Deleting a non-existent stream throws
EventStreamNotFoundException.
Maintenance release for the V6 package line.
- Updates the EventStore implementation packages to depend on
Papst.EventStore>= 6.1.0and< 7.0.0. - Adds
ILowLevelEventStreamAPI
V6 introduces the Event Catalog and low-level event access APIs.
- Adds the
IEventCatalogabstraction for querying registered events and generated JSON schema metadata. - Extends code generation with
AddCodeGeneratedEventCatalog(). - Adds
ILowLevelEventStreamsupport for appending rawJObjectpayloads together with explicit event type names.
Adds a possibility to update a streams MetaData object.
V5.3 introduces new methods on the IAggregatorStreamContext that allows to transfer information from the Aggregator to the next one.
V5.3 Supports only .NET 10.0 and upwards
V5.2 introduces Metadata on the Stream itself. The IEventStream now got its own metadata Property.
Meta Data for the Stream needs to be set during creation, otherwise it will be empty. To Create a stream with Meta Data the IEventStorehas got a new extended CreateAsync method that takes the additional metadata.
Only the Azure Cosmos Implementation offers a new option in the configuration that updates the TenantId based on the last set event.
V5 comes with a new access model to the streams, with paging and a new library structure.
V5 Supports only .NET 8.0 and upwards
It introduces a separation of EventStore and EventStream. The EventStore now only offers the possibility to create or retrieve streams.
- The
IEventStoreinterface no longer has methods to append to the EventStream - The new
IEventStreamneeds an index document, which needs to be added to existing event streams. See Migration Chapter in Cosmos DB Implementation. - The
IEventStreamAggregatorimplementation that uses the code generated events has moved to a own package to allow removing active code from thePapst.EventStorepackage. - The
EventNameAttribute now uses positional parameters, provided by a constructor. - A single EventStream can no longer contain Events for multiple Entities.
- The
IEventStreamAggregatornow usesValueTaskinstead ofTask
- Meta Data Properties are now of type
string?instead ofGuid?to achieve greater compatability.
- V4 only supports .NET 6.0
It introduces the concept of Code Generated registration of events and aggregators by decorating them with the EventName attribute.
It also decouples the auto generated event type descriptor (was basically a description used to revert to a type using Type.GetType()) from the concrete implementation.
This allows to version and migrate events by just adding a different descriptor.
A sample on how to use the Event Descriptors is found under Samples. The Extension Method AddCodeGeneratedEvents() is automatically generated during compilation if the package Papst.EventStore.CodeGeneration is added to the Project.
Migrate v3 events by adding a EventName attribute and add the typename as a name: [Fullename of the type],[assembly name of the type].
For the MyEventSourcingEvent in the Code Generation Sample it would look like this:
SampleCodeGeneratedEvents.MyEventSourcingEvent,SampleCodeGeneratedEvents
V4 removes support for authenticating with shared keys against the cosmos DB. The implementation is still there, but changed and marked as obsolete.
V3 supports mainly .NET 5.0 and registration of events and event aggregators through reflection