Codebelt

Savvyio.Extensions.EFCore.Domain

Use aggregate-aware EF Core repositories and data sources that dispatch queued domain events before saving changes.

.NET 10.0 / .NET 9.0 MIT v5.0.8 12,717 downloads

Overview

Savvyio.Extensions.EFCore.Domain adapts Savvyio aggregate roots to EF Core persistence. It extends Savvyio.Extensions.EFCore with an aggregate-specific repository, a unit-of-work implementation that can raise pending domain events before save, and dispatcher extensions that read events directly from the EF Core change tracker.

The package fits applications that already model aggregates with Savvyio.Domain and want the EF Core save boundary to coordinate persistence and in-process domain event publication. It does not introduce event-stream storage or aggregate rehydration from stored events. That concern belongs to Savvyio.Extensions.EFCore.Domain.EventSourcing.

Key APIs

EfCoreAggregateDataSource extends EfCoreDataSource with domain-event dispatch at save time. Its public constructor accepts an IDomainEventDispatcher together with EfCoreDataSourceOptions, and its overridden SaveChangesAsync calls RaiseManyAsync against the backing DbContext before delegating to the base EF Core save operation. If no dispatcher is supplied, it still falls back to the base save behavior.

EfCoreAggregateRepository<TEntity, TKey> is the aggregate-specific EF Core repository type. It inherits the standard add, remove, get, and find members from EfCoreRepository<TEntity, TKey>, constrains TEntity to IAggregateRoot<IDomainEvent, TKey>, and exposes the repository through IAggregateRepository<TEntity, TKey>.

DomainEventDispatcherExtensions.RaiseManyAsync() scans a DbContext for tracked IAggregateRoot<IDomainEvent> entries that still hold pending events. For each event it merges aggregate metadata into the event instance and forwards it to IDomainEventDispatcher.RaiseAsync.

DomainEventDispatcherExtensions.RaiseMany() performs the same extraction and metadata merge synchronously through IDomainEventDispatcher.Raise. Both dispatcher extensions clear the aggregate event buffer before yielding the queued events when the buffered event type implements IDomainEvent.

Basic usage

using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Threading.Tasks;
using Codebelt.Extensions.Xunit;
using Cuemon.Threading;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using Microsoft.EntityFrameworkCore.Metadata;
using Savvyio.Domain;
using Savvyio.Extensions.EFCore;
using Savvyio.Extensions.EFCore.Domain;
using Xunit;

namespace MyProject.Tests;

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

    [Fact]
    public async Task Add_AggregateRoot_TracksUnitOfWork()
    {
        var source = new RecordingEfCoreDataSource();
        var repository = new EfCoreAggregateRepository<InventoryItem, int>(source);
        var item = new InventoryItem(42, "SKU-42");

        repository.Add(item);
        await source.SaveChangesAsync();

        TestOutput.WriteLine($"Tracked {source.Items.Single().Sku}; pending events: {item.Events.Count}.");
        Assert.Single(source.Items);
        Assert.Same(item, source.Items.Single());
        Assert.Equal(1, source.SaveChangesCalls);
        Assert.IsType<InventoryItemRegistered>(item.Events.Single());
    }
}

sealed class RecordingEfCoreDataSource : IEfCoreDataSource
{
    public int SaveChangesCalls { get; private set; }
    public List<InventoryItem> Items { get; } = new();

    public DbSet<TEntity> Set<TEntity>() where TEntity : class
    {
        if (typeof(TEntity) != typeof(InventoryItem)) { throw new InvalidOperationException($"Unsupported entity type: {typeof(TEntity)}."); }
        return (DbSet<TEntity>)(object)new FakeDbSet<InventoryItem>(Items);
    }

    public Task SaveChangesAsync(Action<AsyncOptions> setup = null!)
    {
        SaveChangesCalls++;
        return Task.CompletedTask;
    }
}

sealed class FakeDbSet<TEntity>(List<TEntity> items) : DbSet<TEntity>, IQueryable<TEntity>, IEnumerable<TEntity>, IEnumerable where TEntity : class
{
    private readonly IQueryable<TEntity> _query = items.AsQueryable();

    public override EntityEntry<TEntity> Add(TEntity entity)
    {
        items.Add(entity);
        return null!;
    }

    public override IEntityType EntityType => null!;
    public Type ElementType => _query.ElementType;
    public Expression Expression => _query.Expression;
    public IQueryProvider Provider => _query.Provider;
    public IEnumerator<TEntity> GetEnumerator() => items.GetEnumerator();
    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}

sealed class InventoryItem : AggregateRoot<int>
{
    public InventoryItem(int id, string sku) : base(id)
    {
        Sku = sku;
        AddEvent(new InventoryItemRegistered(sku));
    }

    public string Sku { get; }
}

sealed record InventoryItemRegistered(string Sku) : DomainEvent;

Use this pattern when you want EfCoreAggregateRepository<TEntity, TKey> to sit in front of an IEfCoreDataSource implementation that commits aggregate roots as one unit of work. It matters because the package keeps aggregate persistence expressed in repository and unit-of-work terms while constraining the repository to IAggregateRoot<IDomainEvent, TKey> entities.

Installation

dotnet add package Savvyio.Extensions.EFCore.Domain

Usage guidance

Adopt this package when your EF Core persistence layer works with IAggregateRoot<IDomainEvent, TKey> entities and you want one unit of work that can both save aggregate state and raise queued domain events from tracked aggregates. If you only need EF Core repositories for regular entities, Savvyio.Extensions.EFCore is the smaller fit, and if your aggregates are event sourced use Savvyio.Extensions.EFCore.Domain.EventSourcing instead.

Family packages