This repository implementation feels wrong to me since it forces me to follow CRUD.
Well, you chose a simplistic example where the entire business logic (i.e. your handles) effectively mirrors an available method in your repository.
Had you picked a different example, you would have had business logic that is more than just a CRUD-passthrough. Just inventing something for the sake of example:
public sealed class GetLivingUsersQueryHandler : IRequestHandler<GetLivingUsersQuery, IEnumerable<User>>
private readonly IUserRepository userRepository;
private readonly IObituaryService obituaryService;
// basic DI constructor omitted
public async Task<IEnumerable<User>> Handle(GetLivingUsersQuery getLivingUsersQuery, CancellationToken cancellationToken)
var users = await userRepository.GetUsersAsync();
var livingUsers = users.Where(user => !obituaryService.HasDied(user));
And there you have it: complex business logic that is not solely CRUD-driven.
Query handlers might want to query the database in different ways
Just to be clear, a "query" (as in "query handler") does not refer to a database query, it refers to a CQRS query. Those are two very different concepts.
Since the actual database query handling is represented by your repository, it is therefore your task to ensure that the repository allows the querying of data in the database in the way you need it to.
Whether that is one large
Get method with optional filtering, or specific methods such as
GetUsersOlderThan(int age), is completely contextual. Either can become overkill in certain scenarios. For simple column-equality filters, I tend to just make one method with optional filtering, but the choice here is yours.
Command handlers might want to run transactional logic
Again, the responsibility of the database is left up to your repositories. If you need transactions that span across repositories, then a unit of work is going to help you control that.
That being said, there is an argument to be made for not wrapping EF's
DbSet<> into a UOW/repository, because
DbSet<> already is a unit of work (
DbContext allows transactions) over a bunch of repositories (
However, this means that EF crosses the boundary of the data layer. Purists will argue that a third-party-library may never bleed into your domain. I, on the other hand, argue that this advice applies only to libraries and not frameworks, and it's not called Entity Library after all. But that is a very long and hotly debated discussion that I'm not going to rehash here.
and even query from multiple repositories
(assuming no UOW) So inject the repositories you need into your command handler. Nowhere is it said that a handler is only allowed to access one repository.
(assuming a UOW) Commonly, the UOW provides access to all its repositories. So by injecting the UOW into your handler, you have access to all repositories that are covered by the UOW.
so things might get messy or mixed up
I'm not sure where the mix up here is. I get the feeling that because you're conflating "database query" and "CQRS query", that you're not seeing that your handler and your repository are two different layers (handler = business, repository = datalayer), with each their own focus.
The datalayer (and thus repository) handles the basic database querying. You can make this interface as complex as you need it to be. It doesn't need to be barebones CRUD, but barebones CRUD is a good initial start, and you can develop extra methods along the way.
The business layer (and thus handler) performs your complex business logic, which might entail speaking to multiple services (be they repositories, external adapters, internal services, ...) in order to achieve the needed outcome.
Yes, some business logic may sometimes be a pure passthrough onto a repository method. If you are fetching data which comes from one database table and requires no post-processing, you end up with a very simple business method.
But when there is some actual business logic to be done, the handler can be adjusted to perform more than just this call onto the repository.
use MediatR to create reusable isolated data access handlers
It is important to note here that the mediator pattern (which Mediatr is a popular implementation of) is just a pattern, and it doesn't inherently tell you that it handles business logic.
You could, in principle, use a mediator pattern in the datalayer (instead of your repositories), and then have a regular service in your business logic.
It is fairly common to see the mediator pattern used to handle the business layer, and it's fairly common to see the repository pattern used to handle the data layer. But those are not set in stone. You can actually implement the mediator pattern on any layer where you want to decouple individual operations and not try to mash them into pre-categorized boxes.