Loupe

.NET Core CRUD générique avec MediatR et AutoMapper

Dans cet article nous verrons comment mettre en place un CRUD générique en .NET Core à l'aide de MediatR et d'AutoMapper.

L'utilisation d'AutoMapper est optionnelle ici mais permet de vous affranchir du mapping à la main de votre modèle de donnée vers vos DTOs et inversement. AutoMapper peut vous faire gagner beaucoup de temps si vous avez beaucoup de DTOs et évite les erreurs de mapping lorsqu'elles sont faites à la main (sans tests unitaires), mais c'est évidemment moins performant. Il y a donc un compromis à faire entre le temps de développement et les performances en fonction du type de projet sur lequel vous travaillez.

Si vous ne connaissez pas MediatR, c'est une implémentation simple et légère du patron de conception médiateur qui permet de réduire au maximum le couplage et donc les dépendances entre vos différentes classes et modules (ce qui aide à éviter le code spaghetti).

La configuration

Vous aurez besoin des packages NuGet suivants :nuget-crud-cqrs.png

Dans la fonction ConfigureServices de votre fichier Startup.cs ajoutez ces deux lignes de code pour l'injection des dépendences :

services.AddMediatR(typeof(CreateCommandHandler));
services.AddAutoMapper(config =>
            {
                config.CreateMissingTypeMaps = true;
                config.ValidateInlineMaps = false;
                config.AllowNullCollections = true;
            });

La classe CreateCommandHandler étant n'importe quelle classe du projet ou de la librairie dans laquelle vous avez implémenté vos Queries/Commands puisque mediatR va scanner l'assembly contenant cette classe.

Exemple de code

Nous allons prendre comme exemple une entité simple et son DTO :

public class Item : IBaseEntity
{
    public Guid Id { get; set; }
    public string Name { get; set; }
    public int Code { get; set; }
    public DateTimeOffset CreatedAt { get; set; }
}

public class ItemDto
{
    public Guid Id { get; set; }
    public string Name { get; set; }
    public int Code { get; set; }
}

Contrôleur avec méthodes de CRUD (et liste paginée)

public class ItemController : ControllerBase
{
    private readonly IMediator _mediator;
    private readonly IMapper _mapper;

    public ItemController(IMediator mediator, IMapper mapper)
    {
        _mediator = mediator;
        _mapper = mapper;
    }

    [HttpGet("{itemId}")]
    public async Task<ActionResult<ItemDto>> GetItemAsync(Guid itemId)
    {
        var item = await _mediator.Send(new GetItemRequest(itemId));
        return _mapper.Map<ItemDto>(item);
    }

    [HttpGet]
    public async Task<ActionResult<List<ItemDto>>> GetItemsAsync([FromQuery]int pageIndex = 0, [FromQuery]int pageSize = 10)
    {
        var items = await _mediator.Send(new GetItemsRequest(isDescending, pageIndex, pageSize));

        return _mapper.MapAll(items).To<ItemDto>();
    }

    [HttpDelete("{itemId}")]
    public async Task<IActionResult> DeleteItemAsync(Guid itemId)
    {
        var request = new DeleteItemCommand(itemId);
        await _mediator.Send(request);

        return NoContent();
    }

    [HttpPost]
    public async Task<ActionResult<ItemDto>> CreateItemAsync(CreateItemCommand commandDto)
    {
        var command = _mapper.Map<CreateItemCommand>(commandDto);
        var item = await _mediator.Send(command);
        var result = _mapper.Map<ItemDto>(item);

        return CreatedAtAction(nameof(GetItemAsync), new { itemId = item.Id }, result);
    }

    [HttpPut("{itemId}")]
    public async Task<ActionResult<ItemDto>> UpdateCredentialAsync(Guid itemId, UpdateItemCommand commandDto)
    {

        var command = new UpdateCredentialCommand(itemId);
        _mapper.Map(commandDto, request);

        var item = await _mediator.Send(command);
        return _mapper.Map<ItemDto>(item);
    }
}

Ici dans chacune de nos méthode de contrôleur nous déléguons la responsabilité de l'exécution des Query/Command à mediatR. Nous n'avons aucune logique métier dans le contrôleur.

Chaque entité doit avoir un identifiant unique, ici ce sera un GUID et notre interface d'entité de base depuis laquelle toutes nos entité hériterons sera celle-ci :

public interface IBaseEntity
{
    Guid Id { get; set; }
}

Les Query/Command génériques

Commande générique de création

public interface ICreateCommand<out TEntity> : IRequest<TEntity> where TEntity : class, new() { }

public abstract class CreateCommandHandler<TEntity, TCommand> : IRequestHandler<TCommand, TEntity>
    where TEntity : class, IBaseEntity, new()
    where TCommand : class, ICreateCommand<TEntity>, new()
{
    private readonly DatabaseContext _context;
    private readonly IMapper _mapper;

    protected CreateCommandHandler(DatabaseContext context, IMapper mapper)
    {
        _context = context;
        _mapper = mapper;
    }

    public async Task<TEntity> Handle(TCommand request, CancellationToken cancellationToken)
    {
        var entity = _mapper.Map<TCommand, TEntity>(request);

        _context.Set<TEntity>().Add(entity);

        await _context.SaveChangesAsync(cancellationToken);

        return entity;
    }
}

Commande générique de mise à jour

public interface IUpdateCommand<out TEntity> : IRequest<TEntity> where TEntity : class, new()
{
    Guid Id { get; }
}

public abstract class UpdateCommandHandler<TEntity, TCommand> : IRequestHandler<TCommand, TEntity>
    where TEntity : class, IBaseEntity, new()
    where TCommand : class, IUpdateCommand<TEntity>, new()
{
    private readonly DatabaseContext _context;
    private readonly IMapper _mapper;

    protected UpdateCommandHandler(DatabaseContext context, IMapper mapper)
    {
        _context = context;
        _mapper = mapper;
    }

    public async Task<TEntity> Handle(TCommand request, CancellationToken cancellationToken)
    {
        var entity = _mapper.Map<TCommand, TEntity>(request);

        _context.Set<TEntity>().Update(entity);

        await _context.SaveChangesAsync(cancellationToken);

        return entity;
    }
}

Commande générique de suppression

public interface IDeleteCommand<TEntity> : IRequest where TEntity : class, new()
{
    Guid Id { get; }
}

public abstract class DeleteCommandHandler<TEntity, TCommand> : IRequestHandler<TCommand>
    where TEntity : class, IBaseEntity, new()
    where TCommand : class, IDeleteCommand<TEntity>, new()
{
    private readonly DatabaseContext _context;

    protected DeleteCommandHandler(DatabaseContext context)
    {
        _context = context;
    }

    public async Task<Unit> Handle(TCommand request, CancellationToken cancellationToken)
    {
        var entity = await _context.Set<TEntity>().FirstOrDefaultAsync(e => e.Id == request.Id, cancellationToken);

        if (entity == null)
        {
            throw new EntityNotFoundException<TEntity>($"Id : {request.Id}");
        }

        _context.Set<TEntity>().Remove(entity);

        await _context.SaveChangesAsync(cancellationToken);

        return Unit.Value;
    }
}

Query générique de lecture simple

public interface IGetQuery<TEntity> : IRequest<TEntity> where TEntity : class, new()
{
    IQueryable<TEntity> Query { get; set; }
    Guid Id { get; }
}

public abstract class GetQueryHandler<TEntity, TQuery> : IRequestHandler<TQuery, TEntity>
    where TEntity : class, IBaseEntity, new()
    where TQuery : class, IGetQuery<TEntity>, new()
{
    protected readonly DatabaseContext _context;

    protected GetQueryHandler(DatabaseContext context)
    {
        _context = context;
    }

    public virtual async Task<TEntity> Handle(TQuery request, CancellationToken cancellationToken)
    {
        var query = request.Query;

        var entity = await query.SingleOrDefaultAsync(e => e.Id == request.Id, cancellationToken);
        if (entity == null)
        {
            throw new EntityNotFoundException<TEntity>($"Id : {request.Id}");
        }
        return entity;
    }
}

Query générique de lecture multiple

public interface IListQuery<TEntity> : IRequest<(int, IReadOnlyList<TEntity>)> where TEntity : class, new()
{
    int PageIndex { get; }

    int PageSize { get; }

    IQueryable<TEntity> Query { get; set; }
}

public abstract class ListQueryHandler<TEntity, TQuery> : IRequestHandler<TQuery, (int, IReadOnlyList<TEntity>)>
    where TEntity : class, IBaseEntity, new()
    where TQuery : class, IListQuery<TEntity>, new()
{
    protected readonly DatabaseContext _context;

    protected ListQueryHandler(DatabaseContext context)
    {
        _context = context;
    }

    public virtual async Task<(int ,IReadOnlyList<TEntity>)> Handle(TQuery request, CancellationToken cancellationToken)
    {
        var query = request.Query;

        query = query.Skip(request.PageIndex * request.PageSize);

        if (request.PageSize > 0)
        {
            query = query.Take(request.PageSize);
        }

        return await query.ToListAsync(cancellationToken);
    }
}

Exemple d'implémentation de la commande générique de création

public class CreateItemCommand : ICreateCommand<Item>
{
    public CreateItemCommand()
    {
    }

    public string Code { get; set; }

    public string Name { get; set; }
}

public class CreateItemHandler : CreateCommandHandler<Item, CreateItemCommand>
{
    public CreateItemHandler(DatabaseContext context, IMapper mapper) : base(context, mapper) { }
}

C'est aussi simple que cela, mediatR s'occupe de lancer la méthode Handle de la classe parente lorsque le pipeline est dépilé.

Exemple d'implémentation de la query de lecture multiple

public class GetItemsWithCodeRequest : IListQuery<Item>
{
    public GetItemsWithCodeRequest()
    {

    }

    public GetItemsWithCodeRequest(int pageIndex, int pageSize, int code)
    {
        PageIndex = pageIndex;
        PageSize = pageSize;
        Code = code;
    }

    public int Code { get; }
    public int PageIndex { get;  }
    public int PageSize { get; }
    public IQueryable<Item> Query { get; set; }
}

public class GetItemsWithCodeHandler : ListQueryHandler<Item, GetItemsWithCodeRequest>
{
    public GetItemsWithCodeHandler(DatabaseContext context) : base(context) {}

    public override async Task<IReadOnlyList<Item>> Handle(GetItemsWithCodeRequest request, CancellationToken cancellationToken)
    {
        request.Query = _context.Items.Include(item => item.ParentItem);

        request.Query = request.Query.Where(item => item.Code == request.Code);

        return await base.Handle(request, cancellationToken);
    }
}

Dans ce cas là nous implémentons une propriété IQueryable<TEntity> qui nous permet de surcharger la query qui va être utilisée dans la classe parente générique avec des actions plus spécifiques telles que des includes, des filtres ou autre...

Cette implémentation générique du CRUD à l'aide de MediatR et AutoMapper peut vous faire gagner beaucoup de temps lors du boot d'un projet qui propose quasiment toujours des méthodes de CRUD (et du listing paginé) avec du code qui se répète souvent notamment les appels à Entity Framework.

Ces billets pourraient aussi vous intéresser

Vous nous direz ?!

Commentaires

comments powered by Disqus