The notion of having a generic repository is that you get to write less code for a bunch of similar functionalities for each entity. For example,

public interface IRepository<in TEntity> where TEntity : Entity
{
    Task Create(TEntity entity);
    Task<List<TEntity>> Read();
}
IRepository.cs

No matter how many types you have, if they extend the Entity base class then you have a common read and write functionality,

public class GenericRepository<TEntity> : 
    IRepository<TEntity>
    where TEntity : Entity
{
    private readonly ApplicationDbContext _dbContext;

    public GenericRepository(ApplicationDbContext dbContext)
    {
        _dbContext = dbContext;
    }
    public Task Create(TEntity entity)
    {
        _dbContext.Set<TEntity>().AddAsync(entity);
        return _dbContext.SaveChangesAsync();
    }

    public Task<List<TEntity>> Read()
    {
        return _dbContext.Set<TEntity>().ToListAsync();
    }
}
GenericRepository.cs

The entity we have in our hand is the WeatherForecast which as stated earlier entends the base Entity class,

public abstract class Entity
{
    public int Id { get; set; }
}

public class WeatherForecast : Entity
{
    public DateTime Date { get; set; }
    public int TemperatureC { get; set; }
    public string? Summary { get; set; }
}

In the realm of CQRS, we should consider seperating the read and write functionalities into their respective command and query featues. Using MediatR, you would want to create two features to tackle this,

-  Features                    
  |
   -  Weather                     // Feature for a specific domain entity
     |
      -  Create                   // Generalized command featue
     |  |
     |   - Command             
     |   - CommandHandler
     |   - IRepository            // Feature level repository contract
     | 
      -  Read                     // Generalized query featue
        |
         - Query
         - QueryHandler
         - IRepository

Here comes the time you should get your thinking cap on! What happens if you a are adding another domain entity? Let's just say the new entity is FooBar. You end up with a new enity specific feature folder with same old read and write features,

-  Features                    
  | 
   -  Weather                     // Feature for a specific domain entity
  |  |
  |   -  Create                   // Generalized command featue
  |  |  |
  |  |   - Command             
  |  |   - CommandHandler
  |  |   - IRepository            // Feature level repository contract
  |  | 
  |   -  Read                     // Generalized query featue
  |     |
  |      - Query
  |      - QueryHandler
  |      - IRepository
  |
   -  Foobar                      // Feature for a specific domain entity
     |
      -  Create                   // Generalized command featue
     |  |
     |   - Command             
     |   - CommandHandler
     |   - IRepository            // Feature level repository contract
     | 
      -  Read                     // Generalized query featue
        |
         - Query
         - QueryHandler
         - IRepository

It completely depends on you whether to go with the pain of creating individual features or make a single generic one to rule them all.

For the lack of a better name, let's call the generic feature Crud,

-  Features                    
  |
   -  Crud                        // Generic feature for all domain entity
     |
      -  Create                   // Generalized command featue
     |  |
     |   - Command             
     |   - CommandHandler
     |   - IRepository            // Feature level repository contract
     | 
      -  Read                     // Generalized query featue
        |
         - Query
         - QueryHandler
         - IRepository

The generic Query should look like this,

using GenericMediatRFeatures.Entities;
using MediatR;

namespace GenericMediatRFeatures.Features.Crud.Read;

public partial class Read
{
    public record Query<TEntity> : IRequest<List<TEntity>> where TEntity : Entity;
}
Query.cs

The generic QueryHandler,

using AutoMapper;
using GenericMediatRFeatures.Entities;
using GenericMediatRFeatures.ViewModels;
using MediatR;

namespace GenericMediatRFeatures.Features.Crud.Read;

public partial class Read
{
    public class QueryHandler<TEntity> : IRequestHandler<Query<TEntity>, List<TEntity>> 
        where TEntity : Entity
    {
        private readonly IRepository<TEntity> _repository;

        public QueryHandler(IRepository<TEntity> repository)
        {
            _repository = repository;
        }
        
        public async Task<List<TEntity>> Handle(Query<TEntity> request, CancellationToken cancellationToken)
        {
            return await _repository.Read();
        }
    }
}
QueryHandler.cs

Feature level generic repository contract.

using GenericMediatRFeatures.Entities;

namespace GenericMediatRFeatures.Features.Crud.Read;

public partial class Read
{
    public interface IRepository<TEntity> where TEntity : Entity
    {
        Task<List<TEntity>> Read();
    }
}
IRepository.cs

Generally you would register all the handlers with the DI container just by calling,

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddMediatR(Assembly.GetExecutingAssembly());
Program.cs

But in the case of our generic handlers this won't work as it needs to know the specific type for which you are writing the handler. To overcome this problem, register these hanglers explicity,

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddMediatR(Assembly.GetExecutingAssembly());

builder.Services.AddTransient(typeof(IRequestHandler<Read.Query<WeatherForecast>, List<WeatherForecast>>),
    typeof(Read.QueryHandler<WeatherForecast>));
Program.cs

To call this handler from a specific controller action, inject the IMediator interface in the constructor and call the feature as shown below,

private readonly IMediator _mediator;

public WeatherForecastController(IMediator mediator)
{
    _mediator = mediator;
}

[HttpGet(Name = "GetWeatherForecast")]
public Task<List<WeatherForecast>> Get() => _mediator.Send(new Read.Query<WeatherForecast>());
WeatherForecastController.cs

Bonus:

You might not want to expose an entity directly from a controller. In that scenario, a view-model/dto could come in handy. The mapping of the entity to a view-model could be done inside the handlers. You would want to modify your Query as follows,

public partial class Read
{
    public record Query<TModel> : IRequest<List<TModel>> where TModel : ViewModel { }
}
Query.cs

The handler should be modified as follows,

public partial class Read
{
    public class QueryHandler<TEntity, TModel> : IRequestHandler<Query<TModel>, List<TModel>> 
        where TEntity : Entity
        where TModel: ViewModel
    {
        private readonly IRepository<TEntity> _repository;
        private readonly IMapper _mapper;

        public QueryHandler(IRepository<TEntity> repository, IMapper mapper)
        {
            _repository = repository;
            _mapper = mapper;
        }
        
        public async Task<List<TModel>> Handle(Query<TModel> request, CancellationToken cancellationToken)
        {
            return _mapper.Map<List<TModel>>(await _repository.Read());
        }
    }
}
QueryHandler.cs
AutoMapper  for mapping an entity to a view-model/dto

The registration of the handler is also modified,

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddMediatR(Assembly.GetExecutingAssembly());

builder.Services.AddTransient(typeof(IRequestHandler<Read.Query<WeatherForecastModel>, List<WeatherForecastModel>>),
    typeof(Read.QueryHandler<WeatherForecast, WeatherForecastModel>));
Program.cs

The WeatherForecastModel view-model is nothing but a `POCO `exposing only the properties that are intended. To make the mapping work, Automapper needs a mapping configuration as follows,

public partial class Read
{
    public class MapperConfiguration : Profile
    {
        public MapperConfiguration()
        {
            CreateMap<WeatherForecast, WeatherForecastModel>(MemberList.Destination)
                .ForMember(d => d.TemperatureF, opts => opts.MapFrom(s => 32 + (int) (s.TemperatureC / 0.5556)));
        }
    }
}
MapperConfiguration.cs
Here, TemperatureF is a computed property which is only available in the view-model

One last thing you would want to do is to register Automapper services in the DI container,

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddAutoMapper(Assembly.GetExecutingAssembly());
Program.cs

Just like before, call the feature from a controller's action method,

[HttpGet(Name = "GetWeatherForecast")]
public Task<List<WeatherForecastModel>> Get() => _mediator.Send(new Read.Query<WeatherForecastModel>());
WeatherForecastController.cs

And here you have it; a generic feature to handle all crud related operations for every entiy. Browse the repository source code to find the implementation of the generic Write command which is pretty similar to the Read feature that I've demonstrated.

Repository:

GitHub - fiyazbinhasan/GenericMediatRFeatures
Contribute to fiyazbinhasan/GenericMediatRFeatures development by creating an account on GitHub.

Links:

AutoMapper
AutoMapper : A convention-based object-object mapper. 100% organic and gluten-free. Takes out all of the fuss of mapping one object to another.
Home · jbogard/MediatR Wiki
Simple, unambitious mediator implementation in .NET - Home · jbogard/MediatR Wiki