Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save haritha99ch/d395d1fd4ff9837caec5a4691c55f441 to your computer and use it in GitHub Desktop.
Save haritha99ch/d395d1fd4ff9837caec5a4691c55f441 to your computer and use it in GitHub Desktop.
.NET Entity Framework: Extending Specification Pattern with Projections

Extending Specification Pattern with Projections

Specification pattern is a design pattern that allows to encapsulate some pieces of domain logics into a single unit, that can be passed around the system. It's a way to take some conditional logic and put it into a reusable, composable unit.

It is important to have a good understanding of the Repository pattern and the Specification pattern in Entity Framework Core.

Ordinary Specification

In its typical form, a specification is composed of one or more criteria, allowing entities to be filtered based on these criteria. Additional properties like sorting and pagination can also be incorporated. Here's a basic implementation of a Specification class.

public abstract class Specification<TEntity> where TEntity : IAggregateRoot
{
    public Expression<Func<TEntity, bool>>? Criteria { get; init; }
    public List<Func<IQueryable<TEntity>, IIncludableQueryable<TEntity, object>>> Includes { get; } = [];
    public Expression<Func<TEntity, object>>? OrderBy { get; private set; }
    public Expression<Func<TEntity, object>>? OrderByDescending { get; private set; }
    public Pagination? Pagination { get; private set; }

    protected Specification(Expression<Func<TEntity, bool>>? criteria)
    {
        Criteria = criteria;
    }

    protected void AddInclude(
            Func<IQueryable<TEntity>, IIncludableQueryable<TEntity, object>> include
        ) => Includes.Add(include);

    protected void AddOrderBy(Expression<Func<TEntity, object>> orderByExpression)
        => OrderBy = orderByExpression;

    protected void AddOrderByDescending(
            Expression<Func<TEntity, object>> orderByDescendingExpression
        ) => OrderByDescending = orderByDescendingExpression;

    protected void AddPagination(Pagination pagination) => Pagination = pagination;
}

However, the ordinary implementation of the Specification pattern does not include support for projections or selections.

New Specification

To address the absence of projection capabilities, an extended version of the Specification pattern introduces a second type parameter, TResult, to the Specification class. This facilitates the definition of projections or selections within the specification itself.

public abstract class Specification<TEntity, TResult>
    where TEntity : IAggregateRoot where TResult : ISelector {}

In this context, TResult represents the projection of the entity. Rather than returning the entire entity, the query will return only the properties specified in the projection.

Defining a Projection

To define a projection, a mechanism is needed for handling both single and multiple select expressions. This is achieved through the Select struct, which is also an example of using Result pattern or the workaround of C# for Union type.

public readonly struct Select<TEntity, TResult> where TEntity : IAggregateRoot where TResult : ISelector
{
    private readonly Expression<Func<TEntity, TResult>>? _selectSingle;
    private readonly Expression<Func<TEntity, IEnumerable<TResult>>>? _selectMany;

    private Select(Expression<Func<TEntity, TResult>> selectSingle)
    {
        _selectSingle = selectSingle;
        _selectMany = null;
    }

    private Select(Expression<Func<TEntity, IEnumerable<TResult>>> selectMany)
    {
        _selectMany = selectMany;
        _selectSingle = null;
    }

    public static implicit operator Select<TEntity, TResult>(Expression<Func<TEntity, TResult>> selectSingle)
        => new(selectSingle);
    public static implicit operator Select<TEntity, TResult>(Expression<Func<TEntity, IEnumerable<TResult>>> selectMany)
        => new(selectMany);

    public IQueryable<TResult> Handle(IQueryable<TEntity> query)
        => _selectSingle is not null ? query.Select(_selectSingle) : query.SelectMany(_selectMany!);
}

The Select struct has two private readonly fields, _selectSingle and _selectMany, to store the select expressions for single and multiple result queries respectively. It also provides two implicit conversion operators and a Handle method to handle the select expression in evaluation. This Select object can be used in the new Specification to handle select expression.

Implementation in the New Specification Object

Addition to the existing Specification, new members should be introduced in the new Specification.

public Select<TEntity, TResult> Select { get; private set; }
protected void ProjectTo(Expression<Func<TEntity, TResult>> select) => Select = select;
protected void ProjectTo(Expression<Func<TEntity, IEnumerable<TResult>>> select) => Select = select;
public abstract class Specification<TEntity, TResult>
    where TEntity : IAggregateRoot where TResult : ISelector
{
    public Expression<Func<TEntity, bool>>? Criteria { get; }
    public List<Func<IQueryable<TEntity>, IIncludableQueryable<TEntity, object>>> Includes { get; } = [];
    public Expression<Func<TEntity, object>>? OrderBy { get; private set; }
    public Expression<Func<TEntity, object>>? OrderByDescending { get; private set; }
    public Pagination? Pagination { get; private set; }
    public Select<TEntity, TResult> Select { get; private set; }

    protected Specification(Expression<Func<TEntity, bool>>? criteria = null)
    {
        Criteria = criteria;
    }

    protected void AddInclude(Func<IQueryable<TEntity>, IIncludableQueryable<TEntity, object>> include)
        => Includes.Add(include);

    protected void AddOrderBy(Expression<Func<TEntity, object>> orderByExpression) => OrderBy = orderByExpression;

    protected void AddOrderByDescending(Expression<Func<TEntity, object>> orderByDescendingExpression)
        => OrderByDescending = orderByDescendingExpression;

    protected void AddPagination(Pagination? pagination) => Pagination = pagination;
    protected void ProjectTo(Expression<Func<TEntity, TResult>> select) => Select = select;
    protected void ProjectTo(Expression<Func<TEntity, IEnumerable<TResult>>> select) => Select = select;
}

The ProjectTo() overloads allow the implicit conversion of Select<TEntity, TResult> to be used. When a projection is defined, there is no need to separately define a single select query and a list select query.

Usage Of Specification

Once implemented, select objects can be defined as follows. These result/ select objects can be models or DTOs.

public class AccountInfo : ISelector
{
    public required string Email { get; set; }
    public required string FullName { get; set; }
}

ISelector interface is not mandatory but allows to identify Result objects.

Once there is a result object for a entity the new Specification can be implemented.

public class AccountInfoById : Specification<Account, AccountInfo>
{
    public AccountInfoById(AccountId accountId) : base(a => a.Id.Equals(accountId))
    {
        ProjectTo(a => new()
        {
            Email = a.Email,
            FullName = $"{a.User!.FirstName} {a.User.LastName}"
        });
    }
}

This will set the Select struct as Select<Account, AccountInfo> and set the _selectSingle as what is passed in ProjectTo().

Evaluating the Specification

To evaluate this, an overload for Specification evaluator is required.

Current Specification Evaluator.

public static IQueryable<TEntity> AddSpecification<TEntity>(
        this IQueryable<TEntity> query,
        Specification<TEntity> specification
    )
    where TEntity : IAggregateRoot
{
    query = specification.Includes.Aggregate(query, (current, include) => include(current));
    if (specification.Criteria is { } criteria)
    {
        query = query.Where(criteria);
    }
    if (specification.OrderBy is { } orderBy)
    {
        query = query.OrderBy(orderBy);
    }
    else if (specification.OrderByDescending is { } orderByDescending)
    {
        query = query.OrderByDescending(orderByDescending);
    }
    if (specification.Pagination is { } pagination)
    {
        query = query.Skip(pagination.Skip).Take(pagination.Take);
    }
    return query;
}

Overloaded Specification Evaluator.

public static IQueryable<TResult> AddSpecification<TEntity, TResult>(
        this IQueryable<TEntity> query,
        Specification<TEntity, TResult> specification
    )
    where TEntity : IAggregateRoot where TResult : ISelector
{
    query = specification.Includes.Aggregate(query, (current, include) => include(current));
    if (specification.Criteria is { } criteria)
    {
        query = query.Where(criteria);
    }
    if (specification.OrderBy is { } orderBy)
    {
        query = query.OrderBy(orderBy);
    }
    else if (specification.OrderByDescending is { } orderByDescending)
    {
        query = query.OrderByDescending(orderByDescending);
    }
    if (specification.Pagination is { } pagination)
    {
        query = query.Skip(pagination.Skip).Take(pagination.Take);
    }
    return specification.Select.Handle(query);
}

Same as the new Specification, this evaluator overload takes an additional type parameter as the result or return type. By invoking the Handle() method of the Select object, the evaluator seamlessly incorporates projections into the query execution.

Expanding Generic Repository For The New Specification

Similar to both Specification and Specification Evaluator, introduce 2nd type parameter as an overload for Get methods in the generic repository.

public interface IRepository<TEntity, in TEntityId> where TEntity : AggregateRoot<TEntityId> where TEntityId : EntityId
{
    .
    .
    Task<TEntity?> GetOneAsync<TSpecification>(
            TSpecification specification,
            CancellationToken cancellationToken = default
        ) where TSpecification : Specification<TEntity>;

    Task<List<TEntity>> GetManyAsync<TSpecification>(
            TSpecification specification,
            CancellationToken cancellationToken = default
        ) where TSpecification : Specification<TEntity>;
    .
    .
}

Add overloads by passing 2nd parameter as the Result type and make sure the return type is same as Result type.

    Task<TResult?> GetOneAsync<TSpecification, TResult>(
            TSpecification specification,
            CancellationToken cancellationToken = default
        ) where TSpecification : Specification<TEntity, TResult> where TResult : ISelector;

    Task<List<TResult>> GetManyAsync<TSpecification, TResult>(
            TSpecification specification,
            CancellationToken cancellationToken = default
        ) where TSpecification : Specification<TEntity, TResult> where TResult : ISelector;

Implementation

internal class Repository<TEntity, TEntityId> : IRepository<TEntity, TEntityId> where TEntity : AggregateRoot<TEntityId>
    where TEntityId : EntityId
{
    private readonly ApplicationDbContext _context;
    private DbSet<TEntity> DbSet => _context.Set<TEntity>();
    .
    .
    public async Task<TResult?> GetOneAsync<TSpecification, TResult>(
            TSpecification specification,
            CancellationToken cancellationToken = default
        ) where TSpecification : Specification<TEntity, TResult>
        where TResult : ISelector
        => await DbSet.AsNoTracking().AddSpecification(specification).FirstOrDefaultAsync(cancellationToken);


    public async Task<List<TResult>> GetManyAsync<TSpecification, TResult>(
            TSpecification specification,
            CancellationToken cancellationToken = default
        ) where TSpecification : Specification<TEntity, TResult>
        where TResult : ISelector
        => await DbSet.AsNoTracking().AddSpecification(specification).ToListAsync(cancellationToken);
    .
    .
}

Just like on other methods call FirstOrDefault() when the Result is single and ToList() when the Result is a Collection. Correct AddSpecification() overload will be called according to the TSpecification parameter.

Example

var accountRepository = host.Services.GetRequiredService<IRepository<Account, AccountId>>();

var accountInfo = await accountRepository.GetOneAsync<AccountInfoById, AccountInfo>(new(ids.accountId));

Here, an instance of the account repository. The GetOneAsync method is then used to fetch account information using the AccountInfoById specification. This specification is tailored specifically for retrieving account information by ID. The result is an AccountInfo object containing only the email and full name of the account.

This query triggers SQL execution by Entity Framework, resulting in the retrieval of relevant account data from the database:

SELECT TOP(1) [a].[Email], [u].[FirstName], [u].[LastName]
FROM [Accounts] AS [a]
LEFT JOIN [Users] AS [u] ON [a].[Id] = [u].[AccountId]
WHERE [a].[Id] = @__accountId_0

The SQL query retrieves the email, first name, and last name of the account from the database, satisfying the requirements specified in the AccountInfoById specification.

Example Whe Returning Collection Of Result

In scenarios where a collection of results needs to be retrieved, such as fetching multiple blog posts with detailed information, the new Specification pattern can be used to achieve this.

Define selectors/ result objects

public class BlogPostDetails : ISelector
{
    public required string Caption { get; set; }
    public required string Content { get; set; }
    public IEnumerable<MediaItemUrl> MediaItems { get; set; } = [];
}

public class MediaItemUrl : ISelector
{
    public required string Url { get; set; }
}

Define Specification

public class BlogPostDetailsListById : Specification<Blog, BlogPostDetails>
{
    public BlogPostDetailsListById(BlogId blogId) : base(b => b.Id.Equals(blogId))
    {
        ProjectTo(b => b.Posts.AsQueryable()
            .Select(p => new BlogPostDetails
            {
                Caption = p.Caption,
                Content = p.Content,
                MediaItems = p.MediaItems.AsQueryable().Select(m => new MediaItemUrl { Url = m.Url })
            })
            .ToList());
    }
}

When dealing with collections like b.Posts, p.MediaItems, it's crucial to call AsQueryable() to ensure that Entity Framework constructs the SQL query efficiently. Without it, Entity Framework might fetch all columns from the related table (in this case, BlogPosts or PostMediaItems) before performing the projection, leading to performance issues, especially with large datasets.

In this specification, each blog post is projected into a BlogPostDetails object, including its caption, content, and associated media items.

var blogRepository = host.Services.GetRequiredService<IRepository<Blog, BlogId>>();

var blogPostDetails = await blogRepository.GetManyAsync<BlogPostDetailsListById, BlogPostDetails>(new(ids.blogId));

SQL query that Entity Framework executes against the database as follows:

SELECT [p].[Caption], [p].[Content], [b].[Id], [p].[Id], [p0].[Url], [p0].[Id]
FROM [Blogs] AS [b]
INNER JOIN [Posts] AS [p] ON [b].[Id] = [p].[BlogId]
LEFT JOIN [PostMediaItems] AS [p0] ON [p].[Id] = [p0].[PostId]
WHERE [b].[Id] = @__blogId_0
ORDER BY [b].[Id], [p].[Id]

With this expanded capability in Specification pattern, no it is to specify select queries to retrieve precisely the data needed, whether it is a single entity with specific properties or a collection of entities with specific properties.

Example Project.

Simple demo of projection: https://github.com/haritha99ch/ExtendedSpecificationPattern-Example.
Advanced use-case: https://github.com/haritha99ch/CrimeWatch.

Your feedback and suggestions for further refinement are greatly appreciated.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment