Skip to main content

Command Palette

Search for a command to run...

The Specification Pattern in ASP.NET Core: When to Use It and How

Updated
โ€ข13 min read
The Specification Pattern in ASP.NET Core: When to Use It and How

Enterprise .NET teams integrating the Specification Pattern into their ASP.NET Core applications consistently face the same fork in the road: does this pattern genuinely solve a data-access problem, or does it add a layer of indirection that the codebase doesn't need? The answer depends almost entirely on the domain's complexity and the team's long-term intent โ€” not on framework defaults. Getting this call right early avoids months of refactoring later. The full production-ready implementation โ€” with composable specifications, pagination support, and EF Core integration wired into a complete domain layer โ€” is available on Patreon, ready to run and adapt.

For teams building on top of ASP.NET Core Web API with EF Core, this decision becomes even sharper. Understanding the Specification Pattern in the context of a real production codebase โ€” not just a textbook example โ€” is exactly what Chapter 3 of the Zero to Production course covers, specifically in the section on the repository pattern and EF Core beyond the basics.

What Problem Does the Specification Pattern Actually Solve?

The Specification Pattern is a domain-driven design concept that encapsulates a business query into a named, reusable object. Instead of scattering filter logic across repositories, controllers, or service layers, a Specification holds the complete definition of a query โ€” including filtering criteria, ordering, pagination, and eager loading instructions โ€” in one place.

The core tension it resolves is this: as domain complexity grows, query logic tends to leak. A GetActiveCustomers() method in one repository, a GetHighValueCustomers() in another, and a GetCustomersByRegionAndStatus() somewhere in a service layer. None of these are individually wrong, but together they form an unmaintainable query sprawl. Specifications address that sprawl by making the query itself a first-class citizen of the domain.

Microsoft's own architecture guidance for microservices, published at learn.microsoft.com, describes the Query Specification as the correct place to consolidate filter and ordering logic in DDD-aligned persistence layers.

When to Use the Specification Pattern

The Specification Pattern earns its place when at least two of the following conditions are true:

Query combinations are legitimately complex. When a repository consistently receives requests like "give me customers who are active, in a specific region, registered before a date, with outstanding balances above a threshold, sorted by account age" โ€” the parameters are business-level concepts, not just database filters. Specifications model that intent explicitly.

The same query runs across multiple use cases. If the same filter logic reappears across different command handlers, background jobs, and API endpoints, extracting it into a named Specification eliminates duplication and creates a single point of change when business rules evolve.

You're working within a DDD-aligned architecture. If the application uses a domain layer with aggregates and value objects, Specifications fit naturally. They keep query logic close to the domain model, respect bounded context boundaries, and don't push database concerns into domain services.

Testing query logic independently matters. Specifications can be unit-tested without touching EF Core or a database. A ActiveHighValueCustomerSpecification can be verified against an in-memory list. This is a genuine advantage over raw IQueryable composition โ€” particularly in teams that maintain rigorous unit test coverage.

When Not to Use the Specification Pattern

The pattern is frequently over-applied, and the costs of doing so are real.

CRUD-dominated applications don't benefit. When the vast majority of queries are simple GetById, GetAll, GetByStatus calls, a Specification layer adds ceremony without payoff. EF Core's DbContext already provides a clean, expressive query API. Wrapping it in a Specification framework just moves the work.

When CQRS is already in place. In a CQRS architecture โ€” particularly one using MediatR pipeline behaviors with dedicated Query handlers โ€” each query already has a named, encapsulated location with its own handler. Adding Specifications on top creates a second layer of query encapsulation that usually conflicts rather than complements. Choose one.

Small teams or short-lived projects. The investment in a Specification infrastructure โ€” defining the ISpecification<T> contract, building the evaluator, maintaining the spec library โ€” only pays off at scale. For a two-person team building a focused internal tool, it's over-engineering.

When you don't own the EF Core DbContext. The Specification Pattern relies on composing IQueryable<T> expressions. If data access goes through a micro-ORM like Dapper, raw SQL, or a third-party service SDK, the pattern doesn't translate.

Core Concepts

A well-designed Specification for ASP.NET Core with EF Core has three responsibilities:

Criteria โ€” the Expression<Func<T, bool>> predicate that maps directly to a WHERE clause via LINQ-to-Entities. This is the heart of the specification.

Includes โ€” the navigation properties to eagerly load via Include() / ThenInclude(). Centralising these in the specification prevents N+1 scenarios from being re-introduced at different call sites.

Ordering and Pagination โ€” sort expressions and Skip/Take logic. This keeps the repository thin: it applies the specification's instructions rather than receiving parameters for each individual sort option.

The ISpecification<T> interface typically exposes these three concerns as properties, and a SpecificationEvaluator (a static or injected helper) translates them into an IQueryable<T> chain against the DbContext. This is the exact approach recommended in the Ardalis Specification library โ€” one of the most widely adopted implementations in the .NET ecosystem.

The ISpecification Interface Shape

A minimal but complete ISpecification<T> interface in .NET looks like this:

public interface ISpecification<T>
{
    Expression<Func<T, bool>>? Criteria { get; }
    List<Expression<Func<T, object>>> Includes { get; }
    Expression<Func<T, object>>? OrderBy { get; }
    Expression<Func<T, object>>? OrderByDescending { get; }
    int Take { get; }
    int Skip { get; }
    bool IsPagingEnabled { get; }
}

Implementations extend a BaseSpecification<T> abstract class that populates these properties. Business-specific specs โ€” like ActiveOrdersForCustomerSpecification โ€” inherit from it, express the criteria and includes in the constructor, and nothing else. The spec itself is pure: no EF Core dependencies, no infrastructure concerns.

The SpecificationEvaluator<T> then takes an IQueryable<T> from the repository and applies each spec's instructions in the correct order โ€” criteria first, then includes, then ordering, then paging โ€” producing a composed query that EF Core translates to SQL.

Decision Matrix: Specification vs Alternatives

Scenario Specification Pattern Named Repository Methods Raw CQRS Queries IQueryable Leakage
Complex domain filtering โœ… Best fit โš ๏ธ Grows unwieldy โœ… Also valid โŒ Avoid
Simple CRUD โŒ Overkill โœ… Fine โœ… Fine โœ… Acceptable
Reusable filter across use cases โœ… Best fit โš ๏ธ Requires duplication โš ๏ธ Requires duplication โŒ Risky
CQRS + MediatR already in use โŒ Usually redundant โŒ Usually redundant โœ… Preferred โŒ Avoid
DDD aggregate-per-repository โœ… Natural fit โœ… Also works โœ… Also works โŒ Avoid
Unit testing query logic โœ… Easy โŒ Tied to DB โš ๏ธ Depends on handler โŒ Hard

Anti-Patterns to Avoid

The God Specification. A specification that accepts 12 optional parameters and branches internally based on which ones are null. This defeats the purpose: a specification should express one named business concept, not serve as a universal query builder. If you find yourself adding if (criteria.IncludeDeleted) inside a base specification, you have a God Specification.

Specifications without domain meaning. GetByIdSpecification, GetAllSpecification, FilterByStatusSpecification โ€” these are not specifications in the DDD sense; they're parameterised queries dressed up in a pattern. They don't represent a business concept; they just shift the parameter passing one level up.

Composing specifications with AND/OR at the call site. While it's technically possible to combine specifications using && / || expression trees, doing this at the application layer usually means the business rule itself hasn't been properly named. If two specifications are always used together, they should be one.

Bypassing the specification in some repositories. If half of the repository methods use specifications and half accept raw parameters, the architecture is inconsistent. Teams end up unsure which approach to use for new features. Pick one mode and enforce it.

Ignoring AsNoTracking. Specifications used purely for read operations should disable change tracking. The specification evaluator should support an AsNoTracking() option โ€” particularly important in high-read scenarios where EF Core's change tracker adds overhead without benefit.

Integration with ASP.NET Core's DI Pipeline

Specifications themselves need no DI registration โ€” they're simple objects, constructed at the call site and passed to the repository. The SpecificationEvaluator may be registered as a singleton if it's injected rather than called statically. The repository interface (IRepository<T>) and its EF Core implementation (EfRepository<T>) are registered normally via IServiceCollection.

This keeps the infrastructure layer clean: the DI container handles lifetime management of the repository and evaluator; the domain layer owns the specification definitions; the application layer composes them per use case.

For teams already using the Ardalis Specification NuGet package, the official documentation covers the exact integration pattern for EF Core, including support for compiled queries and async enumeration โ€” both relevant for performance-sensitive paths.

Does the Specification Pattern Replace the Repository?

No โ€” and this is a critical distinction. The Specification Pattern describes what to query. The repository describes how to query it. They are complementary, not interchangeable.

A common misconception is that Specifications allow direct access to DbContext, eliminating the need for a repository abstraction. While technically feasible โ€” passing a DbContext directly to a SpecificationEvaluator โ€” doing so removes the testability benefit. The repository interface remains the boundary: it accepts a specification and returns a typed result. The implementation uses EF Core. Tests mock the repository interface.

This is particularly relevant in the context of Clean Architecture, where the repository interface lives in the Domain or Application layer and has no dependency on EF Core. If the Specification Pattern introduces an EF Core Expression<Func<T, bool>> into the domain layer directly, it violates the dependency rule. The interface should accept a domain-level specification; the infrastructure layer translates it to an EF Core query.

Trade-offs Summary

Trade-off Specification Pattern Alternative
Query reusability High โ€” specs are composable, named units Low for named methods; medium for CQRS queries
Testability High โ€” specs are pure expressions Medium for CQRS; low for raw repository methods
Infrastructure coupling Low โ€” specs are expression trees, not EF calls High when IQueryable leaks to application layer
Complexity overhead Medium โ€” requires spec + evaluator infrastructure Low for simple repos; equivalent for CQRS
DDD alignment Strong โ€” fits bounded contexts naturally Varies
Learning curve Medium โ€” uncommon outside DDD contexts Lower for CRUD-first teams

What Teams Get Wrong at Scale

The most common failure at scale isn't the pattern itself โ€” it's inconsistent adoption. Teams that introduce specifications for complex read operations but continue using raw repository methods for simpler ones end up with a split codebase. New developers don't know which style to use. Code reviews become debates. The abstraction layer cost is paid without the consistency benefit being earned.

The second failure is treating the specification as a DTO. When specs start accepting int page, string sortField, bool includeDeleted as constructor parameters, they stop being domain objects and become query builders. The naming becomes meaningless, and the domain-layer knowledge they were meant to capture is lost.

The third is neglecting the evaluator. As EF Core versions change โ€” adding support for features like compiled queries, raw SQL mixing, or split queries โ€” the evaluator is the single point that needs updating. Teams that hard-code specification evaluation inline in each repository lose this advantage.

For teams evaluating where the Specification Pattern fits within their overall architecture, the Vertical Slice vs Clean Architecture guide covers the higher-level structure decision that often determines whether a dedicated domain layer โ€” and therefore Specifications โ€” makes sense at all.


โ˜• Prefer a one-time tip? Buy us a coffee โ€” every bit helps keep the content coming!


FAQ

What is the Specification Pattern in ASP.NET Core? The Specification Pattern encapsulates a query โ€” including its filtering criteria, eager-loading instructions, and paging logic โ€” into a named domain object. In ASP.NET Core with EF Core, this means defining an ISpecification<T> that expresses business intent as an expression tree, which a SpecificationEvaluator translates into an IQueryable<T> chain.

Should I use the Specification Pattern with CQRS and MediatR? Generally, no. In a CQRS architecture, each Query handler already encapsulates its own retrieval logic in a named, isolated handler class. Adding a Specification layer on top creates a second, redundant abstraction. Choose one: CQRS query handlers or Specifications. Teams that try to combine both usually end up with unnecessary complexity without extra benefit.

What is the Ardalis Specification library and should I use it? The Ardalis Specification library is a popular, well-maintained NuGet package that provides a production-grade ISpecification<T> base, evaluators, and EF Core integration. For teams adopting the pattern at scale, it's a strong choice โ€” it handles edge cases like async enumeration, AsNoTracking, and compiled queries. For teams wanting minimal dependencies, rolling a lightweight version is also viable.

Does the Specification Pattern replace the repository pattern? No. Specifications describe what to query; repositories describe how to query. The two are complementary. A repository interface accepts a Specification and returns typed results. The EF Core implementation of that repository translates the specification to a query. This separation preserves testability and keeps EF Core out of the domain layer.

How do I unit test a Specification? Specifications are pure expression trees and can be evaluated against in-memory IEnumerable<T> lists. Use spec.Criteria.Compile() to produce a compiled delegate and apply it to a list of test objects. This validates the filter logic without requiring EF Core or a database connection โ€” making spec tests fast and reliable.

When does the Specification Pattern become an anti-pattern? When specifications lose domain meaning. If you find yourself creating GetByIdSpecification(int id), GetAllSpecification(), or accepting a dozen optional parameters in a single specification class, you're using the pattern as a query builder, not as a domain concept. The pattern's value comes from named, business-meaningful specifications โ€” ActiveHighValueCustomerSpecification, OrdersReadyForFulfillmentSpecification. The moment specs stop being named domain concepts, the abstraction cost is no longer justified.

Can I use the Specification Pattern without a repository abstraction? Yes, technically โ€” you can pass a DbContext directly to a SpecificationEvaluator. But you lose the testability that makes the pattern valuable. Without a repository interface, you can't substitute a mock in unit tests. Most DDD-aligned teams use the two together: the specification defines the query, and the repository provides the testability boundary.