CSharpEssentials.EntityFrameworkCore 5.0.0

There is a newer version of this package available.
See the version list below for details.
dotnet add package CSharpEssentials.EntityFrameworkCore --version 5.0.0
                    
NuGet\Install-Package CSharpEssentials.EntityFrameworkCore -Version 5.0.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="CSharpEssentials.EntityFrameworkCore" Version="5.0.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="CSharpEssentials.EntityFrameworkCore" Version="5.0.0" />
                    
Directory.Packages.props
<PackageReference Include="CSharpEssentials.EntityFrameworkCore" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add CSharpEssentials.EntityFrameworkCore --version 5.0.0
                    
#r "nuget: CSharpEssentials.EntityFrameworkCore, 5.0.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package CSharpEssentials.EntityFrameworkCore@5.0.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=CSharpEssentials.EntityFrameworkCore&version=5.0.0
                    
Install as a Cake Addin
#tool nuget:?package=CSharpEssentials.EntityFrameworkCore&version=5.0.0
                    
Install as a Cake Tool

CSharpEssentials.EntityFrameworkCore

Extensions, interceptors, and utilities for Entity Framework Core. Audit trails, domain events, pagination, and CQRS support.

Features

  • Audit Interceptor: Auto-populates CreatedAt, CreatedBy, UpdatedAt, UpdatedBy and handles soft deletes.
  • Domain Event Interceptor: Dispatches domain events with before/after save timing and outbox support.
  • Slow Query Logging: Logs queries exceeding a configurable threshold.
  • Pagination: Offset-based and cursor-based pagination on IQueryable.
  • Batch Soft Delete: Soft-delete many rows with a single UPDATE statement.
  • BaseDbContext: Opt-in interceptor attachment from DI and domain event dispatch around SaveChanges.
  • CQRS Support: Register separate write and read DbContexts.
  • SQL Connection Factories: Abstractions for raw SQL/Dapper scenarios.

Installation

dotnet add package CSharpEssentials.EntityFrameworkCore

Targets net10.0 (EF Core 10), net9.0 (EF Core 9) and net8.0 (EF Core 8); there is no net11.0 target. Use the EF Core major that matches your target framework.

Usage

Audit Interceptor

services.AddAuditInterceptor(() => "system-user");

services.AddDbContext<MyDbContext>((sp, options) =>
    options.UseNpgsql(connectionString)
           .AddInterceptors(sp.GetRequiredService<AuditInterceptor>()));

Domain Event Interceptor

services.AddSingleton<IDomainEventPublisher, MyPublisher>();
services.AddSingleton<DomainEventInterceptor>();

// In your entity
public sealed record PriceChangedEvent(Guid ProductId, decimal NewPrice) : IDomainEvent;

public class Product : EntityBase<Guid>
{
    public decimal Price { get; private set; }

    public void UpdatePrice(decimal newPrice)
    {
        Price = newPrice;
        Raise(new PriceChangedEvent(Id, newPrice));
    }
}

Pagination

using CSharpEssentials.EntityFrameworkCore.Pagination;

// Offset-based
var response = await dbContext.Users.PaginateAsync(
    new PaginationRequest { PageNumber = 1, PageSize = 10 }, cancellationToken: ct);

// Offset-based with page number and size
var page = await dbContext.Users.OrderBy(u => u.Id).PaginateAsync(pageNumber: 2, pageSize: 20, cancellationToken: ct);

// Synchronous, also works on in-memory IQueryable
var memoryPage = users.AsQueryable().Paginate(pageNumber: 1, pageSize: 10);

// Cursor-based
var cursorResponse = await dbContext.Logs.PaginateAsync(
    new CursorPaginationRequest<DateTimeOffset> { Limit = 20, Cursor = lastDate },
    cursorSelector: x => x.CreatedAt);

Batch Soft Delete

// UPDATE ... SET IsDeleted = 1, DeletedAt = @now, DeletedBy = @user WHERE ... AND NOT IsDeleted
int affected = await dbContext.Orders
    .Where(o => o.CustomerId == customerId)
    .SoftDeleteAsync(deletedBy: userId, timeProvider, ct);

Works for any entity implementing ISoftDeletable (e.g. SoftDeletableEntityBase). Rows already soft-deleted keep their original DeletedAt/DeletedBy. Like all ExecuteUpdate calls, it bypasses the change tracker and SaveChanges interceptors and needs a relational provider.

BaseDbContext

public sealed class AppDbContext(DbContextOptions<AppDbContext> options, IServiceScopeFactory scopeFactory)
    : BaseDbContext<AppDbContext>(options, scopeFactory)
{
    // Attach interceptors registered in DI (AddAuditInterceptor, AddSlowQueryInterceptor, ...).
    protected override DbContextInterceptors InterceptorsFromServices =>
        DbContextInterceptors.Audit | DbContextInterceptors.SlowQuery;

    // Dispatch domain events around SaveChanges/SaveChangesAsync.
    protected override bool DispatchDomainEventsOnSaveChanges => true;
}

Both hooks are off by default. Interceptors that are not registered in DI, or are already on the options, are skipped. When dispatch is on, BeforeSave events go to IDomainEventPublisher before the save, and AfterSave events go to IDomainEventOutbox (if registered) or IDomainEventPublisher after the save succeeds. Override DispatchDomainEventsAsync to change this. If you override OnConfiguring, call base.OnConfiguring(optionsBuilder).

The two domain event paths differ in when AfterSave events are handled:

DispatchDomainEventsOnSaveChanges DomainEventInterceptor
AfterSave events after the save completes in SavingChanges, before the database write
Outbox write not atomic with the save: the save is already committed when StoreAsync runs; inside an explicit transaction it runs before that transaction commits before the save, in the same SaveChanges call
Save fails AfterSave events are put back on their entities, so a retry dispatches them events are already dispatched

Use one of them, not both. When DispatchDomainEventsOnSaveChanges is true, DbContextInterceptors.DomainEvents in InterceptorsFromServices is ignored and the DomainEventInterceptor is not attached. The context's service scope (ServiceProvider) is disposed with the context.

Enum Storage

ConfigureEnumConventions stores [StringEnum] enums by their wire name (the same spelling as JSON and binding) and adds a check constraint per column:

using CSharpEssentials.Enums;               // EnumConventions, EnumStorage, [StringEnum]
using CSharpEssentials.EntityFrameworkCore; // ConfigureEnumConventions, HasEnumStorage, HasEnumCheckConstraint

public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
    protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) =>
        configurationBuilder.ConfigureEnumConventions(EnumConventions.Default);

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Order>().Property(o => o.Priority).HasEnumStorage(EnumStorage.Integer);
        modelBuilder.Entity<Order>().Property(o => o.Note).HasEnumCheckConstraint(false);
    }
}
Shape Column Check constraint ck_{table}_{column}_enum
String (default) text on PostgreSQL, TEXT on SQLite, otherwise n = longest wire name rounded up to 16 "Status" IN ('pending', 'shipped')
Integer ([StringEnum(Storage = EnumStorage.Integer)], HasEnumStorage, EnumConventions.Storage) underlying integer type "Priority" IN (0, 1, 2)
[Flags] (integer by default) underlying integer type ("Permissions" & ~7) = 0
[Flags] with String storage text[] on PostgreSQL, JSON array text elsewhere <@ ARRAY[...]::text[] on PostgreSQL
List<TEnum> text[]/integer[] on PostgreSQL, JSON text elsewhere <@ ARRAY[...] on PostgreSQL
ToJson() owned types JSON column none; EnumJsonValueReaderWriter<TEnum> writes wire names
  • Precedence: HasEnumStorage per property, then [StringEnum(Storage)], then EnumConventions.Storage (FlagsStorage for flags).
  • Reads are tolerant (wire name, member name, aliases, any case); writes are canonical. An unknown stored value maps to the [EnumFallback] member, otherwise the query throws and the message names the column.
  • Enums without generated metadata (no [StringEnum]) keep EF's default integer column. ConfigureEnumConventionsWithReflection opts them in through reflection (not trim or AOT safe).
  • Properties with your own HasConversion are skipped.
  • Adding a member changes only the constraint, so the migration is DropCheckConstraint + AddCheckConstraint.
  • Opt out of constraints per property with HasEnumCheckConstraint(false), or globally with EnumConventions.CheckConstraints = false.
  • Renaming a member also changes the constraint; see Renaming a member safely.
Upgrading an existing database

Existing enum columns hold integers (EF's default) or an older text format. Keep them as they are, so the first 5.0 migration is empty:

using CSharpEssentials.Enums;               // EnumConventions, EnumStorage
using CSharpEssentials.EntityFrameworkCore; // EnumStoredAs, HasLegacyEnumStorage

configurationBuilder.ConfigureEnumConventions(EnumConventions.Default, existingStorage: EnumStoredAs.Integer);

// opt in one column at a time
modelBuilder.Entity<Order>().Property(o => o.Status).HasEnumStorage(EnumStorage.String);

// or keep a column's old text format (MemberName, CamelCase, LegacySnakeCase, FlagsText)
modelBuilder.Entity<Order>().Property(o => o.Kind).HasLegacyEnumStorage(EnumStoredAs.MemberName);

existingStorage and HasLegacyEnumStorage keep writing the old format, read every spelling and add no check constraint. The 4.x overloads (ConfigureEnumConventions(params Assembly[]), EnumConventionOptions) are obsolete forwarders; UseLegacySnakeCase = true maps to existingStorage: EnumStoredAs.LegacySnakeCase.

Converting the data

When you drop HasLegacyEnumStorage (or existingStorage) for a column, dotnet ef migrations add generates an AlterColumn that changes the type without converting the data. Replace it with ConvertEnumColumn, and keep the constraint operations around it:

using CSharpEssentials.Enums;               // EnumStorage
using CSharpEssentials.EntityFrameworkCore; // ConvertEnumColumn, EnumStoredAs

protected override void Up(MigrationBuilder migrationBuilder)
{
    migrationBuilder.DropCheckConstraint(name: "ck_orders_Status_enum", table: "orders");   // when one exists
    migrationBuilder.ConvertEnumColumn<OrderStatus>("orders", "Status", from: EnumStoredAs.Integer, to: EnumStorage.String);
    migrationBuilder.AddCheckConstraint(name: "ck_orders_Status_enum", table: "orders", sql: "...");   // as generated
}

protected override void Down(MigrationBuilder migrationBuilder)
{
    migrationBuilder.DropCheckConstraint(name: "ck_orders_Status_enum", table: "orders");
    migrationBuilder.ConvertEnumColumn<OrderStatus>("orders", "Status", from: EnumStoredAs.Text, to: EnumStorage.Integer);
}
From To Notes
Integer EnumStorage.String or a text format (MemberName, CamelCase, LegacySnakeCase) undefined numbers are kept as their text and fail the new constraint
Text (any known spelling), MemberName, CamelCase, LegacySnakeCase EnumStorage.String or another text format unknown text is kept and fails the new constraint
any text EnumStorage.Integer integer text is kept; other unknown text aborts the migration with cannot convert orders.Status to OrderStatus, unknown value: ...
FlagsText ("Read, Write") EnumStorage.Integer tokens are OR'ed; on PostgreSQL through a temporary column
flags Integer EnumStoredAs.FlagsText single-flag member names; values with undefined bits stay numbers
  • No conversion writes NULL. Text conversions skip canonical values, so running them twice changes nothing.
  • SQL is generated for PostgreSQL and SQLite (MigrationBuilder.ActiveProvider); other providers throw. On SQLite EF rebuilds the table.
  • type: sets a non-default target store type (for example varchar(32)); schema: the table schema.
  • ConvertEnumJsonPath<TEnum>("orders", "payload", ["status"]) rewrites one path of a PostgreSQL jsonb column (to: EnumStorage.Integer for Down); unknown values are left untouched.
  • EnumDataAudit.Sql<TEnum>("orders", "Status", storedAs: ..., provider: ...) returns a read-only query that lists the values a conversion or constraint would reject, with their counts. Run it in production before you deploy the migration (see below).
  • CSE0014 (build error) reports a generated AlterColumn that you forgot to delete next to ConvertEnumColumn for the same column.
Auditing the data

EnumDataAudit.Sql only builds the SQL (SELECT "Status" AS "Value", count(*) AS "Count" ... GROUP BY ... ORDER BY ...); it changes nothing. Print it and run it with psql or your SQL client, or run it through EF Core:

using CSharpEssentials.EntityFrameworkCore; // EnumDataAudit, EnumStoredAs

string sql = EnumDataAudit.Sql<OrderStatus>("orders", "Status", storedAs: EnumStoredAs.Integer, provider: db.Database.ProviderName);

List<EnumAuditRow> rejected = await db.Database.SqlQueryRaw<EnumAuditRow>(sql).ToListAsync(ct);
foreach (EnumAuditRow row in rejected)
    logger.LogWarning("orders.Status holds {Value} in {Count} rows", row.Value, row.Count);

// Value has the column's type: int for EnumStoredAs.Integer, string for text columns
public sealed class EnumAuditRow
{
    public int Value { get; init; }
    public long Count { get; init; }
}

An empty result means every row converts and passes the new constraint. Pass schema: for a table outside the default schema.

CQRS Write/Read

services.AddCqrsDbContexts<WriteDbContext, ReadDbContext>(
    configureWrite: (sp, options) => options.UseNpgsql(connectionString),
    configureRead: (sp, options) => options.UseNpgsql(connectionString).UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking));

Slow Query Logging

services.AddSlowQueryInterceptor(TimeSpan.FromMilliseconds(500));
Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 is compatible.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
6.5.2 89 10/10/2026
6.5.1 87 10/10/2026
6.5.0 94 10/9/2026
6.4.0 87 10/9/2026
6.3.0 81 10/8/2026
6.2.0 84 10/8/2026
6.1.0 87 10/7/2026
6.0.0 85 10/7/2026
5.2.1 97 10/7/2026
5.2.0 82 10/7/2026
5.1.0 96 10/7/2026
5.0.0 81 10/6/2026
4.1.0 91 10/6/2026
4.0.0 91 10/5/2026
3.2.3 170 5/31/2026
3.2.2 133 5/31/2026
3.2.1 125 5/31/2026
3.2.0 124 5/30/2026
3.1.0 134 5/27/2026
3.0.8 130 5/20/2026
Loading failed