The Specification Pattern with EF Core can be useful when a query has named, reusable criteria but a Repository abstraction would add ceremony without solving a current problem. This article owns that narrow path: an expression-based specification goes directly onto a DbSet<T> or IQueryable<T>, while tracking, ordering, projection, result limits, cancellation, and materialization remain visible at the call site.
That boundary matters. The specification in this article is not a domain validator and not a full query envelope. It carries one provider-visible predicate: Expression<Func<T, bool>>. EF Core still owns translation and execution, while the application still owns the shape and limits of the result.
What Direct Specification Pattern With EF Core Usage Means
The direct flow is intentionally small:
- A named specification exposes an expression.
Queryable.Whereadds that expression to anIQueryable<T>.- The caller chooses a tracking policy, ordering, projection, and bound.
- A terminal EF Core operation executes the query with a cancellation token.
Expression<TDelegate> represents a strongly typed lambda as an inspectable expression tree, and Queryable.Where accepts an Expression<Func<T, bool>>. That is why the specification stores an expression instead of a compiled Func<T, bool>.
IQueryable<T> carries both an expression and a query provider, and enumeration executes the expression through that provider. Until execution, Where, OrderBy, Select, and Take return query objects that extend the expression rather than database rows. If deferred execution is unfamiliar, this guide to LINQ execution and materialization provides the broader LINQ context.
This model does not require a Repository. The DbContext API describes it as the EF Core session used to query and save entities; it exposes sets, coordinates change tracking, and saves database changes. Whether an application also needs a Repository is a separate architecture decision, and it is outside this article's intent.
If you need the broader persistence foundation first, start with this complete EF Core guide for .NET. For a deeper review of how EF Core combines filtering and projection, the EF Core LINQ querying guide provides the surrounding query context.
Keep the IQueryable Boundary Visible
A useful query specification should not hide when provider translation ends. This is the safe shape:
DbSet<Product>
-> Apply(specification)
-> AsNoTracking()
-> OrderBy(...)
-> Select(...)
-> Take(...)
-> ToListAsync(cancellationToken)
The dangerous alternative is compiling the expression and accidentally selecting Enumerable.Where:
specification.Criteria.Compile()
-> Func<Product, bool>
-> client-side filtering
Since EF Core 3.0, EF Core throws when an untranslatable expression appears outside the top-level projection. Explicit boundaries such as AsEnumerable, AsAsyncEnumerable, or materialization opt into client-side processing after that point.
The practical rule is simple: do not call Compile() inside an EF query pipeline. Compiling the criterion can be appropriate for a focused in-memory unit test, but that test says nothing about SQL translation.
Keep the criterion smaller than the use case
The specification should name the reusable filter, not impersonate the entire application operation. ActiveProductsAtOrBelowPrice answers which products qualify. It does not decide how many products a particular endpoint may return, whether the caller needs entities or summaries, or whether those entities will be edited.
That separation gives the same criterion room to participate in more than one bounded query. An admin export might select a different DTO and use a different limit. A background check might use AnyAsync instead of returning rows. The criterion can remain stable because those operations do not change what "active and at or below a price" means.
The opposite design puts OrderBy, Select, Take, and ToListAsync into an object that is still described as a predicate. That can work as a full query envelope, but it is a different model with more merge and execution rules. For this direct approach, keeping the expression small protects the boundary.
Treat compiler acceptance and provider translation as separate checks
A lambda can be legal C# and still be outside a provider's translation surface. Microsoft's expression-tree restrictions describe what the compiler can represent, while EF Core's client-evaluation guidance describes what happens when a provider cannot translate a represented expression.
Compiler-valid expression trees are not automatically SQL-translatable. Favor simple member access, comparisons, and Boolean operations when they express the rule clearly. When a criterion needs a database function or a less common method, treat it as provider-sensitive and prove it with the provider that will run in production.
Do not hide a translation failure by adding AsEnumerable() inside Apply. That changes the operation from a database filter to an in-memory filter and can move far more data across the boundary than the caller intended. If client processing is genuinely required, make the transition explicit at the use case so reviewers can see the materialization and bound.
A Complete Direct DbContext Example
The example targets net10.0 on .NET 10, C# 14, and EF Core 10.0.10. .NET 11, C# 15, and EF Core 11 are deliberately excluded. The relational test uses the matching SQLite provider so the query is translated and executed by a relational database engine.
The project needs these package references:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>14.0</LangVersion>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<NoWarn>$(NoWarn);CA1707</NoWarn>
<OutputType>Exe</OutputType>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.10" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit.v3" Version="3.0.0" />
</ItemGroup>
</Project>
The next three C# blocks are consecutive sections of one compile-valid file. The first block defines the entity, context, expression specification, and result DTO.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Xunit;
namespace DirectSpecificationExample;
public sealed class Product
{
private Product()
{
Name = string.Empty;
}
public Product(int id, string name, decimal price, bool isActive)
{
Id = id;
Name = name;
Price = price;
IsActive = isActive;
}
public int Id { get; private set; }
public string Name { get; private set; }
public decimal Price { get; private set; }
public bool IsActive { get; private set; }
}
public sealed class CatalogDbContext(DbContextOptions<CatalogDbContext> options)
: DbContext(options)
{
public DbSet<Product> Products => Set<Product>();
}
public interface IQuerySpecification<T>
{
Expression<Func<T, bool>> Criteria { get; }
}
public sealed class ActiveProductsAtOrBelowPrice(decimal maximumPrice)
: IQuerySpecification<Product>
{
public Expression<Func<Product, bool>> Criteria { get; } =
product => product.IsActive && product.Price <= maximumPrice;
}
public sealed record ProductSummary(int Id, string Name, decimal Price);
The second block applies only the predicate. The query service deliberately keeps every other policy visible. It rejects unbounded or excessive requests, applies no-tracking behavior for the read, uses deterministic ordering, projects to a DTO, and materializes with the supplied cancellation token.
public static class SpecificationQueryableExtensions
{
public static IQueryable<T> Apply<T>(
this IQueryable<T> query,
IQuerySpecification<T> specification)
{
ArgumentNullException.ThrowIfNull(query);
ArgumentNullException.ThrowIfNull(specification);
return query.Where(specification.Criteria);
}
}
public sealed class ProductQueryService(CatalogDbContext dbContext)
{
public IQueryable<ProductSummary> BuildQuery(
IQuerySpecification<Product> specification,
int take)
{
if (take is < 1 or > 100)
{
throw new ArgumentOutOfRangeException(nameof(take));
}
return dbContext.Products
.Apply(specification)
.AsNoTracking()
.OrderBy(product => product.Id)
.Select(product => new ProductSummary(
product.Id,
product.Name,
product.Price))
.Take(take);
}
public Task<List<ProductSummary>> ListAsync(
IQuerySpecification<Product> specification,
int take,
CancellationToken cancellationToken)
{
return BuildQuery(specification, take)
.ToListAsync(cancellationToken);
}
}
The third block is a relational integration test. It first inspects the generated SQLite command text, then executes the same query and checks the materialized results. No generated SQL is pasted into the article because SQL text is provider- and version-specific.
public sealed class DirectSpecificationIntegrationTests
{
[Fact]
public async Task ListAsync_MatchingSpecification_ReturnsBoundedRows()
{
var testCancellation = TestContext.Current.CancellationToken;
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync(testCancellation);
var options = new DbContextOptionsBuilder<CatalogDbContext>()
.UseSqlite(connection)
.Options;
await using var dbContext = new CatalogDbContext(options);
await dbContext.Database.EnsureCreatedAsync(testCancellation);
dbContext.Products.AddRange(
new Product(1, "Mechanical Keyboard", 80m, true),
new Product(2, "USB-C Dock", 150m, true),
new Product(3, "Archived Mouse", 40m, false),
new Product(4, "Desk Mat", 25m, true));
await dbContext.SaveChangesAsync(testCancellation);
var specification = new ActiveProductsAtOrBelowPrice(100m);
var service = new ProductQueryService(dbContext);
var query = service.BuildQuery(specification, take: 2);
var sql = query.ToQueryString();
Assert.Contains("WHERE", sql, StringComparison.OrdinalIgnoreCase);
Assert.Contains("LIMIT", sql, StringComparison.OrdinalIgnoreCase);
using var cancellation = new CancellationTokenSource(
TimeSpan.FromSeconds(10));
var products = await service.ListAsync(
specification,
take: 2,
cancellation.Token);
Assert.Collection(
products,
product => Assert.Equal("Mechanical Keyboard", product.Name),
product => Assert.Equal("Desk Mat", product.Name));
}
}
This example demonstrates the complete query path without pretending the predicate owns the entire query.
Follow the Query From Definition to Execution
It helps to trace the sample one stage at a time because each stage answers a different question.
First, ActiveProductsAtOrBelowPrice captures the rule's input and creates an expression. Constructing the specification does not contact the database. It only creates an object containing provider-visible criteria.
Second, Apply calls Where(specification.Criteria). The return type remains IQueryable<Product>. There is still no list and no database result. The query expression now contains the product filter.
Third, BuildQuery adds application policies. AsNoTracking declares a read intent. OrderBy(product => product.Id) makes the bounded result deterministic for the seeded example. Select narrows each row to the fields in ProductSummary. Take enforces the caller's validated maximum.
Fourth, the integration test calls ToQueryString() on the final IQueryable<ProductSummary>. That gives the test a chance to inspect the translated command shape before execution. The API returns debugging text; it does not alter the query or materialize rows.
Finally, ListAsync calls ToListAsync(cancellationToken). That terminal operation asynchronously creates a list from the query and is the visible database I/O and buffering boundary. The returned List<ProductSummary> is no longer a provider query that can accept more SQL-translated filters.
This staged reading is useful during code review. If a future change inserts AsEnumerable, moves Take after materialization, removes AsNoTracking, or returns full entities instead of the DTO, the boundary change is visible in one short pipeline.
Design the Application Method Around an Outcome
Returning a raw IQueryable<T> from every application method may feel flexible, but it pushes execution choices onto callers and makes the final query harder to audit. In the sample, BuildQuery is public only so the article's integration test can inspect the generated command. The operation used by application code is ListAsync, which returns a materialized, bounded result.
In a real codebase, one option is to keep BuildQuery private or internal and expose outcome-focused methods such as ListAffordableActiveProductsAsync. Another option is to let a data-access test assembly see internals. Either approach preserves direct DbContext use without turning the rest of the application into an open-ended EF query builder.
There is a practical balance:
- Returning materialized DTOs gives the application method a clear execution and data-shape contract.
- Keeping a narrowly scoped query builder testable allows generated SQL inspection before materialization.
- Exposing unrestricted
IQueryable<T>maximizes caller flexibility but also lets every caller change tracking, shape, bounds, and provider behavior.
The sample chooses the first two. The specification remains reusable, but the use case still owns a concrete outcome.
Why Each Query Decision Stays Outside the Specification
Each query decision below can vary without changing the Boolean meaning of ActiveProductsAtOrBelowPrice. Keeping those choices outside the criterion prevents accidental coupling between a reusable filter and one endpoint's execution policy.
Tracking policy
EF Core tracks entity-returning queries by default. AsNoTracking avoids adding returned entities to the context change tracker, while AsNoTrackingWithIdentityResolution offers a different identity-resolution tradeoff. A projection that contains no entity instances is not tracked.
The sample still calls AsNoTracking() explicitly because the read policy should be easy to see during review. It also projects to ProductSummary, so the returned values are not mutable entity instances.
Do not turn that choice into the absolute claim that no-tracking is always faster. The appropriate tracking mode depends on whether the operation intends to update entities and whether identity resolution is useful.
Bounded results
Microsoft recommends projecting only the columns a query needs and limiting potentially large result sets. The efficient querying guidance explains both concerns.
The sample enforces a take range from 1 through 100. That is a local application policy, not a universal EF Core limit. It prevents the apparently reusable specification from becoming an excuse to materialize every matching row.
This is deliberately not a pagination tutorial. Take supplies a bound for one query. Offset pagination, keyset pagination, cursor design, and total counts belong to a separate query-envelope concern.
Visible materialization and cancellation
Where, OrderBy, Select, and Take build the query, while ToListAsync executes it and buffers the returned rows. EF Core's asynchronous programming guidance explains that async terminal operations accept cancellation tokens and that EF Core does not support parallel operations through one DbContext.
Passing the token does not guarantee identical cancellation behavior across providers. It gives the provider the opportunity to observe the request. That is one more reason the production provider belongs in integration coverage.
Error handling stays outside the predicate
A specification should not turn translation failures, timeouts, or cancellations into a Boolean answer. Those failures describe query execution, not whether a product satisfies the criteria.
Let cancellation propagate as cancellation. Let an untranslatable expression fail the integration test instead of returning an empty list. If the application needs retries, logging, or a user-facing error result, place those policies around the terminal operation where the database call is visible.
This boundary prevents a dangerous ambiguity: an empty result should mean "the executed query matched no rows," not "the query could not be translated" or "the request was cancelled."
Generated SQL Is Evidence, Not the Whole Proof
ToQueryString() returns a debugging representation of the generated query. Microsoft explicitly notes that this string may not be directly executable and is intended for debugging, as documented by the ToQueryString API.
Inspecting it is useful. You can confirm that a filter exists, a projection is narrow, ordering is present, and a bound reached the provider. It is still not enough by itself. The integration test executes the query because command generation can succeed while data semantics, collation, null behavior, or provider functions still differ.
Avoid assertions against an entire SQL string unless the exact text is genuinely part of the test contract. Whitespace, aliases, parameters, and provider implementation details can change between servicing releases. Assert the critical shape, execute the query, and verify the result.
What the Relational Test Proves
The sample test proves four bounded statements:
- The composed LINQ query can be translated by the EF Core 10.0.10 SQLite provider.
- The generated command contains a filter and a result limit.
- The query executes against a relational database.
- The materialized rows match the seeded SQLite data.
It does not prove that SQL Server, PostgreSQL, MySQL, or another production provider will translate the expression identically. Providers can differ in translation and generally need to match EF Core's major version.
Microsoft's EF Core testing strategy recommends meaningful testing against the actual production database system. SQLite is useful as a relational smoke test, but it cannot prove production-provider collation, functions, query plans, or SQL text. EF Core InMemory is not relational and cannot provide this translation proof.
For a production application, retain the fast SQLite smoke test if it is useful, then add focused tests using the real provider for every provider-specific criterion.
Pros and Cons of the Direct Approach
Advantages
The direct approach has a small surface. A specification names reusable criteria, while ordinary EF Core APIs continue to express query execution. There is no additional Repository interface, evaluator pipeline, or package-specific query builder to learn.
It also makes review boundaries obvious. A reader can see where tracking changes, where the result is ordered, which columns are selected, how many rows may return, and where database I/O begins.
Tradeoffs
Direct use couples the query service to EF Core. If the application genuinely needs a persistence-independent application seam, a Repository or use-case-specific query interface may provide value. That decision should be based on actual testing and architecture needs, not on the idea that every DbContext requires another wrapper.
A predicate-only specification also does not centralize includes, projections, sorting, or pagination. That is intentional here. As those policies accumulate, you are moving toward a full query envelope, where conflict rules and execution semantics need a more deliberate design.
Finally, reusable expressions can still fail translation. Naming a predicate does not make an unsupported method provider-compatible.
Common Questions About Specification Pattern EF Core Queries
The recurring questions tend to come back to one boundary: criteria are reusable, but execution remains an explicit application and EF Core responsibility.
Can I apply the specification directly to DbSet?
Yes. DbSet<T> implements IQueryable<T>, so the Apply extension can call Where(specification.Criteria) directly. Keep later query decisions and terminal execution visible.
Why not store Func<T, bool> instead?
A Func<T, bool> is executable code, not an inspectable expression tree. Passing it to LINQ selects Enumerable.Where rather than giving EF Core a provider-visible predicate.
Should AsNoTracking be inside every specification?
Not in this predicate-only model. Tracking is an execution policy. Put it where the read or write intent is visible, and choose it deliberately.
Does ToQueryString prove the query works?
No. ToQueryString() returns debugging text, but the query must also execute through a relational provider and return the expected data.
Is SQLite enough for all integration tests?
No. SQLite can differ from the production database in case sensitivity, supported methods, and SQL behavior. Production-provider behavior still needs focused tests whenever collation, functions, SQL shape, null semantics, or query plans matter.
The Decision Boundary
Use this direct form when the application needs a few named, reusable EF Core predicates and the rest of the query remains clearer as ordinary LINQ. Start with the expression, keep IQueryable<T> intact, make tracking and bounds explicit, pass cancellation to the terminal operation, inspect the generated command, and execute against a relational provider.
That is the useful center of the Specification Pattern EF Core approach without a Repository. It names criteria without hiding the database boundary.

