DKNet

DKNet.EfCore.Specifications

The Specification pattern for EF Core — filter, includes, and order-by as one reusable object, executed through a single non-generic IRepositorySpec, plus a runtime dynamic predicate builder.

✨ Why use it?

Reach for this package whenever you need reusable, testable query logic against an EF Core DbContext — especially when some of the filter criteria are supplied by a caller at runtime.

It is the successor to the retired DKNet.EfCore.Repos and DKNet.EfCore.Repos.Abstractions, whose types carry [Obsolete] and point here. IRepositorySpec covers the same read/write/paging surface as the old IRepository<T>. See Migrating-Repos-To-Specifications.md for a full call-site mapping.

🚀 Quick Start

dotnet add package DKNet.EfCore.Specifications

Register the repository against your DbContext type:

using DKNet.EfCore.Specifications;

services.AddSpecRepo<AppDbContext>();

SpecSetup.AddSpecRepo<TDbContext>(this IServiceCollection services) is idempotent — it no-ops if IRepositorySpec is already registered — and wires up two services:

Model projection (Query<TEntity, TModel>, and every *Async<TEntity, TModel> repository extension) additionally requires a Mapster IMapper registered in DI — RepositorySpec<TDbContext> resolves it via IServiceProvider.GetService<IMapper>() and throws InvalidOperationException at query time if none is found.

🧩 Features

Specification<TEntity> — filter, include, and order-by in one object

Specification<TEntity> is the abstract base you derive from. Configure it entirely from the constructor, using its protected builder methods — they are not callable from outside the subclass, which is what keeps a specification’s query logic immutable and self-contained once constructed:

public sealed class ActiveExpensiveProductsSpec : Specification<Product>
{
    public ActiveExpensiveProductsSpec(decimal minPrice)
    {
        WithFilter(p => p.IsActive && p.Price >= minPrice);   // FilterQuery
        AddInclude(p => p.Category);                          // IncludeQueries
        AddOrderByDescending(p => p.Price);                    // OrderByClauses, declared-sequence
        AddOrderBy(p => p.Name);                               // OrderByClauses, applied after Price
    }
}

The public surface a repository (or your own IQueryable code, via ApplySpecs) reads back is ISpecification<TEntity>:

Member Meaning
FilterQuery Expression<Func<TEntity, bool>>? — set once via WithFilter.
IncludeQueries Single-level Expression<Func<TEntity, object?>> includes added via AddInclude(Expression<...>), e.g. AddInclude(p => p.Category). Supports one level of filtered include, e.g. AddInclude(p => p.OrderItems.Where(i => i.Quantity > 0)) — see the tracking caveat below.
IncludeBuilders Func<IQueryable<TEntity>, IQueryable<TEntity>> chains added via AddInclude(Func<...>), for Include(...).ThenInclude(...) chains or per-navigation Where/OrderBy/Skip/Take.
IsIgnoreQueryFilters Set via IgnoreQueryFilters() — see “Configuration options and defaults”.

Ordering is not exposed on ISpecification<TEntity>. Only Specification<TEntity> (the abstract base every specification derives from) carries it, as an internal declared-sequence list — foreign ISpecification<TEntity> implementations that don’t derive from Specification<TEntity> are not supported and contribute no ordering.

Additional protected builders on Specification<TEntity>:

IRepositorySpec — the non-generic repository surface

IRepositorySpec is injected once and used for every entity type in the DbContext; the entity type comes from the ISpecification<TEntity> (or explicit type argument) passed to each call:

public sealed class ProductService(IRepositorySpec repo)
{
    public Task<Product?> FindActiveExpensiveAsync(decimal minPrice, CancellationToken ct) =>
        repo.FirstOrDefaultAsync(new ActiveExpensiveProductsSpec(minPrice), ct);

    public async Task CreateAsync(Product product, CancellationToken ct)
    {
        await repo.AddAsync(product, ct);
        await repo.SaveChangesAsync(ct);
    }
}

Core interface members: AddAsync/AddRangeAsync, Delete, BulkDeleteAsync<TEntity>(predicate, ct) (server-side ExecuteDeleteAsync — the replacement for the removed DeleteRange), Entry<TEntity>, Query<TEntity>(spec) / Query<TEntity, TModel>(spec), SaveChangesAsync, UpdateAsync/UpdateRangeAsync, and BeginTransactionAsync.

Query execution goes through Extensions/SpecRepoExtensions.cs and Extensions/ModelSpecRepoExtensions.cs, both implemented as extension members on IRepositorySpec:

Method Returns
AnyAsync<TEntity>(spec, ct) Task<bool>
CountAsync<TEntity>(spec, ct) Task<int>
FirstAsync<TEntity>(spec, ct) Task<TEntity> (throws if empty)
FirstOrDefaultAsync<TEntity>(spec, ct) Task<TEntity?>
FirstAsync<TEntity, TModel> / FirstOrDefaultAsync<TEntity, TModel>(spec, ct) projected model (see ModelSpecification<TEntity, TModel> — projections)
ToListAsync<TEntity>(spec, ct) / ToListAsync<TEntity, TModel>(spec, ct) Task<List<T>>
ToPagedListAsync<TEntity>(spec, pageNumber, pageSize, ct) / <TEntity, TModel> overload Task<IPagedList<T>> (X.PagedList)
ToPageEnumerable<TEntity>(spec) / <TEntity, TModel> overload IAsyncEnumerable<T>, internally paged (see Keyset (cursor) pagination and streaming enumeration)
ToKeysetPageAsync<TEntity, TKey>(spec, keySelector, cursor, pageSize, ct) / two-key overload Task<List<TEntity>> (see Keyset (cursor) pagination and streaming enumeration)

repo.Query<TEntity>(spec) and Query<TEntity, TModel>(spec) also return the raw IQueryable<T> — call .ToQueryString() on it to inspect generated SQL, the pattern used throughout the test suite.

Dynamic Predicate Builder — the signature feature

For filters whose shape is only known at runtime (search boxes, ?field=value query strings, admin grids), build a predicate from (propertyName, operation, value) triples instead of hand-writing Expression<Func<T, bool>> trees. DynamicAnd/DynamicOr are extension members (defined in the LinqKit namespace, so no extra using is needed alongside PredicateBuilder) on both ExpressionStarter<T> and plain Expression<Func<T, bool>>:

using LinqKit;
using DKNet.EfCore.Specifications.Dynamics;

public sealed class ProductSearchSpecification : Specification<Product>
{
    public ProductSearchSpecification(string? name, decimal? minPrice, string? category)
    {
        var predicate = CreatePredicate(p => p.IsActive);   // ExpressionStarter<Product>

        if (name is not null)
            predicate = predicate.DynamicAnd(nameof(Product.Name), Ops.Contains, name);

        if (minPrice is not null)
            predicate = predicate.DynamicAnd(nameof(Product.Price), Ops.GreaterThanOrEqual, minPrice);

        if (category is not null)
            predicate = predicate.DynamicAnd("Category.Name", Ops.Equal, category); // nested/dotted path

        WithFilter(predicate);
    }
}

Executing it through IRepositorySpec needs nothing extra — RepositorySpec<TDbContext>.Query<TEntity> already calls .AsExpandable() internally (_dbContext.Set<TEntity>().AsExpandable().ApplySpecs(spec)) before applying the specification, so repo.ToListAsync(new ProductSearchSpecification(...), ct) just works. .AsExpandable() only needs to be added by hand when you build and execute a DynamicAnd/DynamicOr predicate directly against an IQueryable/DbSet outside IRepositorySpec — see “Gotchas and limits”.

Supported operations (Ops enum, DKNet.EfCore.Specifications.Dynamics namespace):

Ops member SQL shape Notes
Equal / NotEqual = @0 / <> @0 null value → IS NULL / IS NOT NULL, not a parameterized comparison
GreaterThan / GreaterThanOrEqual / LessThan / LessThanOrEqual >, >=, <, <=  
Contains / NotContains LIKE '%..%' / negated Auto-converted to Equal/NotEqual on non-string properties
StartsWith / EndsWith LIKE '..%' / LIKE '%..' Auto-converted to Equal on non-string properties
In / NotIn IN (...) / NOT IN (...) Value must be a non-empty IEnumerable that is not itself a string; invalid values are rejected (see below)

Property paths. The property name argument is normalized with PropertyNameExtensions.ToPascalCase() — segments separated by _ or - are treated as word boundaries, and dotted paths ("category.name", "customer_profile.city") are normalized segment-by-segment (Category.Name, CustomerProfile.City) so callers can pass camelCase, snake_case, kebab-case, or already-PascalCase names interchangeably.

Fail-safe, not fail-loud, for the triple overload. DynamicAnd(propertyName, operation, value) / DynamicOr(...) silently return the predicate unchanged — they do not throw — when:

This makes the triple overload safe to wire straight to unvalidated user input: a bad filter is dropped rather than crashing the request. Scalar values are coerced automatically to numeric types, bool, DateTime, DateOnly, TimeOnly, Guid, and enums (so a query-string "true"/"2024-01-01"/"Active" reaches the database as the right CLR type) — this coercion is what can fail and trigger the silent skip above.

There is also a raw Dynamic LINQ overload — DynamicAnd(string expression, params object?[] values) / DynamicOr(...) — for expressions the triple shape can’t express (e.g. "Price * Quantity > @0"). Unlike the triple overload, this one is fail-loud: it validates the expression against a blocklist of dangerous substrings (System., Reflection., Process., File., SqlCommand, Environment., …) and throws ArgumentException if one is found, and lets System.Linq.Dynamic.Core parse/throw normally otherwise.

ModelSpecification<TEntity, TModel> — projections

For read paths that should never materialize the full entity, derive from ModelSpecification<TEntity, TModel> instead of Specification<TEntity>. It adds no new members — same protected builders — but flags the specification for projection, and pairs with the <TEntity, TModel> repository overloads (FirstOrDefaultAsync, ToListAsync, ToPagedListAsync, ToPageEnumerable) that call Query<TEntity, TModel> under the hood:

public sealed class ActiveProductSummariesSpec : ModelSpecification<Product, ProductSummaryDto>
{
    public ActiveProductSummariesSpec()
    {
        WithFilter(p => p.IsActive);
        AddOrderBy(p => p.Name);
    }
}

List<ProductSummaryDto> summaries =
    await repo.ToListAsync<Product, ProductSummaryDto>(new ActiveProductSummariesSpec(), ct);

RepositorySpec<TDbContext>.Query<TEntity, TModel> maps via Mapster (ProjectToType<TModel>(_mapper.Config)) on top of .AsNoTracking() — projected reads are always non-tracking regardless of whether the specification called AsNoTracking() itself.

Keyset (cursor) pagination and streaming enumeration

ToPageEnumerable (in the IRepositorySpec table above) streams a specification’s results as an IAsyncEnumerable<T>, fetching pages of 100 rows internally (Skip/Take) rather than materializing the whole result set:

await foreach (var product in repo.ToPageEnumerable(new ActiveExpensiveProductsSpec(50m)))
{
    await ProcessAsync(product);
}

It requires the specification to declare at least one OrderBy/OrderByDescending — EnsureSpecHasOrdering throws NotSupportedException up front otherwise, since paging an unordered query would return unstable/duplicate rows across page boundaries.

Keyset pagination trades Skip/Take (which scans and discards every preceding row) for an index seek on the ordering column(s) — it stays fast as tables grow, where offset pagination degrades. Three layers, from simplest to richest:

  1. IQueryable<TEntity>.AfterKeyset / .BeforeKeyset (single-key or composite two-key overloads, in Extensions/KeysetQueryExtensions.cs) add only a WHERE predicate — you own the OrderBy yourself:

    var nextPage = await context.Orders
        .OrderBy(o => o.CreatedDate).ThenBy(o => o.Id)
        .AfterKeyset(o => o.CreatedDate, o => o.Id, lastDate, lastId)
        .Take(pageSize)
        .ToListAsync();
    // WHERE CreatedDate > @date OR (CreatedDate = @date AND Id > @id)
    // equivalent to the row-value comparison (CreatedDate, Id) > (@date, @id)
    
  2. repo.ToKeysetPageAsync<TEntity, TKey>(spec, keySelector, cursor, pageSize, ct) (and the TKey1, TKey2 composite overload) is the IRepositorySpec convenience wrapper: it applies the specification, chains AfterKeyset, and takes pageSize rows. The specification still owns OrderBy — declare it there so results stay ordered consistently with the cursor comparison.

  3. IQueryable<TEntity>.ToKeysetPageAsync(configureKeyset, pageSize, direction, reference, ct) is the arbitrary-arity surface, backed by MR.EntityFrameworkCore.KeysetPagination. It owns both ordering and the cursor filter — do not chain it after your own OrderBy — and returns a KeysetPage<TEntity> record (Items, HasPrevious, HasNext) instead of a bare list:

    var page = await context.Merchants.ToKeysetPageAsync(
        b => b.Ascending(m => m.Country).Descending(m => m.Revenue).Ascending(m => m.Id),
        pageSize: 20,
        direction: KeysetPaginationDirection.Forward,
        reference: lastSeenMerchant); // null for the first page
    
    if (page.HasNext) { /* show a "next" control */ }
    

    reference only needs an object whose property names match the configured keyset columns — it does not have to be a TEntity. This overload costs three round trips: the page query, plus one each for the HasPrevious/ HasNext existence checks (and per MR.EntityFrameworkCore.KeysetPagination 1.6.0, those two checks do not accept a CancellationToken; only the page query itself observes it).

⚙️ Configuration reference

There is no options object or configuration section — behaviour is set per specification and per registration:

Knob Where Default Effect
AddSpecRepo<TDbContext>() DI not registered Registers IRepositorySpec for TDbContext. Idempotent.
AsNoTracking() specification constructor tracking on (EF Core default) Opts that specification’s entity query into a no-tracking query. Projected (TModel) queries are always no-tracking.
IgnoreQueryFilters() specification constructor false Bypasses global query filters whose GlobalQueryFilter.IsIgnorable is true; a filter overriding it to false is never bypassed.
Skip / Take specification constructor unset Applied only when the specification is executed via Query<TEntity> / Query<TEntity, TModel> (see the note below).
ToPageEnumerable page size internal constant PageAsyncEnumeratorExtensions.DefaultPageSize 100 Rows fetched per round trip while streaming. Not exposed as a parameter.
Keyset ordering configureKeyset delegate none — required Defines the keyset columns and direction. A specification with no ordering throws NotSupportedException.

The same points in full:

🧱 Where it fits

A specification is inert data until ApplySpecs folds it onto an IQueryable, and it does that in a fixed order — filters, then includes, then ordering, then the tracking/window flags:

Data-flow diagram of ApplySpecs: the specification is applied to the queryable as IgnoreQueryFilters and Where, then Include and ThenInclude chains, then OrderBy and ThenBy in declaration sequence, and finally AsNoTracking, Skip and Take before EF Core executes the query.

⚠️ Gotchas & limits