An Ardalis.Specification tutorial can become inaccurate quickly when it copies old repository methods, assumes every target framework has a direct package asset, or treats a query builder as magic. This guide is pinned to Ardalis.Specification 9.3.1, Ardalis.Specification.EntityFrameworkCore 9.3.1, and EF Core 10.0.10 in a consuming net10.0 application.
The package models a full query envelope. A Specification can carry filters, search expressions, includes, ordering, pagination, projection, tracking and query flags, tags, cache metadata, and post-processing. That is broader than the historical predicate-only Specification Pattern, so the package should be evaluated as a query-description library.
Ardalis.Specification Tutorial Baseline: Pin the Package Versions
NuGet identifies both Ardalis packages as stable 9.3.1 releases published on 2025-08-24. The core package metadata and EF package metadata also show an important split:
| Package | Direct target frameworks in 9.3.1 | Main responsibility |
|---|---|---|
Ardalis.Specification |
net8.0, net9.0, netstandard2.0 |
Specification state, builders, interfaces, in-memory evaluation |
Ardalis.Specification.EntityFrameworkCore |
net8.0, net9.0 |
EF evaluators, WithSpecification, EF flags, and RepositoryBase<T> |
The tagged core project file and EF project file contain no net10.0 target. Do not claim that 9.3.1 ships direct .NET 10 assets.
The package FAQ says newer .NET applications can consume its minimum target and can explicitly install a newer EF Core version. That is a compatibility path, not a promise about every future runtime or provider combination. A net10.0 application should pin and validate its own EF Core graph.
This project file states the versions used in the examples:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>14.0</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Ardalis.Specification"
Version="9.3.1" />
<PackageReference Include="Ardalis.Specification.EntityFrameworkCore"
Version="9.3.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore"
Version="10.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite"
Version="10.0.10" />
</ItemGroup>
</Project>
SQLite is used below for a relational smoke test. It does not prove that another production provider has identical translation, collation, functions, or SQL. The EF Core testing guidance recommends meaningful testing against the actual production database system where provider behavior matters.
That distinction matters during upgrades. A successful NuGet restore and compilation show that the selected lower-target assets can be consumed by this particular net10.0 project. They do not prove that every 9.3.1 feature has been tested by the package maintainers against EF Core 10, every EF provider, or every application query. Treat the consuming application as responsible for translation and behavioral tests across its real provider matrix.
Build a Current 9.3.1 Specification
The following sample defines a small EF model, a searchable entity Specification, and a projection Specification. The entity keeps public mutation constrained while remaining usable by EF Core.
using Ardalis.Specification;
using Microsoft.EntityFrameworkCore;
namespace ArdalisCurrentGuide;
public sealed class Customer
{
private Customer()
{
}
public Customer(
Guid id,
string name,
string email,
bool isActive)
{
Id = id;
Name = name;
Email = email;
IsActive = isActive;
}
public Guid Id { get; private set; }
public string Name { get; private set; } = string.Empty;
public string Email { get; private set; } = string.Empty;
public bool IsActive { get; private set; }
}
public sealed record CustomerSummary(
Guid Id,
string Name,
string Email);
public sealed class AppDbContext(
DbContextOptions<AppDbContext> options)
: DbContext(options)
{
public DbSet<Customer> Customers => Set<Customer>();
}
public sealed class ActiveCustomerSearchSpec
: Specification<Customer>
{
public ActiveCustomerSearchSpec(string? term)
{
var hasTerm = !string.IsNullOrWhiteSpace(term);
var pattern = $"%{term}%";
Query
.Where(customer => customer.IsActive)
.Search(
customer => customer.Name,
pattern,
hasTerm,
group: 1)
.Search(
customer => customer.Email,
pattern,
hasTerm,
group: 1)
.OrderBy(customer => customer.Name)
.ThenBy(customer => customer.Id)
.AsNoTracking()
.TagWith(nameof(ActiveCustomerSearchSpec));
}
}
public sealed class CustomerSummaryProjection
: Specification<Customer, CustomerSummary>
{
public CustomerSummaryProjection()
{
Query.Select(customer => new CustomerSummary(
customer.Id,
customer.Name,
customer.Email));
}
}
The 9.3.1 Search builder source accepts a selector, pattern, optional condition, and group number. The package does not insert % wildcards for you. A contains-style search therefore supplies %term% explicitly.
The tagged search documentation states that expressions in the same group are combined with OR, while separate groups combine with AND. In the sample, an active customer matches when either the name or email matches the pattern.
The EF search evaluator source builds EF.Functions.Like expressions. Actual matching details still depend on the configured relational provider and database.
Apply a Specification Directly With DbContext
Repository inheritance is optional. The tagged WithSpecification documentation applies a Specification directly to a DbSet<T> or IQueryable<T>.
WithProjectionOf can reuse the query state from a concrete Specification<T> and apply the selector from a concrete Specification<T, TResult>. The 9.3.1 implementation clones the source and copies selector and result post-processing state.
using Ardalis.Specification;
using Ardalis.Specification.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
namespace ArdalisCurrentGuide;
public sealed class CustomerQueries(AppDbContext dbContext)
{
public Task<List<CustomerSummary>> SearchAsync(
string? term,
CancellationToken cancellationToken)
{
var filter = new ActiveCustomerSearchSpec(term);
var projection = new CustomerSummaryProjection();
var specification = filter.WithProjectionOf(projection);
return dbContext.Customers
.WithSpecification(specification)
.ToListAsync(cancellationToken);
}
}
This keeps the terminal operation visible. WithSpecification returns IQueryable<CustomerSummary>; EF Core executes when ToListAsync materializes it.
Projection syntax is version-sensitive. In 9.3.1, Select and SelectMany return void, as shown in the tagged Builder_Select.cs. Put Query.Select(...) last or in a separate statement. Do not copy older fluent chains that continue after Select.
Choose Among Three Execution Paths
An Ardalis.Specification tutorial should not imply that one repository shape is mandatory. Version 9.3.1 supports direct WithSpecification use, a custom repository built on the tagged SpecificationEvaluator, and inheritance from tagged RepositoryBase<T>.
| Path | Benefits | Tradeoffs |
|---|---|---|
Direct WithSpecification |
Smallest integration, visible IQueryable and terminal execution |
Application/data-access code owns materialization and post-processing decisions |
Custom repository using SpecificationEvaluator |
Keeps a project-specific data-access seam without inheriting the supplied repository | You must define methods and execution semantics |
Inherit RepositoryBase<T> |
Reuses the package's materialization and repository operations | The generic surface may be broader than a particular application needs |
The custom evaluator path is small:
using Ardalis.Specification;
using Ardalis.Specification.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
namespace ArdalisCurrentGuide;
public sealed class CustomerReadRepository(
AppDbContext dbContext,
ISpecificationEvaluator evaluator)
{
public Task<List<TResult>> ListAsync<TResult>(
ISpecification<Customer, TResult> specification,
CancellationToken cancellationToken)
{
return evaluator
.GetQuery(dbContext.Customers, specification)
.ToListAsync(cancellationToken);
}
}
public interface ICustomerRepository
: IRepositoryBase<Customer>
{
}
public sealed class CustomerRepository(AppDbContext dbContext)
: RepositoryBase<Customer>(dbContext),
ICustomerRepository
{
}
The tagged SpecificationEvaluator applies filters, search, includes, ordering, pagination, tracking flags, filter and auto-include flags, split-query behavior, and tags before projection.
The tagged RepositoryBase<T> source provides current operations such as ListAsync, FirstOrDefaultAsync, SingleOrDefaultAsync, CountAsync, and AnyAsync. Use source as the authority: the tagged single-result interface marks the nongeneric interface obsolete, and stale examples showing SingleAsync do not match the 9.3.1 repository implementation.
For broader EF fundamentals before adopting a query abstraction, see the EF Core complete guide. The package changes how query intent is organized, not how EF translation and execution fundamentally work.
Understand Criteria-Only Count and Any
The tagged RepositoryBase<T> implementation calls the evaluator with evaluateCriteriaOnly: true for CountAsync(spec) and AnyAsync(spec). In 9.3.1, that excludes evaluators that are not criteria evaluators, including ordering, includes, and pagination.
This allows one paged Specification to produce both a page and a total count. It also means count behavior should be read from the evaluator pipeline rather than inferred from the Specification's full envelope.
That feature is useful, but it is not automatically the right count for every screen. A projected or grouped use case may need a purpose-built query. Keep the returned business meaning explicit.
Projection and WithProjectionOf Are Not General Composition
Ardalis.Specification 9.3.1 intentionally does not provide arbitrary whole-Specification AND, OR, or NOT composition. The tagged FAQ explains why: two query envelopes can conflict in includes, ordering, paging, projection, caching, and post-processing.
WithProjectionOf is a narrow reuse feature. It combines the cloned query state of one concrete Specification with the projection of another concrete Specification. It does not merge two independent filters or define conflict rules for every piece of query metadata.
This limitation is reasonable. Predicate composition has clear Boolean semantics. Whole-envelope composition needs decisions such as:
- Which ordering wins?
- Can two projections coexist?
- Should page sizes be combined, rejected, or replaced?
- How are cache keys and post-processing functions merged?
If reusable query fragments are needed, the package recommends builder extension methods. Keep each final Specification explicit.
Tracking and Query Flags Are Part of the Envelope
The tagged Builder_Flags.cs exposes these current flags:
| Flag | Intended EF behavior |
|---|---|
AsTracking() |
Request tracking and disable the two no-tracking flags |
AsNoTracking() |
Request no tracking |
AsNoTrackingWithIdentityResolution() |
Request no tracking with result-local identity resolution |
AsSplitQuery() |
Request EF split-query behavior |
IgnoreQueryFilters() |
Bypass EF global query filters |
IgnoreAutoIncludes() |
Bypass model-configured auto-includes |
These are provider and EF execution concerns. They are not domain predicates. Treat them as visible query policy, especially IgnoreQueryFilters, which can bypass soft-delete or tenancy filters.
The EF Core LINQ querying guide provides more context on keeping filters and projections provider-visible. A passing in-memory Specification evaluation cannot prove how these flags behave in SQL.
EnableCache Stores Metadata Only
EnableCache and WithCacheKey set cache-key state. The tagged cache builder assigns CacheKey, and the caching documentation places actual caching in consuming infrastructure such as a cached repository.
The package does not provide:
- Cache storage.
- Expiration.
- Eviction.
- Invalidation.
- Tenant or user scoping.
Calling EnableCache(nameof(MySpec), parameter) therefore does not cache a query result. It only marks the Specification with metadata that another component may interpret. That component must build a complete key and own data freshness.
Post-Processing Depends on the Execution Path
PostProcessingAction is an in-memory function applied after query materialization. The tagged builder source stores the function on the Specification.
Execution is the caveat:
using Ardalis.Specification;
using Ardalis.Specification.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
namespace ArdalisCurrentGuide;
public sealed class PostProcessedCustomerSpec
: Specification<Customer>
{
public PostProcessedCustomerSpec()
{
Query
.Where(customer => customer.IsActive)
.OrderBy(customer => customer.Id)
.Take(25)
.PostProcessingAction(customers =>
customers.OrderByDescending(customer => customer.Name));
}
}
public static class PostProcessingExamples
{
public static Task<List<Customer>> ListWithPackagePostProcessingAsync(
AppDbContext dbContext,
PostProcessedCustomerSpec specification,
CancellationToken cancellationToken)
{
return dbContext.Customers.ToListAsync(
specification,
cancellationToken);
}
public static Task<List<Customer>> ListWithoutPackagePostProcessingAsync(
AppDbContext dbContext,
PostProcessedCustomerSpec specification,
CancellationToken cancellationToken)
{
return dbContext.Customers
.WithSpecification(specification)
.ToListAsync(cancellationToken);
}
}
The first method uses the package's DbSet.ToListAsync(specification, token) overload, which materializes and then invokes post-processing. The second uses WithSpecification, receives an IQueryable<Customer>, and then calls EF Core's ordinary ToListAsync. It does not invoke PostProcessingAction.
The tagged RepositoryBase<T>.ListAsync source materializes the query and then invokes the specification's post-processing action. Do not assume that every route through IQueryable runs it.
Post-processing also happens after rows have been fetched. It cannot make an unbounded query safe or move an unsupported filter to SQL. If filtering belongs in the database, express it in provider-translatable criteria.
Validate Translation With a Relational Provider
The package's in-memory evaluator is useful for selected LINQ semantics, but it cannot prove EF-specific includes, tracking, split queries, ignored filters, auto-includes, tags, or provider translation.
This relational smoke test executes the current Specification and projection through SQLite:
using ArdalisCurrentGuide;
using Ardalis.Specification;
using Ardalis.Specification.EntityFrameworkCore;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Xunit;
namespace ArdalisCurrentGuide.Tests;
public sealed class ActiveCustomerSearchSpecTests
{
[Fact]
public async Task WithSpecification_MatchingName_ReturnsProjectedCustomer()
{
await using var connection =
new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(connection)
.Options;
await using var dbContext = new AppDbContext(options);
await dbContext.Database.EnsureCreatedAsync();
dbContext.Customers.AddRange(
new Customer(
Guid.NewGuid(),
"Avery Stone",
"[email protected]",
isActive: true),
new Customer(
Guid.NewGuid(),
"Morgan Reed",
"[email protected]",
isActive: false));
await dbContext.SaveChangesAsync();
var specification =
new ActiveCustomerSearchSpec("Avery")
.WithProjectionOf(new CustomerSummaryProjection());
var results = await dbContext.Customers
.WithSpecification(specification)
.ToListAsync();
var result = Assert.Single(results);
Assert.Equal("Avery Stone", result.Name);
}
}
This test proves that the shown APIs compose and execute through one relational provider. It does not prove identical production-provider SQL. The DevLeader EF testing comparison explains why relational smoke tests and production-provider tests answer different questions.
Frequently Asked Questions
Does Ardalis.Specification 9.3.1 directly target .NET 10?
No. The tagged core project directly targets net8.0, net9.0, and netstandard2.0, while the tagged EF project directly targets net8.0 and net9.0. A net10.0 consumer uses compatible lower-target assets and should pin and validate EF Core 10 explicitly.
Do I need RepositoryBase<T>?
No. You can call WithSpecification directly on DbSet<T> or IQueryable<T>, use SpecificationEvaluator inside a custom repository, or inherit RepositoryBase<T>.
Does EnableCache cache query results?
No. It stores cache-key metadata. Storage, expiration, eviction, invalidation, and security scoping belong to consuming infrastructure.
Can I combine any two Ardalis Specifications with AND or OR?
Not through a built-in general composition API. The package intentionally avoids arbitrary whole-envelope composition because query-shaping fields can conflict. WithProjectionOf is projection reuse, not Boolean composition.
Does WithSpecification(spec).ToListAsync() run post-processing?
No. WithSpecification returns an IQueryable, and EF Core's terminal operation does not call the Specification's post-processing function. Use a supported package execution path or invoke the action deliberately.
How do search groups and wildcards work?
The tagged search documentation states that expressions in the same group are ORed and different groups are ANDed. Supply % or other SQL LIKE wildcard syntax in the pattern yourself.
Decide Based on the Envelope You Need
Ardalis.Specification 9.3.1 is useful when named EF query envelopes repeat across an application and the builder features reduce duplicated query construction. Direct WithSpecification keeps integration small. A custom repository can provide application-specific execution. RepositoryBase<T> offers a broader starting point.
The tradeoff is additional semantics to learn. Projection syntax, evaluator order, query flags, criteria-only counts, cache metadata, and post-processing paths all affect behavior.
Keep the version pin visible. Treat the direct target frameworks honestly. Test the exact provider and EF Core version used by the application. Most importantly, remember what the package is doing: it describes and evaluates queries. It does not make caching, composition, provider translation, or execution boundaries disappear.

