Source-generated specification pattern for .NET, an Ardalis.Specification alternative: specs are structs, so composing and evaluating them allocates nothing, and the same spec still translates to SQL through EF Core.
- Composing and evaluating allocates nothing.
And,OrandNotbuildreadonly structvalues. Composing and callingIsSatisfiedByallocates 0 B, asserted by tests. Ardalis.Specification allocates 1,752 B to compose two specs. - About 320× faster for in-memory filtering than Ardalis.Specification, measured in the same run: 100 items in 49 ns instead of 15,599 ns, because nothing is compiled per call.
- One spec for memory and the database. Every spec also exposes
ToExpression(), so EF Core turns the composed spec into aWHEREclause. - Works under NativeAOT and trimming, with a NativeAOT smoke app published and run in CI. Mistakes such as
[Specification]on a class or a missingpartialare compile errors ZA001 to ZA003.
dotnet add package ZeroAlloc.SpecificationThe source generator ships inside this package. Targets .NET 8 and later.
using System.Linq.Expressions;
using ZeroAlloc.Specification;
User[] users = [new("Ann", true, 1500m), new("Bob", true, 200m), new("Cy", false, 3000m)];
var spec = new ActiveUserSpec().And(new BigSpenderSpec(1000m)); // a struct, 0 B
Console.WriteLine(users.Count(u => spec.IsSatisfiedBy(u))); // 1, in memory
Console.WriteLine(spec.ToExpression()); // one combined expression for dbContext.Users.Where(...)
public sealed record User(string Name, bool IsActive, decimal TotalSpend);
[Specification]
public readonly partial struct ActiveUserSpec : ISpecification<User>
{
public bool IsSatisfiedBy(User user) => user.IsActive;
public Expression<Func<User, bool>> ToExpression() => u => u.IsActive;
}
[Specification]
public readonly partial struct BigSpenderSpec(decimal minSpend) : ISpecification<User>
{
public bool IsSatisfiedBy(User user) => user.TotalSpend >= minSpend;
public Expression<Func<User, bool>> ToExpression() { var min = minSpend; return u => u.TotalSpend >= min; }
}The generator adds And, Or and Not to every [Specification] struct. Evaluation is a direct call on a struct that the JIT can inline, with no virtual dispatch.
- Fluent composition:
And,OrandNot, nested to any depth, without allocating. - Static builder:
Spec.And,Spec.OrandSpec.Notwhen you want explicit type arguments. - In-memory evaluation through
IsSatisfiedBy, for hot loops and domain rules. - EF Core translation through
ToExpression(), which combines the specs' expression trees into one. - Compile-time checks that each spec is a
readonly partial structimplementingISpecification<T>. - Recipes for an EF Core repository, complex compositions and caching the expression of a stateless spec.
Building an expression tree with ToExpression() does allocate, as every expression tree does. Cache it for stateless specs.
Head-to-head vs Ardalis.Specification 9.3.1, .NET 10.0.13, BenchmarkDotNet v0.15.8.
| Operation | Ardalis.Specification | ZA.Specification | Speedup |
|---|---|---|---|
| Construct composed spec | 305 ns / 792 B | < 1 ns / 0 B | > 300× faster, 0 B alloc |
| Compose two specs | 821 ns / 1,752 B | < 1 ns / 0 B | > 800× faster, 0 B alloc |
| Evaluate over 100 items (in-memory) | 15,599 ns / 4,743 B | 49 ns / 0 B | ~320× faster, 0 B alloc |
Rows under 1 ns are below what BenchmarkDotNet can tell apart from an empty method, so those speedups are lower bounds. The machine was shared while measuring, so timings are approximate; the allocations are exact. For database queries the SQL provider dominates and the gap is invisible; for in-memory filtering it dominates. Full methodology: docs/performance.md.
- Documentation: specification.zeroalloc.net. Start with getting started, core concepts and the diagnostics reference.
- Release history: CHANGELOG.md.
- More ZeroAlloc libraries, such as ZeroAlloc.Validation and ZeroAlloc.Mapping: zeroalloc.net.
- Issues and pull requests are welcome on GitHub.
- MIT licensed. See LICENSE.