Specifications
Specifications are a way to abstract part of a query into a reusable type.
Currently, there are four main kinds of specifications: Filter, Sort, Projection
and Aggregate.
To declare your own specifications, you always extend from one of the base
classes. Never implement ISpecification
, or its derivate interfaces, directly.
Filter Specification
Create a specification class in the Domain
module of your service, make it
extends from the FilterSpecification<TEntity>
class and set the condition to
follow as the Criteria
field.
C# |
---|
| public class MyEntityByNameFilterSpecification : FilterSpecification<PersonTestEntity>
{
public MyEntityByNameFilterSpecification(string name)
{
Criteria = x => x.Name == name;
}
}
|
Important
Domain layer specifications cannot reference any DB specific logic. It is about
the Domain Model, not about the infrastructure layer.
Since the specification can be used in memory, as well as in DB queries,
its expression must be able to be compiled, which means it must not involve
logic that can not be used outside of an IQueryable
The criterion of each specification should be as granular as the rule it
represents to be as concise as possible.
Avoid expressions involving different conditions that are loosely related,
instead, separate each condition into their own specification. This way it makes
it more apparent which rule the specification represents, eases the test of it
and could prevent unwanted behaviors when they are translated and applied to the
queries on the repositories.
Avoid:
C# |
---|
| public class UsersWithNameAndBirthday
: FilterSpecification<PersonTestEntity>
{
public UsersWithNameAndBirthday(string name, Date birthday)
{
Criteria = x => x.Name == name && x.Birthday == birthday;
}
}
|
Do:
C# |
---|
| public class UsersWithName
: FilterSpecification<PersonTestEntity>
{
public UsersWithName(string name)
{
Criteria = x => x.Name == name;
}
}
public class UsersWithBirthdayOn
: FilterSpecification<PersonTestEntity>
{
public UsersWithBirthdayOn(Date birthday)
{
Criteria = x => x.Birthday == birthday;
}
}
|
Specifications can be easily combined by using an Aggregate Specification.
DateRange Specification
This is a particular FilterSpecification that easily allows us to set the
condition that a date entity property value is included into a DateRange
.
A DateRange
can be built using an specific StartDate and EndDate, or an amount
of days and a direction (x days before the current date, x days after the
current date, or both).
To create a new specification class, just extend from
DateRangeSpecification<TEntity>
. For example:
C# |
---|
| public class PersonWithBirthDateInRange
: DateRangeSpecification<PersonTestEntity>
{
public PersonWithBirthDateInRange(DateRange dateRange)
: base(x => x.BirthDay, dateRange)
{
}
}
|
As Filter Specifications, they can be combined by using an Aggregate
Specification.
Sort Specification
Specifications can be used to define a sorting criteria.
Create a new class per sorting criteria, extending from
SortSpecification<TEntity>
.
C# |
---|
| public class PersonByNameSortSpecification
: SortSpecification<PersonTestEntity>
{
public PersonByNameSortSpecification(bool descending = false)
{
this.Criteria = p => p.Name;
this.Descending = descending;
}
}
|
Filter and Sort Specifications can be easily combined by using an Aggregate
Specification.
Projection Specification
When you'd like to SELECT a subset of your entity fields, or compose them, or
execute aggregation functions, a Projection Specification can be used.
Create a new class extending from
ProjectionSpecification<TEntity, TProjection>
C# |
---|
| public class PersonNamesProjectionSpecification
: ProjectionSpecification<PersonTestEntity, string>
{
public PersonNamesProjectionSpecification()
{
this.Projection = e => e.Name;
}
}
|
AutoMapper Projections
When mapping entities to DTOs, we can use the
AutoMapperProjectionSpecification
to use Auto Mapper's ProjectTo
.
This specification does not require a custom implementation and can just be
instantiated with the proper generic parameters, and an instance of IMapper
which must be retrieved from DI.
C# |
---|
| // var mapper = .. // inject the mapper from DI
var spec = new AutoMapperProjectionSpecification<PersonTestEntity, PersonDto>(mapper);
|
Important
When working with GraphQL, if you need to return a projection of an entity
over a DTO you should use AutoMapperHotChocolateProjectionSpecification
,
see this.
Aggregate Specification
The Aggregate can be used to easily combine multiple specifications into one in
order to execute a query against a repository, for example.
Aggregates can be created on the fly by simply instantiating a new one:
C# |
---|
| var aggregate = AggregateSpecification
.For<PersonTestEntity>()
.AndFilter(new PersonByNameFilterSpecification(name))
.AddSort(new PersonByNameSortSpecification())
.AddSort(new PersonByCountrySortSpecification());
|
Important
Aggregates are immutables. Calling its methods returns a new instance, hence
it's important to always assign its results; just like with IEnumerable
s.
Combining two Aggregates
Aggregates can be composed as well by using the AndAggregate
and OrAggregate
methods.
C# |
---|
| var childAggregate = AggregateSpecification.For<PersonTestEntity>()
.OrFilter(new PersonByNameFilterSpecification(name))
.OrFilter(new PersonByNameStartsWithFilterSpecification("Baby"));
var secondChildAggregate = AggregateSpecification.For<PersonTestEntity>()
.OrFilter(new PersonByNameFilterSpecification("Juan Doe"))
.OrFilter(new PersonByIdFilterSpecification(123123213));
var aggregate = AggregateSpecification.For<PersonTestEntity>()
.AndAggregate(childAggregate)
.AndAggregate(secondChildAggregate)
.AddSort(new PersonByNameSortSpecification());
|
Executing Specifications in-memory
Any of the classes implementing ISpecification
can be applied to IQueryable
s
or IEnumerable
s:
C# |
---|
| var johns = new [] {
new PersonTestEntity("John Doe")
};
var aggregate = AggregateSpecification
.For<PersonTestEntity>()
.AndFilter(new PersonByNameFilterSpecification("John Doe"));
var result = aggregate.Apply(johns);
|
Executing Specifications against the Database
The IRepository.SearchAsync
receives an ISpecification<TEntity>
so any of
the provided Specifications can be used. It is most convenient to provide an
AggregateSpecification
so that you can combine multiples to perform a complex
query.
The convention to perform searches in the Application
layer, taking as example
a method on the AppService which takes a FilterDto as parameter, is as
follows:
C# |
---|
| public async Task<IEnumerable<IDomainDto>> SearchDomains(
DomainFilterDto filter,
CancellationToken ct = default)
{
// Create the Aggregate and add default specifications.
var spec = AggregateSpecification
.For<Domain>()
.AddSort(new DomainsByNameSortSpecification());
// We add each new rule to the list of specifications.
if (!filter.Name.IsNullOrEmpty())
{
spec = spec.AndFilter(new DomainsWithNameFilterSpecification(filter.Name));
}
// We talk about "rules" since it could be that a field of the filter
// implies certain combination of different specifications (through logic operations)
// to form the criteria to be met.
if (filter.IsEligible.HasValue)
{
var eligibleDomainSpec = new EligibleDomainsFilterSpecification();
spec = spec.AndFilter(filter.IsEligible.Value ? eligibleDomainSpec : !eligibleDomainSpec);
}
if (filter.Ids?.Any() == true)
{
spec = spec.AndFilter(new DomainsWithIdsInFilterSpecification(filter.Ids.ToList()));
}
// Finally, to perform the search we simply use the "SearchAsync" method
// provided by the BaseRepository class.
var domains = await domainRepository.SearchAsync(spec, ct);
// [...]
}
|
EFCore Includes
When we need to .Include
relations into our query, we can use the
IncludeSpecification<TEntity>
to define them:
C# |
---|
| public class PersonIncludeChildrenSpecification
: IncludeSpecification<PersonTestEntity>
{
public PersonIncludeChildrenSpecification()
{
this.Include(x => x.Children)
.ThenInclude(c => c.Parent);
}
}
|
If we need to .Include
more than one relation of the same entity into our
query:
C# |
---|
| public class ServiceOrderIncludeIssueSpecification : IncludeSpecification<ServiceOrder>
{
public ServiceOrderIncludeIssueSpecification()
{
var issueInclude = this.Include(x => x.Issue);
issueInclude.ThenInclude(x => x.DefaultPriority);
issueInclude.ThenInclude(x => x.DefaultDepartment);
}
}
|
Polymorphic Child Inheritance
Some times we have root entities, that have children which may be of different
types inheriting from a base class, and the entity has a list of the base class.
When creating includes for the root entity's children, we need to use this
hackish wizardry:
C# |
---|
| public abstract class BaseChild {}
public class ConcreteChild : BaseChild {
MyRelation Relation {get;}
}
public class OtherChild : BaseChild {
OtherRelation OtherRelation {get;}
}
public class ChildrenSpecificPropertiesIncludeSpecification
: IncludeSpecification<BaseChild>
{
public ChildrenSpecificPropertiesIncludeSpecification()
{
this.Include(c => (c as ConcreteChild)!.Relation)
.ThenInclude(r => r.RelationCustomProperty);
this.Include(c => (c as OtherChild)!.OtherRelation)
.ThenInclude(r => r.CustomProperty);
}
}
|
Info
In this example we are mapping the abstract class BaseChild
that can be a
ConcreteChild
which has the property Relation
and we also need to
include the children of this property. If the abstract class had more
implementations, just add another builder with the same approach.
You can combine an IncludeSpecification
with the AggregateSpecification
to
generate a complex query:
C# |
---|
| var aggregate = AggregateSpecification
.For<PersonTestEntity>()
.AndFilter(new PersonByNameFilterSpecification(name))
.AddSort(new PersonByCountrySortSpecification())
.AddInclude(new PersonIncludeChildrenSpecification());
var result = await this.repository.SearchAsync(aggregate);
|
Validations on application layer
To execute an specification in memory, against an Enumerable or Queryable, you
can simply use the Apply
method. For example:
C# |
---|
| var spec = new DomainsByNameFilterSpecification("Test Domain");
var list = new List<Domain>(){
// [..]
};
// result contains the resulting list
// after applying the spec to the original list.
var result = spec.Apply(list);
|
Unit tests
As long as we keep our specifications' criterion simple, our testing will often
only imply to check if the rule it represents is truly the one we expected, this
means, that the criterion it has is met by the models which should satisfy it,
and not met by those models who shouldn't.
For example, for the following specification:
C# |
---|
| public class UsersWithName : FilterSpecification<User>
{
public UsersWithName(string name)
{
Criteria = x => x.Name == name;
}
}
|
The tests we will need to define will be the following:
C# |
---|
| [Fact]
public void UsersWithNameSpecification_EvaluatesUserWhichDoesSatisfiesIt_ShouldReturnTrue()
{
// Arrange.
string name = "John Doe";
var users = new [] {
new User(name)
};
// Act.
var spec = new UsersWithName(name);
var results = spec.Apply(users);
// Assert.
Assert.Single(results);
}
[Fact]
public void UsersWithNameSpecification_EvaluatesUserWhichDoesNotSatisfyIt_ShouldReturnFalse()
{
// Arrange.
string name = "John Doe";
var users = new [] {
new User(name)
};
// Act.
var spec = new UsersWithName("John Nope");
var results = spec.Apply(users);
// Assert.
Assert.Empty(results);
}
|