CSharpEssentials.EntityFrameworkCore
5.0.0
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
<PackageReference Include="CSharpEssentials.EntityFrameworkCore" Version="5.0.0" />
<PackageVersion Include="CSharpEssentials.EntityFrameworkCore" Version="5.0.0" />
<PackageReference Include="CSharpEssentials.EntityFrameworkCore" />
paket add CSharpEssentials.EntityFrameworkCore --version 5.0.0
#r "nuget: CSharpEssentials.EntityFrameworkCore, 5.0.0"
#:package CSharpEssentials.EntityFrameworkCore@5.0.0
#addin nuget:?package=CSharpEssentials.EntityFrameworkCore&version=5.0.0
#tool nuget:?package=CSharpEssentials.EntityFrameworkCore&version=5.0.0
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,UpdatedByand 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
UPDATEstatement. - 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:
HasEnumStorageper property, then[StringEnum(Storage)], thenEnumConventions.Storage(FlagsStoragefor 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.ConfigureEnumConventionsWithReflectionopts them in through reflection (not trim or AOT safe). - Properties with your own
HasConversionare 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 withEnumConventions.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 examplevarchar(32));schema:the table schema.ConvertEnumJsonPath<TEnum>("orders", "payload", ["status"])rewrites one path of a PostgreSQLjsonbcolumn (to: EnumStorage.IntegerforDown); 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
AlterColumnthat you forgot to delete next toConvertEnumColumnfor 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 | Versions 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. |
-
net10.0
- CSharpEssentials.Core (>= 5.0.0)
- CSharpEssentials.Entity (>= 5.0.0)
- CSharpEssentials.Enums (>= 5.0.0)
- CSharpEssentials.Json (>= 5.0.0)
- CSharpEssentials.Maybe (>= 5.0.0)
- CSharpEssentials.Results (>= 5.0.0)
- EFCore.NamingConventions (>= 10.0.0 && < 11.0.0)
- Microsoft.EntityFrameworkCore (>= 10.0.1 && < 11.0.0)
- Microsoft.Extensions.Caching.Abstractions (>= 10.0.1)
- Microsoft.Extensions.Caching.Memory (>= 10.0.1)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.1)
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.1)
-
net8.0
- CSharpEssentials.Core (>= 5.0.0)
- CSharpEssentials.Entity (>= 5.0.0)
- CSharpEssentials.Enums (>= 5.0.0)
- CSharpEssentials.Json (>= 5.0.0)
- CSharpEssentials.Maybe (>= 5.0.0)
- CSharpEssentials.Results (>= 5.0.0)
- EFCore.NamingConventions (>= 8.0.0 && < 9.0.0)
- Microsoft.Bcl.TimeProvider (>= 8.0.0)
- Microsoft.EntityFrameworkCore (>= 8.0.0 && < 9.0.0)
- Microsoft.Extensions.Caching.Abstractions (>= 9.0.4)
- Microsoft.Extensions.Caching.Memory (>= 9.0.4)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 9.0.4)
- Microsoft.Extensions.Logging.Abstractions (>= 9.0.4)
- System.Text.Json (>= 9.0.4)
-
net9.0
- CSharpEssentials.Core (>= 5.0.0)
- CSharpEssentials.Entity (>= 5.0.0)
- CSharpEssentials.Enums (>= 5.0.0)
- CSharpEssentials.Json (>= 5.0.0)
- CSharpEssentials.Maybe (>= 5.0.0)
- CSharpEssentials.Results (>= 5.0.0)
- EFCore.NamingConventions (>= 9.0.0 && < 10.0.0)
- Microsoft.EntityFrameworkCore (>= 9.0.4 && < 10.0.0)
- Microsoft.Extensions.Caching.Abstractions (>= 9.0.4)
- Microsoft.Extensions.Caching.Memory (>= 9.0.4)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 9.0.4)
- Microsoft.Extensions.Logging.Abstractions (>= 9.0.4)
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 |