How to add extensions to the specification builder
The specification builder from Ardalis.Specification
is extensible by design. In fact, the methods you can use out of the box are implemented as extension methods themselves (check out the source code). Your project might have requirements that cannot be satisfied by the existing toolset of course, or you might want to simplify repetitive code in several specification constructors. Whatever your case, enhancing the default builder is easy by creating your own extension methods.
So where do you start? A good practice is to write the thing you think you need. Say you’d like to use a builder method WithCustomerIdAndName
that takes the Id
and Name
of a customer as parameters. Then just write it like so:
Query.AsNoTracking()
.WithCustomerIdAndName(1337, "John Doe");
From here you can inspect the return type of the builder method you chained it to (AsNoTracking
), and create an extension method on that interface (it doesn’t need to be chained of course – working on Query
itself is also valid). This will most likely be ISpecificationBuilder<T>
, but in some cases it’s an inherited inteface. The example below illustrates how extension methods on inherited interfaces allow the builder to offer specific methods in specific contexts.
Example: Configure caching behavior through specification builder extension method
In order to achieve this (note the .WithTimeToLive
method):
public class CustomerByNameWithStores : Specification<Customer>
{
public CustomerByNameWithStores(string name)
{
Query.Where(x => x.Name == name)
.EnableCache(nameof(CustomerByNameWithStoresSpec), name)
// Can only be called after .EnableCache()
.WithTimeToLive(TimeSpan.FromHours(1))
.Include(x => x.Stores);
}
}
We can create a simple extension method on the specification builder:
public static class SpecificationBuilderExtensions
{
public static ISpecificationBuilder<T> WithTimeToLive<T>(this ICacheSpecificationBuilder<T> @this, TimeSpan ttl)
where T : class
{
// The .SetCacheTTL method is an extension method which is discussed below
@this.Specification.SetCacheTTL(ttl);
return @this;
}
}
This extension method can only be called when chained after EnableCache
. This is because EnableCache
returns ICacheSpecificationBuilder<T>
which inherits from ISpecificationBuilder<T>
. Which is nice because it helps the IDE to give the right suggestions in the right place, and because it avoids confusing code as the .WithTimeToLive
cannot be used without its parent EnableCache
method.
The next thing we need to is use the TTL information in a repository. For example:
public class Repository<T>
{
private DbContext _ctx;
private MemoryCache _cache;
public List<T> List(ISpecification<T> spec)
{
var specificationResult = SpecificationEvaluator.Default.GetQuery(_ctx.Set<T>().AsQueryable(), spec);
if (spec.CacheEnabled)
{
// The .GetCacheTTL method is an extension method which is discussed below
var ttl = spec.GetCacheTTL();
// Uses Microsoft's MemoryCache to cache the result
_cache.GetOrCreate(spec.CacheKey, ce =>
{
ce.AbsoluteExpiration = DateTime.Now.Add(ttl);
return specificationResult.ToList();
});
}
else
{
return specificationResult.ToList();
}
}
}
Finally, we need to take care of some plumbing to implement the .GetCacheTTL
and .SetCacheTTL
methods that we’ve used in the example repository and builder extension.
public static class SpecificationExtensions
{
public static void SetCacheTTL<T>(this ISpecification<T> spec, TimeSpan timeToLive)
{
spec.Items["CacheTTL"] = timeToLive;
}
public static TimeSpan GetCacheTTL<T>(this ISpecification<T> spec)
{
spec.Items.TryGetValue("CacheTTL", out var ttl);
return (ttl as TimeSpan?) ?? TimeSpan.MaxValue;
}
}