I’ll argue the repository pattern should be applied asymmetrically in some cases. Recently, I’ve seen many discussions and arguments on social media about ignoring the repository pattern to have the ORM’s capabilities especially the Entity Framework Core, directly on use case layer of the application. At first, we might wonder why it is being suggested while that layer would be coupled to the EFCore and also how would be the testing from now. But by investigating the details, its benefits would be more obvious.

Repository Pattern

To inform you about the concept of it, first I would like to point out to the loose coupling design idea in software applications. When a software is divided into layers, there would be some parts which contain the handlers and use cases of the application. These parts need to access some external services like databases. The loose coupling demonstrates that there must be an abstraction for implementation of the data access service which communicates to database by ORM that is mapped to domain models and to be used by the use case handler. At this point, the repository pattern enters the field. It hides the details of how the data is prepared from the use case handler. Now the unit of work pattern must be pointed here while the repository does the changes on memory and unit of work would be the abstraction of sending changes to database in a transaction to ensure the consistency of the state of data.

Scale as the Deciding Factor

The usage of repository pattern really depends on the scale of the app or the bounded context of it. When there are only some simple CRUDs for entities and maybe no complex business checking, the pattern will cause an overhead on the development. But while the application has some complex business domains and strategies, it would be appropriate to be included.

Asymmetric Repository Pattern

Assymetric-Repository-Pattern

While the business has some complexities and the EFCore is the ORM of the project, to use the features like Include, ThenInclude and IQueryable, it would be suggested to use it directly in the related use case layer’s read side. The EFCore already has the abstraction over raw SQL access. The direct usage of it will remove the extra abstraction over the EFCore’s abstraction. So, the features are already available in the use case layer and it is helpful on complex projections and queries.

For the aggregates write side, it may be suitable to use the repository pattern because of its business controls and preventing duplication of code on handlers for the related aggregate. The simple entities with no complex business domain can use the direct DB Context as read side. As an example, the bellow code can be the abstraction contract of the related DB Context:

public interface IApplicationDBContext
{
    public DbSet<Order> Orders { get; }
    public DbSet<Customer> Customers { get;  }
    public DbSet<Product> Products { get;  }
    Task<int> SaveChangesAsync(CancellationToken cancellationToken);
}

As it seems the DBSets do not have setters. Their concrete implementation of DB Context would assign the getter by Set generic function:

public DbSet<Order> Orders => Set<Order>();

The below code snippets are the examples to have a view of how may be the result code for both approaches.

By using generic repository:

public class GetProductsQueryHandler
    (IRepository<Product> productRepository) : IRequestHandler<GetProductsQuery, IReadOnlyList<ProductDTO>>
{
    public async Task<IReadOnlyList<ProductDTO>> Handle(GetProductsQuery request, CancellationToken cancellationToken)
    => await productRepository.SelectAsync(selectorExpression: (product) => new ProductDTO(product.ID, product.Name));
}

public interface IRepository<T> where T : BaseEntity
{
    public Task<IReadOnlyList<TResult>> SelectAsync<TResult>(
        Expression<Func<T, TResult>> selector ,
        Expression<Func<T, bool>>? predicates=null, 
        CancellationToken cancellationToken = default);
}

By using IApplicationDBContext:

public class GetProductsQueryHandler
    (IApplicationDBContext appDBContext) : IRequestHandler<GetProductsQuery, IReadOnlyList<ProductDTO>>
{
    public async Task<IReadOnlyList<ProductDTO>> Handle(GetProductsQuery request, CancellationToken cancellationToken)
    => await appDBContext.Products.AsNoTracking()
        .Select(item => new ProductDTO(item.ID,item.Name)).ToListAsync();
}
 

It is obvious that by this approach we coupled the use case layer to the EFCore and also its Iqueryable is leaked into it but this would be an accepted trade-off by the developer and architect of the app.

Testing the Read Side

The approach changes the way of testing the read side of handlers. The write side can be mocked and unit tested because of the repositories but the read side would be better to be challenged via integration testing against a real database instance (e.g. Testcontainers).

Where I’ve landed

To wrap things up, I'd say the scale and complexity of the app should really drive the choice of pattern. In line with the KISS principle, it's best to keep the architecture as simple as each use case actually demands — reach for the repository where the aggregate's business rules justify it, and let EF Core do the rest.

Faraz Moshtael
Faraz Moshtael

Founder of CoderSight - Senior .NET Developer

Comments

Sign in to leave a comment.

Sign In Create Account

No comments yet. Be the first to share your thoughts!

An unhandled error has occurred. Reload 🗙

Rejoining the server...

Rejoin failed... trying again in seconds.

Failed to rejoin.
Please retry or reload the page.

The session has been paused by the server.

Failed to resume the session.
Please retry or reload the page.