DKNet

DKNet.EfCore.Extensions

The wiring layer for the EF Core area of DKNet: convention-based entity configuration, global query filters, data seeding, GUID v7 keys, SQL sequences, and the SnapshotContext type the rest of the family’s save hooks are built on.

✨ Why use it?

Note that DKNet.EfCore.Hooks — and through it DKNet.EfCore.Events, DKNet.EfCore.AuditLogs and DKNet.EfCore.DataAuthorization — all pass this package’s SnapshotContext into every save-pipeline hook, so it is already in your dependency graph if you use any of those, even when you never call its APIs directly.

🚀 Quick Start

dotnet add package DKNet.EfCore.Extensions

The package depends on DKNet.EfCore.Abstractions and DKNet.Fw.Extensions; both come along transitively.

The entry point is UseAutoConfigModel, an extension on DbContextOptionsBuilder declared in EfCoreSetup.cs:

public static DbContextOptionsBuilder<TContext> UseAutoConfigModel<TContext>(
    this DbContextOptionsBuilder<TContext> @this,
    params Assembly[]? assemblies)
    where TContext : DbContext;

public static DbContextOptionsBuilder UseAutoConfigModel(
    this DbContextOptionsBuilder @this,
    Assembly[] assemblies);

Minimum registration — no assemblies means “scan the assembly the DbContext lives in”:

services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(connectionString)
           .UseAutoConfigModel<AppDbContext>());

Multi-assembly (modular/bounded-context) registration:

options.UseSqlServer(connectionString)
       .UseAutoConfigModel<AppDbContext>(
           typeof(Product).Assembly,
           typeof(Customer).Assembly);

UseAutoConfigModel does not add entity configuration logic to OnModelCreating itself — it stores the assembly list as a IDbContextOptionsExtension and replaces EF Core’s IModelCustomizer with AutoConfigModelCustomizer, which runs the discovery/registration steps below once per model build, then delegates to the original customizer (so it composes with a provider’s own customizer, e.g. Npgsql’s).

🧩 Features

Apply entity configurations by assembly scan

AutoConfigModelCustomizer.Customize calls modelBuilder.ApplyConfigurationsFromAssembly(assembly) (EF Core’s own scanner) for every assembly registered via UseAutoConfigModel. In practice this means: write one IEntityTypeConfiguration<T> per entity (anywhere in a scanned assembly) and it is picked up without an explicit call in OnModelCreating. This package does not invent entities out of thin air — an entity still needs either an explicit IEntityTypeConfiguration<T> or a DbSet<T> property for EF Core to know about it; “auto configuration” only automates applying the configuration classes you already wrote.

DefaultEntityTypeConfiguration<TEntity> (in Configurations/DefaultEntityTypeConfiguration.cs) is a base class for those configuration classes that wires up conventions so you don’t repeat them per entity:

public abstract class DefaultEntityTypeConfiguration<TEntity> : IEntityTypeConfiguration<TEntity>
    where TEntity : class
{
    public virtual void Configure(EntityTypeBuilder<TEntity> builder);
}

It, based on reflection over TEntity:

public class ProductConfiguration : DefaultEntityTypeConfiguration<Product>
{
    public override void Configure(EntityTypeBuilder<Product> builder)
    {
        base.Configure(builder); // Id, audit columns, concurrency token
        builder.Property(p => p.Name).HasMaxLength(255).IsRequired();
        builder.HasIndex(p => p.Sku).IsUnique();
    }
}

Apply global query filters across entity types

IGlobalModelBuilder (Configurations/IGlobalModelBuilder.cs) is the extension point:

public interface IGlobalModelBuilder
{
    void Apply(ModelBuilder modelBuilder, DbContext context);
}

Any non-abstract implementation found while scanning the registered assemblies is instantiated (Activator.CreateInstance) and applied automatically during model build — no separate registration call is required for assembly-discovered filters. You can additionally register one explicitly (useful when the implementation needs constructor arguments EF’s parameterless Activator.CreateInstance can’t supply, or when it lives outside the scanned assemblies) via:

services.AddGlobalModelBuilder<MySoftDeleteFilter>();

GlobalQueryFilter (Configurations/GlobalQueryFilter.cs) is an abstract base that turns the low-level IGlobalModelBuilder.Apply into a simpler per-entity-type contract:

public abstract class GlobalQueryFilter : IGlobalModelBuilder
{
    public abstract string FilterKey { get; }
    public virtual bool IsIgnorable => true;

    protected abstract IEnumerable<IMutableEntityType> GetEntityTypes(ModelBuilder modelBuilder);
    protected abstract Expression<Func<TEntity, bool>>? HasQueryFilter<TEntity>(DbContext context)
        where TEntity : class;
}

FilterKey is EF Core 10’s named query filter key (HasQueryFilter(key, expression)), which lets a DbContext carry multiple, independently-toggleable filters on the same entity. IsIgnorable records (in a static registry exposed as GlobalQueryFilter.IgnorableFilterKeys) whether specification code is allowed to bypass this particular filter — see the DataAuthorization example below, which sets it to false so row-level ownership can never be silently skipped.

internal sealed class SoftDeleteFilter : GlobalQueryFilter
{
    public override string FilterKey => nameof(SoftDeleteFilter);

    protected override IEnumerable<IMutableEntityType> GetEntityTypes(ModelBuilder modelBuilder) =>
        modelBuilder.Model.GetEntityTypes()
            .Where(t => typeof(ISoftDelete).IsAssignableFrom(t.ClrType));

    protected override Expression<Func<TEntity, bool>>? HasQueryFilter<TEntity>(DbContext context) =>
        e => !((ISoftDelete)e).IsDeleted;
}

Seed data through EF Core’s native seeding hooks

IDataSeedingConfiguration (Configurations/IDataSeedingConfiguration.cs) describes a seed unit; the abstract DataSeedingConfiguration<TEntity> base does the plumbing so you only implement GetDataAsync:

public abstract class DataSeedingConfiguration<TEntity> : IDataSeedingConfiguration where TEntity : class
{
    public virtual int Order => 0;
    protected abstract ValueTask<ICollection<TEntity>> GetDataAsync(CancellationToken cancellation = default);
}
public sealed class CountrySeed : DataSeedingConfiguration<Country>
{
    protected override ValueTask<ICollection<Country>> GetDataAsync(CancellationToken cancellation = default) =>
        ValueTask.FromResult<ICollection<Country>>([new Country("VN"), new Country("US")]);
}

Wire seeding in separately from UseAutoConfigModel — it is its own opt-in via EfCoreDataSeedingExtensions.UseAutoDataSeeding:

options.UseSqlServer(connectionString)
       .UseAutoConfigModel<AppDbContext>()
       .UseAutoDataSeeding([typeof(AppDbContext).Assembly]);

UseAutoDataSeeding discovers IDataSeedingConfiguration implementations in the given assemblies and attaches them to EF Core’s native UseSeeding/UseAsyncSeeding hooks (run by EnsureCreated/migration flows), so seeding runs through the same mechanism as any other EF Core seed data, not a bespoke one.

Order is part of the interface but nothing reads it today — UseAutoDataSeeding runs the discovered seeders in the order the assembly scan produced them. Treat seeding order as undefined and make each seeder self-sufficient.

Generate time-ordered GUID keys

GuidV7ValueGenerator (Convertors/GuidV7ValueGenerator.cs) is a ValueGenerator<Guid> whose Next returns Guid.CreateVersion7() — a time-ordered GUID (RFC 9562 v7), which avoids the index-fragmentation cost of random Guid.NewGuid() primary keys on typical clustered-index setups. You rarely construct it directly: DefaultEntityTypeConfiguration<TEntity> attaches it automatically to any Guid Id property via .HasValueGenerator<GuidV7ValueGenerator>(). To use it on an entity that isn’t going through DefaultEntityTypeConfiguration, attach it explicitly:

builder.Property(e => e.Id).HasValueGenerator<GuidV7ValueGenerator>();

Declare SQL sequences from an enum

[SqlSequenceAttribute(schema)] (on an enum, from DKNet.EfCore.Abstractions) plus [SequenceAttribute] (on each enum member) declare one SQL sequence per member. SequenceExtensions.RegisterSequences (internal, invoked automatically by AutoConfigModelCustomizer when the provider is SQL Server or Npgsql) turns them into modelBuilder.HasSequence(...) calls:

[SqlSequence("billing")]
public enum InvoiceSequences
{
    [Sequence(typeof(long), StartAt = 1000, IncrementsBy = 1)]
    InvoiceNumber
}

Read the next value at runtime with the DbContext extensions in EfCoreExtensions.cs:

long? next = await db.NextSeqValue<InvoiceSequences, long>(InvoiceSequences.InvoiceNumber);
string formatted = await db.NextSeqValueWithFormat(InvoiceSequences.InvoiceNumber); // uses [Sequence].FormatString

NextSeqValue issues a raw SELECT NEXT VALUE FOR ... (SQL Server) or SELECT nextval(...) (Npgsql) against context.Database.GetDbConnection(); it throws NotSupportedException on any other provider.

Work with change-tracked graphs

NavigationExtensions.cs adds a handful of EntityEntry/DbContext extensions used to work with change-tracked graphs without hand-rolled reflection:

db.Orders.Add(order); // order.Items contains brand-new OrderItem instances
await db.AddNewEntitiesFromNavigations(); // finds and stages order.Items automatically
await db.SaveChangesAsync();

Retry a save on a concurrency conflict

IEfCoreExceptionHandler / EfCoreExceptionHandler (Extensions/EfCoreExceptionHandler.cs) classify a DbUpdateConcurrencyException into an EfConcurrencyResolution value — RetrySaveChanges, IgnoreChanges or RethrowException. The default implementation retries (after reloading the DB’s current values into OriginalValues) only when the exception message contains "but actually affected 0 row(s)"; anything else is rethrown. EfSaveChangesExtension.SaveChangesWithConcurrencyHandlingAsync drives the retry loop, bounded by IEfCoreExceptionHandler.MaxRetryCount (default 3):

var rows = await db.SaveChangesWithConcurrencyHandlingAsync(); // uses EfCoreExceptionHandler by default

Register a custom, per-DbContext handler through DI (keyed by the DbContext’s full type name):

services.AddEfCoreExceptionHandler<AppDbContext, MyConcurrencyHandler>();

Capture a save-time snapshot for hooks

SnapshotContext (Snapshots/SnapshotContext.cs) wraps a DbContext and, once Initialize() is called, captures every Added/Modified/Deleted EntityEntry at that moment into a read-only list of SnapshotEntityEntry (Entry, Entity, OriginalState). It’s a plain point-in-time capture, not a diffing/change-detection utility — Initialize() calls ChangeTracker.DetectChanges() once and stores the result; Entities throws InvalidOperationException if read before Initialize(), and the type itself throws ObjectDisposedException once disposed.

await using var snapshot = new SnapshotContext(db);
snapshot.Initialize();
foreach (var e in snapshot.Entities)
    Console.WriteLine($"{e.Entity.GetType().Name}: {e.OriginalState}");

You will rarely construct this yourself in application code — see the next section for who does.

Withhold sensitive properties from unauthorised callers

A property declared [SensitiveData(...)] in DKNet.EfCore.Abstractions (and carried onto the generated response model by DKNet.EfCore.DtoGenerator) can be omitted from the JSON payload unless the caller holds one of the roles the declaration names. This is entirely opt-in: it only applies to the JsonSerializerOptions instance you call UseRoleAwareSensitiveData on.

Two types, both in DKNet.EfCore.Extensions.Serialization:

namespace DKNet.EfCore.Extensions.Serialization;

public interface ISensitiveDataPrincipalAccessor
{
    ClaimsPrincipal? Current { get; }
}

public static class SensitiveDataJsonExtensions
{
    public static JsonSerializerOptions UseRoleAwareSensitiveData(
        this JsonSerializerOptions options,
        ISensitiveDataPrincipalAccessor accessor);
}

ISensitiveDataPrincipalAccessor is the seam that keeps this package free of any ASP.NET Core dependency — you write the implementation. That is deliberate: DKNet.EfCore.* references no Microsoft.AspNetCore.* package, so an EF Core model project, a worker service, or a test can use the same assemblies without dragging in the web stack. ClaimsPrincipal (System.Security.Claims) and System.Text.Json are in-box on net10.0, and they are the only identity and serialization types involved.

Wiring it up in ASP.NET Core

The accessor is a handful of lines over IHttpContextAccessor, which lives in your host project:

using DKNet.EfCore.Extensions.Serialization;
using System.Security.Claims;

internal sealed class HttpContextSensitiveDataPrincipalAccessor(IHttpContextAccessor httpContextAccessor)
    : ISensitiveDataPrincipalAccessor
{
    public ClaimsPrincipal? Current => httpContextAccessor.HttpContext?.User;
}

Register it, then opt in the JsonSerializerOptions your endpoints actually serialize with. For minimal APIs that is Microsoft.AspNetCore.Http.Json.JsonOptions; configure it once the container can resolve the accessor:

using DKNet.EfCore.Extensions.Serialization;
using Microsoft.Extensions.Options;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddHttpContextAccessor();
builder.Services.AddSingleton<ISensitiveDataPrincipalAccessor, HttpContextSensitiveDataPrincipalAccessor>();

builder.Services.AddSingleton<IConfigureOptions<Microsoft.AspNetCore.Http.Json.JsonOptions>>(sp =>
    new ConfigureOptions<Microsoft.AspNetCore.Http.Json.JsonOptions>(o =>
        o.SerializerOptions.UseRoleAwareSensitiveData(
            sp.GetRequiredService<ISensitiveDataPrincipalAccessor>())));

var app = builder.Build();

For MVC/controllers it is the same shape against Microsoft.AspNetCore.Mvc.JsonOptions:

builder.Services.AddControllers();
builder.Services.AddSingleton<IConfigureOptions<Microsoft.AspNetCore.Mvc.JsonOptions>>(sp =>
    new ConfigureOptions<Microsoft.AspNetCore.Mvc.JsonOptions>(o =>
        o.JsonSerializerOptions.UseRoleAwareSensitiveData(
            sp.GetRequiredService<ISensitiveDataPrincipalAccessor>())));

The accessor is registered as a singleton on purpose: it holds no state of its own, and IHttpContextAccessor resolves the current request’s HttpContext from an AsyncLocal on every read.

Call it before the options instance has serialized anything. System.Text.Json freezes a JsonSerializerOptions on first use, and a frozen instance rejects the resolver change with InvalidOperationException — which is why the opt-in belongs in startup, not in a request handler.

What the caller sees

Given this entity and its generated response model:

public class Product
{
    public string Name { get; set; } = string.Empty;
    public decimal Price { get; set; }

    [SensitiveData("pricing")]
    public decimal SupplierCostPrice { get; set; }
}

[GenerateDto(typeof(Product))]
public partial record ProductDto;

A caller authenticated and in the pricing role:

{
  "name": "Espresso Machine",
  "price": 899.00,
  "supplierCostPrice": 412.50
}

A caller authenticated but holding only support:

{
  "name": "Espresso Machine",
  "price": 899.00
}

The property is absent — not null, not "***", not an empty string. Nothing in the payload hints that a property was withheld, and a client deserializing into a type with a nullable SupplierCostPrice simply sees null because nothing was assigned. Properties that carry no [SensitiveData] are untouched: they get no ShouldSerialize callback at all, so a model with no sensitive property serializes byte-for-byte as it did before you opted in.

The decision rules

Caller [SensitiveData] [SensitiveData("pricing")]
No accessor value (Current is null) withheld withheld
Authenticated false withheld withheld
Authenticated, no roles sent withheld
Authenticated, in pricing sent sent
Authenticated, in support only sent withheld

It fails closed: when no caller identity is available, or the identity is not authenticated, the property is withheld no matter which roles were named — including the no-roles form. Background code serializing with an opted-in options instance and no ambient principal therefore gets the redacted shape, not the full one.

The check runs per property, per serialization, reading accessor.Current as the payload is written. Two callers hitting the same endpoint through the same JsonSerializerOptions instance are judged independently; no decision is cached onto the JsonTypeInfo.

The rule applies wherever the attribute is visible, including a [SensitiveData] property on a nested object inside the response — the modifier runs for every object type the payload touches, not just the root.

UseRoleAwareSensitiveData composes with whatever TypeInfoResolver the options already carry — naming policies, converters and source-generated contexts you configured keep working, and the modifier is layered on top rather than replacing them. It returns the same options instance for chaining, throws ArgumentNullException on a null options or accessor, and is safe to call twice (the second call adds a redundant modifier, not a broken one).

⚙️ Configuration reference

Setting Default Where
Assemblies scanned by UseAutoConfigModel<TContext>() (no args) [typeof(TContext).Assembly] EfCoreSetup.UseAutoConfigModel
IEfCoreExceptionHandler.MaxRetryCount 3 EfCoreExceptionHandler/interface default
GlobalQueryFilter.IsIgnorable true (filter may be bypassed by spec code) GlobalQueryFilter
DataSeedingConfiguration<T>.Order 0 — declared but never read; UseAutoDataSeeding runs seeders in assembly-scan order IDataSeedingConfiguration
SequenceAttribute.IncrementsBy / Min / Max / StartAt -1 = “leave to the database default”; only values > 0 are applied RegisterSequencesFromEnumType
SequenceAttribute.Cyclic true SequenceAttribute
SqlSequenceAttribute.Schema "seq" SqlSequenceAttribute
Sequence registration Only runs when context.IsSqlServer() or context.IsNpgsql() AutoConfigModelCustomizer
Role-aware sensitive-property filtering Off. Applies only to a JsonSerializerOptions you called UseRoleAwareSensitiveData(accessor) on SensitiveDataJsonExtensions
[SensitiveData] with no role named Any authenticated caller; an unauthenticated one is still refused SensitiveDataJsonExtensions
Role comparison ClaimsPrincipal.IsInRole as configured by your identity stack (ordinal by default) SensitiveDataJsonExtensions

🧱 Where it fits

Everything UseAutoConfigModel does happens once, inside EF Core’s own model build, through a replaced IModelCustomizer — which is why it applies to every entity in the scanned assemblies without a per-entity call:

Workflow diagram of the model build: UseAutoConfigModel records the assemblies in an EntityAutoConfigRegister options extension, AutoConfigModelCustomizer replaces IModelCustomizer, and it then applies every IEntityTypeConfiguration from those assemblies, runs the global model builders (including any registered with AddGlobalModelBuilder), and finally registers [SqlSequence] enums only when the provider is SQL Server or Npgsql.

⚠️ Gotchas & limits