Skip to content

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#
1
2
3
4
5
6
7
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#
1
2
3
4
5
6
7
8
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#
1
2
3
4
5
6
7
8
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#
1
2
3
4
5
6
7
8
9
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#
1
2
3
4
5
6
7
8
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#
1
2
3
4
5
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 IEnumerables.

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 IQueryables or IEnumerables:

C#
1
2
3
4
5
6
7
8
9
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#
1
2
3
4
5
6
7
8
9
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#
1
2
3
4
5
6
7
8
9
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#
1
2
3
4
5
6
7
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#
1
2
3
4
5
6
7
8
9
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#
1
2
3
4
5
6
7
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);
}