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 630× faster for in-memory filtering than Ardalis.Specification, measured in the same run over 100 items, because nothing is compiled per call, and it allocates nothing against 4,743 B.
- 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. Allocations are exact; speed is a same-run ratio.
| Operation | Ardalis.Specification time ÷ ZA's | Ardalis alloc | ZA alloc |
|---|---|---|---|
| Construct composed spec | ZA below resolution | 792 B | 0 B |
| Compose two specs | ZA below resolution | 1,752 B | 0 B |
| Evaluate over 100 items (in-memory) | 630× (632–633) | 4,743 B | 0 B |
ZA's construct and compose are below what BenchmarkDotNet can tell apart from an empty method, so no finite ratio can be given for them. 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.