Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/jordiaragonzaragoza/JordiAragonZaragoza.SharedKernel/llms.txt

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

The SharedKernel Entity Framework package provides a layered set of DbContext base classes, repository base classes, entity type configurations, and interceptors that enforce DDD conventions — including soft delete, SmartEnum column mapping, concurrency tokens, and Ardalis.Specification query patterns — with minimal boilerplate in your own modules.

DbContext Hierarchy

Three abstract DbContext classes form a hierarchy. You choose the appropriate base depending on the persistence concern your context serves.
DbContext
  └── BaseContext                 (SmartEnum conventions, dev-mode logging)
        ├── BaseBusinessModelContext   (+ SoftDeleteEntitySaveChangesInterceptor)
        └── BaseReadModelContext       (+ Checkpoint DbSet for catch-up subscriptions)

BaseContext

The root of the hierarchy. It wires up logging, sensitive-data logging (development only), and configures SmartEnum column conventions for all derived contexts.
public abstract class BaseContext : DbContext
{
    protected BaseContext(
        DbContextOptions options,
        ILoggerFactory loggerFactory,
        IHostEnvironment hostEnvironment)
        : base(options) { }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        _ = optionsBuilder
            .UseLoggerFactory(this.loggerFactory)
            .EnableSensitiveDataLogging(
                this.hostEnvironment.EnvironmentName == "Development")
            .EnableDetailedErrors(
                this.hostEnvironment.EnvironmentName == "Development");

        base.OnConfiguring(optionsBuilder);
    }

    protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
    {
        configurationBuilder.ConfigureSmartEnum();

        base.ConfigureConventions(configurationBuilder);
    }
}
  • EnableSensitiveDataLogging and EnableDetailedErrors are activated only when IHostEnvironment.EnvironmentName == "Development". They are never enabled in production.
  • ConfigureSmartEnum() automatically applies the SmartEnum.EFCore value converter to every SmartEnum-derived property discovered in the model.

BaseBusinessModelContext

Extends BaseContext for write-side (command/aggregate) persistence. It registers the SoftDeleteEntitySaveChangesInterceptor via AddInterceptors, which intercepts every SaveChanges call to convert entity deletions into soft-delete flag updates.
public abstract class BaseBusinessModelContext : BaseContext
{
    protected BaseBusinessModelContext(
        DbContextOptions options,
        ILoggerFactory loggerFactory,
        IHostEnvironment hostEnvironment,
        SoftDeleteEntitySaveChangesInterceptor softDeleteEntitySaveChangesInterceptor)
        : base(options, loggerFactory, hostEnvironment) { }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        _ = optionsBuilder.AddInterceptors(
            this.softDeleteEntitySaveChangesInterceptor);

        base.OnConfiguring(optionsBuilder);
    }
}
Use BaseBusinessModelContext for your aggregate root contexts — contexts that participate in transactional writes, host IAggregateRoot entities, and need soft-delete behavior.

BaseReadModelContext

Extends BaseContext for the read-side (projections/query). It exposes a Checkpoints DbSet<Checkpoint> used by catch-up subscriptions to track their last processed stream position.
public abstract class BaseReadModelContext : BaseContext
{
    protected BaseReadModelContext(
        DbContextOptions options,
        ILoggerFactory loggerFactory,
        IHostEnvironment hostEnvironment)
        : base(options, loggerFactory, hostEnvironment) { }

    public DbSet<Checkpoint> Checkpoints => this.Set<Checkpoint>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        _ = modelBuilder.ApplyConfiguration(new CheckpointConfiguration());
        base.OnModelCreating(modelBuilder);
    }
}
Use BaseReadModelContext for your read-model / projection contexts — contexts populated by event handlers and queried by the application query bus.

Store Base Classes

Two abstract store classes implement the IUnitOfWork interface and manage database transactions. They are the entry points for executing operations within a resilient execution strategy.

BaseBusinessModelStore

Wraps a BaseBusinessModelContext. Exposes EventableEntities — the set of tracked aggregates that have unpublished domain events — and ExecuteInTransactionAsync<TResponse>, which:
  1. Creates an EF execution strategy (for transient-fault retry).
  2. Begins a transaction.
  3. Executes the supplied operation.
  4. Commits if Result.IsSuccess, rolls back otherwise or on exception.
public abstract class BaseBusinessModelStore : IAggregateStore, IUnitOfWork, IDisposable
{
    public IEnumerable<IEventsContainer<IEvent>> EventableEntities
        => this.writeContext.ChangeTracker
            .Entries<IEventsContainer<IDomainEvent>>()
            .Select(static e => e.Entity)
            .Where(static entity => entity.Events.Any());

    public async Task<TResponse> ExecuteInTransactionAsync<TResponse>(
        Func<Task<TResponse>> operation,
        CancellationToken cancellationToken = default)
        where TResponse : IResult { ... }
}

BaseProjectionsStore

Identical contract to BaseBusinessModelStore but wraps a BaseReadModelContext. Used by projection handlers that need transactional writes to the read-model database.

Repository Base Classes

SharedKernel provides three repository tiers under BusinessModel and two under ReadModel, all built on top of Ardalis.Specification.EntityFrameworkCore.

BaseReadRepository<TEntity, TId>

A read-only Ardalis Specification repository. Adds a strongly-typed GetByIdAsync(TId id) that uses EntityByIdSpec internally, replacing the raw GetByIdAsync<TIdx> from RepositoryBase.
public abstract class BaseReadRepository<TEntity, TId>
    : RepositoryBase<TEntity>, ISpecificationReadRepository<TEntity, TId>
    where TEntity : class, IEntity<TId>
    where TId : class, IEntityId
{
    protected BaseReadRepository(BaseBusinessModelContext readContext)
        : base(readContext) { }

    public virtual Task<TEntity?> GetByIdAsync(
        TId id, CancellationToken cancellationToken = default)
        => this.FirstOrDefaultAsync(
            new EntityByIdSpec<TEntity, TId>(id), cancellationToken);
}

BaseRepository<TAggregate, TId>

Adds full read/write capability by implementing IRangeableRepository<TAggregate, TId> on top of BaseReadRepository. Use this for aggregate root repositories that need to add, update, and delete.
public abstract class BaseRepository<TAggregate, TId>
    : BaseReadRepository<TAggregate, TId>, IRangeableRepository<TAggregate, TId>
    where TAggregate : BaseAggregateRoot<TId>
    where TId : class, IEntityId
{
    protected BaseRepository(BaseBusinessModelContext dbContext)
        : base(dbContext) { }
}

BaseCachedSpecificationRepository<TAggregate, TId>

Extends BaseReadRepository and automatically wraps every read operation (GetByIdAsync, ListAsync, FirstOrDefaultAsync, CountAsync, AnyAsync, etc.) in a cache check via ICacheService. Write operations invalidate the relevant cache entries by prefix.
public abstract class BaseCachedSpecificationRepository<TAggregate, TId>
    : BaseReadRepository<TAggregate, TId>, ICachedSpecificationRepository<TAggregate, TId>
    where TAggregate : class, IAggregateRoot<TId>
    where TId : class, IEntityId
{
    // Cache key is the aggregate's full type name
    public string CacheKey => $"{typeof(TAggregate)}";

    public override async Task<TAggregate?> GetByIdAsync(
        TId id, CancellationToken cancellationToken = default)
    {
        var cacheKeyId = $"{this.CacheKey}_{id}";
        var cacheResponse = await this.CacheGetAsync<TAggregate>(cacheKeyId, cancellationToken);
        if (cacheResponse != null) return cacheResponse;

        var response = await base.GetByIdAsync(id, cancellationToken);
        await this.CacheSetAsync(cacheKeyId, response, cancellationToken);
        return response;
    }
    // ... similar overrides for ListAsync, FirstOrDefaultAsync, etc.
}
Use BaseCachedSpecificationRepository for read-heavy aggregates that are frequently fetched by ID or specification but updated infrequently. Write operations automatically call RemoveByPrefixAsync(CacheKey) to keep the cache consistent.

Entity Type Configurations

Two abstract IEntityTypeConfiguration<T> implementations provide a consistent Fluent API starting point.

BaseModelTypeConfiguration<TModel, TId>

Sets the primary key to Id:
public abstract class BaseModelTypeConfiguration<TModel, TId> : IEntityTypeConfiguration<TModel>
    where TModel : class, IBaseModel<TId>
    where TId : notnull
{
    public virtual void Configure(EntityTypeBuilder<TModel> builder)
    {
        _ = builder.HasKey(static x => x.Id);
    }
}

BaseAggregateRootTypeConfiguration<TAggregateRoot, TId>

Extends BaseModelTypeConfiguration and adds:
  • Version as a row version (optimistic concurrency token via IsRowVersion()).
  • IsDeleted shadow property for soft delete.
  • Global query filter that excludes soft-deleted rows from all queries.
public abstract class BaseAggregateRootTypeConfiguration<TAggregateRoot, TId>
    : BaseModelTypeConfiguration<TAggregateRoot, TId>
    where TAggregateRoot : class, IAggregateRoot<TId>
    where TId : class, IEntityId
{
    public override void Configure(EntityTypeBuilder<TAggregateRoot> builder)
    {
        base.Configure(builder);

        _ = builder.Property(static aggregateRoot => aggregateRoot.Version)
            .IsRowVersion();

        _ = builder.Property<bool>("IsDeleted");
        _ = builder.HasQueryFilter(static b => !EF.Property<bool>(b, "IsDeleted"));
    }
}

Soft Delete: SoftDeleteEntitySaveChangesInterceptor

When BaseBusinessModelContext intercepts a SaveChanges call, SoftDeleteEntitySaveChangesInterceptor.UpdateEntities scans the change tracker for entities in the Deleted state. For any entity whose EF model has an IsDeleted shadow property, it:
  1. Resets entry.State to Modified.
  2. Sets entry.CurrentValues["IsDeleted"] = true.
This means DbContext.Remove(entity) never issues a DELETE statement — it issues an UPDATE instead.
public class SoftDeleteEntitySaveChangesInterceptor : SaveChangesInterceptor
{
    private static void UpdateEntities(DbContext? context)
    {
        var entries = context.ChangeTracker.Entries()
            .Where(static entity => entity.State == EntityState.Deleted);

        foreach (var entry in entries)
        {
            var entityType = context.Model.FindEntityType(entry.Entity.GetType());
            var isDeletedProperty = entityType?.FindProperty("IsDeleted");

            if (isDeletedProperty != null)
            {
                entry.State = EntityState.Modified;
                entry.CurrentValues["IsDeleted"] = true;
            }
        }
    }
}
The soft-delete filter defined in BaseAggregateRootTypeConfiguration ensures soft-deleted rows are transparently excluded from all EF queries without any extra code in your repositories or handlers.

Metadata Interfaces and Envelopes

Marks a persistence entity as capable of being soft-deleted. Note that ISoftDeletable exposes a property named IsDelete, while the EF shadow property used by the interceptor and BaseAggregateRootTypeConfiguration is named "IsDeleted" (with a d suffix). The interceptor does not check for this interface — it checks for the "IsDeleted" EF shadow property on each entity type. AggregateEnvelope implements ISoftDeletable for its IsDelete column; the shadow property approach is used directly on aggregate roots configured via BaseAggregateRootTypeConfiguration.
public interface ISoftDeletable
{
    bool IsDelete { get; set; }
}
Marks a persistence entity as multi-tenant / partition-aware by requiring TenantId and PartitionClientId string properties.
public interface IPartitionable
{
    string TenantId { get; set; }
    string PartitionClientId { get; set; }
}
A sealed wrapper that stores an IAggregateRoot as a JSON document column alongside ISoftDeletable and IPartitionable metadata. Use this when storing aggregates as document blobs in a relational column rather than mapping each property to its own column.
public sealed class AggregateEnvelope<TAggregate, TId> : ISoftDeletable, IPartitionable
    where TAggregate : IAggregateRoot<TId>
    where TId : class, IEntityId
{
    public TAggregate Data { get; set; } = default!;
    public bool IsDelete { get; set; }
    public string TenantId { get; set; } = string.Empty;
    public string PartitionClientId { get; set; } = string.Empty;
}
Similar to AggregateEnvelope but for read models. Implements IPartitionable without ISoftDeletable, since read-model rows are typically rebuilt from scratch rather than soft-deleted.
public sealed class ReadModelEnvelope<TReadModel> : IPartitionable
    where TReadModel : class, IReadModel
{
    public TReadModel Data { get; set; } = default!;
    public string TenantId { get; set; } = string.Empty;
    public string PartitionClientId { get; set; } = string.Empty;
}

Complete Example

The following shows how to create a custom context, a repository, and an entity type configuration for a hypothetical Order aggregate.
1

Create a custom DbContext

public class AppDbContext : BaseBusinessModelContext
{
    public AppDbContext(
        DbContextOptions<AppDbContext> options,
        ILoggerFactory loggerFactory,
        IHostEnvironment hostEnvironment,
        SoftDeleteEntitySaveChangesInterceptor softDeleteInterceptor)
        : base(options, loggerFactory, hostEnvironment, softDeleteInterceptor) { }

    public DbSet<Order> Orders => this.Set<Order>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
        base.OnModelCreating(modelBuilder);
    }
}
2

Configure the aggregate type

public class OrderTypeConfiguration
    : BaseAggregateRootTypeConfiguration<Order, OrderId>
{
    public override void Configure(EntityTypeBuilder<Order> builder)
    {
        base.Configure(builder); // sets PK, Version row-version, IsDeleted filter

        builder.Property(o => o.CustomerId)
            .IsRequired();

        builder.Property(o => o.TotalAmount)
            .HasColumnType("decimal(18,2)");
    }
}
3

Create a repository

public class OrderRepository : BaseRepository<Order, OrderId>, IOrderRepository
{
    public OrderRepository(AppDbContext dbContext)
        : base(dbContext) { }
}
4

Register in DI

builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(connectionString));

builder.Services.AddScoped<SoftDeleteEntitySaveChangesInterceptor>();
builder.Services.AddScoped<IOrderRepository, OrderRepository>();

Build docs developers (and LLMs) love