Codebelt

Savvyio.Extensions.EFCore.Domain.EventSourcing

Configure EF Core model mappings for traced aggregate events and load aggregates back by replaying stored events.

.NET 10.0 / .NET 9.0 MIT v5.0.8 11,343 downloads

Overview

Savvyio.Extensions.EFCore.Domain.EventSourcing adds the EF Core persistence pieces needed for Savvyio traced aggregates. It maps each traced domain event to an EfCoreTracedAggregateEntity<TEntity, TKey> row and supplies a repository that rebuilds an aggregate by deserializing stored events in version order.

It extends Savvyio.Extensions.EFCore.Domain for applications that already model aggregates with Savvyio.Domain.EventSourcing. The repository expects the aggregate type to support rehydration from (TKey id, IEnumerable<ITracedDomainEvent> events), which is the constructor signature used when GetByIdAsync recreates an aggregate from its stored event stream.

Key APIs

ModelBuilderExtensions.AddEventSourcing<TEntity, TKey>() adds the EF Core model for EfCoreTracedAggregateEntity<TEntity, TKey>. It configures a composite primary key on Id and Version, maps Timestamp, Type, and Payload, and accepts EfCoreTracedAggregateEntityOptions so consumers can rename the table and columns or change their relational column types.

EfCoreTracedAggregateRepository<TEntity, TKey> is the package's event-sourced repository implementation. Add writes one surrogate row per pending traced domain event and then clears the aggregate event buffer, while GetByIdAsync reads the stored rows for an aggregate, orders them by version, deserializes each payload back to ITracedDomainEvent, and recreates the aggregate through ActivatorFactory. If the aggregate type does not expose the expected rehydration constructor, the method throws a MissingMethodException that spells out the required (TKey, IEnumerable<ITracedDomainEvent>) signature.

EfCoreTracedAggregateEntity<TEntity, TKey> is the EF Core friendly surrogate used to persist a traced aggregate's event stream. It exposes the persisted Id, Version, Timestamp, Type, and Payload, carries aggregate metadata through the explicit IMetadata implementation, and deliberately leaves IAggregateRoot<ITracedDomainEvent>.RemoveAllEvents() as a no-op so dehydrate and rehydrate flows do not wipe the stored event list.

EfCoreTracedAggregateEntityOptions controls the relational shape of the event stream table. Its defaults are DomainEvents for the table name, id and version for the composite key columns, timestamp and clrtype for event metadata, and payload for the serialized event body, with corresponding default column types such as uniqueidentifier, int, datetime, varchar(1024), and varchar(max).

EfCoreTracedAggregateEntityExtensions.ToTracedDomainEvent() converts a persisted surrogate row back into a traced domain event. Consumers supply the original CLR event type and an IMarshaller, and the method deserializes the stored Payload bytes into that concrete event type.

TracedDomainEventExtensions.ToByteArray() serializes an ITracedDomainEvent into the byte payload stored by the surrogate entity. It is the package-level bridge between Savvyio's traced event abstraction and EF Core persistence.

Basic usage

using System;
using Codebelt.Extensions.Xunit;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata.Conventions;
using Savvyio;
using Savvyio.Domain.EventSourcing;
using Savvyio.Extensions.EFCore.Domain.EventSourcing;
using Savvyio.Handlers;
using Xunit;

namespace MyProject.Tests;

public class EventSourcingModelBuilderTest : Test
{
    public EventSourcingModelBuilderTest(ITestOutputHelper output) : base(output) { }

    [Fact]
    public void AddEventSourcing_CustomSchema_UpdatesEventStoreMap()
    {
        var modelBuilder = new ModelBuilder(new ConventionSet());
        modelBuilder.AddEventSourcing<BankAccount, Guid>(o =>
        {
            o.TableName = "TraceEvents";
            o.CompositePrimaryKeyIdColumnName = "aggregate_id";
            o.CompositePrimaryKeyVersionColumnName = "aggregate_version";
            o.TimestampColumnName = "recorded_at";
            o.TypeColumnName = "aggregate_type";
            o.PayloadColumnName = "body";
        });

        var schema = modelBuilder.Model.ToDebugString(MetadataDebugStringOptions.LongDefault);
        TestOutput.WriteLine(schema);

        Assert.Contains("Relational:TableName: TraceEvents", schema);
        Assert.Contains("Relational:ColumnName: aggregate_id", schema);
        Assert.Contains("Relational:ColumnName: aggregate_version", schema);
        Assert.Contains("Relational:ColumnName: recorded_at", schema);
        Assert.Contains("Relational:ColumnName: aggregate_type", schema);
        Assert.Contains("Relational:ColumnName: body", schema);
    }
}

file sealed class BankAccount : TracedAggregateRoot<Guid>
{
    protected override void RegisterDelegates(IFireForgetRegistry<ITracedDomainEvent> handler) { }
}

Use this pattern when you need EF Core to persist a traced aggregate's event stream with package-defined conventions but still want explicit control over the table and column names. It matters because the same mapping shape is what EfCoreTracedAggregateRepository<TEntity, TKey> relies on when it stores event rows and later rehydrates an aggregate from them.

Installation

dotnet add package Savvyio.Extensions.EFCore.Domain.EventSourcing

Usage guidance

Adopt this package when your domain model uses ITracedAggregateRoot<TKey> or TracedAggregateRoot<TKey> and EF Core is responsible for storing the event stream as serialized rows that can later rebuild aggregate state. If you only need EF Core repositories for regular aggregates, Savvyio.Extensions.EFCore.Domain is the better fit, and if EF Core is not part of your persistence stack this package does not add anything on its own.

Family packages