Search code examples
c#entity-framework-corecontrollerautomapperardalis-specification

EF Core: Implement a single endpoint for all Subtypes


I'm having an issue where I try to make one endpoint for all classes that derive the same class.

One of my Core Entities is called Cell and has many deriving types such as ImageCell, VideoCell and so on.

The project is implemented using Ardalis.Specification and Ardalis.Specification.EntityFrameworkCore.

For reference here is the base class Cell and two deriving classes. public abstract class Cell : IAggregateRoot

namespace Core.Entities.Aggregates
{
    public abstract class Cell : IAggregateRoot
    {
        public int CellId { get; set; }
        public string CellType { get; set; }
        public int RowIndex { get; set; }
        public int ColIndex { get; set; }
        public int RowSpan { get; set; }
        public int ColSpan { get; set; }
        public int PageId { get; set; }
        public Page Page { get; set; }
    }

}

namespace Core.Entities.Cells
{
    public class ImageCell : Cell
    {
        public string Url { get; set; }
    }

}

namespace Core.Entities.Cells
{
    public class TextCell : Cell
    {
        public string Text { get; set; }
    }
}

All classes have a corresponding DTO.

namespace API.DTOs
{
    public class CellDTO : DTO
    {
        public int CellId { get; set; }
        public string CellType { get; set; }
        public int RowIndex { get; set; }
        public int ColIndex { get; set; }
        public int RowSpan { get; set; }
        public int ColSpan { get; set; }
        public int PageId { get; set; }
    }
}

namespace API.DTOs.Cells
{
    public class ImageCellDTO : CellDTO
    {
        public string ImageUrl { get; set; }
    }

}

namespace API.DTOs.Cells
{
    public class TextCellDTO : CellDTO
    {
        public string Text { get; set; }
    }
}

The MappingProfile is set up according to the documentation:

namespace API
{
    public class MappingProfile : Profile
    {
        public MappingProfile()
        {
            // Entity -> DTO
            ...
            // Cells
            // https://docs.automapper.org/en/stable/Mapping-inheritance.html
            CreateMap<Cell, CellDTO>()
                .IncludeAllDerived();
            CreateMap<ImageCell, ImageCellDTO>();
            CreateMap<AudioTextCell, AudioTextCellDTO>();
            CreateMap<AudioCell, AudioCellDTO>();
            CreateMap<GameCell, GameCellDTO>();
            CreateMap<TextCell, TextCellDTO>();
            CreateMap<VideoCell, VideoCellDTO>();
            ...

            // DTO -> Enitity
            ...
            // Cells
            CreateMap<CellDTO, Cell>()
                .IncludeAllDerived();
            CreateMap<AudioTextCellDTO, AudioTextCell>();
            CreateMap<AudioCellDTO, AudioCell>();
            CreateMap<GameCellDTO, GameCell>();
            CreateMap<TextCellDTO, TextCell>();
            CreateMap<VideoCellDTO, VideoCell>();
            CreateMap<ImageCellDTO, ImageCell>();
            ...
        }
    }
}

The Repository is set up like this:

using Ardalis.Specification;
namespace Core.Interfaces
{
    public interface IRepository<T> : IRepositoryBase<T> where T : class, IAggregateRoot
    {
    }
}

using Ardalis.Specification;
namespace Core.Interfaces
{
    public interface IReadRepository<T> : IReadRepositoryBase<T> where T : class, IAggregateRoot
    {
    }
}

namespace Infrastructure.Data
{
    public class EfRepository<T> : RepositoryBase<T>, IReadRepository<T>, IRepository<T> where T : class, IAggregateRoot
    {
        public EfRepository(BookDesinerContext dbContext) : base(dbContext)
        {

        }
    }
}

Service like this:

namespace Core.Interfaces
{
    public interface IService<T> where T : class, IAggregateRoot
    {
        Task<bool> ExistsByIdAsync(int id);
        Task<T> GetByIdAsync(int id);
        Task<T> GetByIdAsyncWithSpec(Specification<T> spec);
        Task<IEnumerable<T>> ListAsync();
        Task<IEnumerable<T>> ListAsyncWithSpec(Specification<T> spec);
        Task DeleteByIdAsync(int id);
        Task DeleteRangeAsync(IEnumerable<T> range);
        Task<T> AddAsync(T t);
        Task UpdateAsyc(T t);
    }
}

Now I created a default implementation:

using Ardalis.Specification;
using Core.Interfaces;

namespace Core.Services
{
    public class GenericService<T> : IService<T> where T : class, IAggregateRoot
    {
        private readonly IRepository<T> _repository;
        private readonly IAppLogger<GenericService<T>> _logger;


        public GenericService(IRepository<T> repository, IAppLogger<GenericService<T>> logger)
        {
            _repository = repository;
            _logger = logger;
        }

        public async Task<bool> ExistsByIdAsync(int id)
        {
            return await _repository.GetByIdAsync(id) != null;
        }

        public async Task<T> GetByIdAsync(int id)
        {
            var t = await _repository.GetByIdAsync(id);
            if (t == null)
            {
                _logger.Error($"Element with id: {id} can not be found!");
                throw new ArgumentException($"Element with id: {id} can not be found!");
            }
            return t;
        }

        public async Task<T> GetByIdAsyncWithSpec(Specification<T> spec)
        {
            if (!(spec is ISingleResultSpecification))
            {
                throw new ArgumentException("Specification does not implement marker interface.");
            }
            ISingleResultSpecification<T> specification = (ISingleResultSpecification<T>)spec;
            var t = await _repository.GetBySpecAsync(specification);
            if (t == null)
            {
                _logger.Error($"Element can not be found!");
                throw new ArgumentException($"Element can not be found!");
            }
            return t;
        }

        public async Task<IEnumerable<T>> ListAsync()
        {
            return await _repository.ListAsync();
        }

        public async Task<IEnumerable<T>> ListAsyncWithSpec(Specification<T> spec)
        {
            return await _repository.ListAsync(spec);
        }

        public async Task DeleteByIdAsync(int id)
        {
            var t = await _repository.GetByIdAsync(id);

            if (t == null)
            {
                _logger.Error($"Element with id: {id} can not be found!");
                throw new ArgumentException($"Element with id: {id} can not be found!");
            }

            await _repository.DeleteAsync(t);
        }

        public async Task DeleteRangeAsync(IEnumerable<T> range)
        {
            await _repository.DeleteRangeAsync(range);
        }

        public async Task<T> AddAsync(T t)
        {
            return await _repository.AddAsync(t);
        }

        public async Task UpdateAsyc(T t)
        {
            await _repository.UpdateAsync(t);
        }
    }
}

I registered a Service for every single Subtype:

builder.Services.AddScoped<IService<Cell>, GenericService<Cell>>();
builder.Services.AddScoped<IService<ImageCell>, GenericService<ImageCell>>();
builder.Services.AddScoped<IService<TextCell>, GenericService<TextCell>>();
builder.Services.AddScoped<IService<AudioCell>, GenericService<AudioCell>>();
builder.Services.AddScoped<IService<AudioTextCell>, GenericService<AudioTextCell>>();
builder.Services.AddScoped<IService<VideoCell>, GenericService<VideoCell>>();
builder.Services.AddScoped<IService<GameCell>, GenericService<GameCell>>();

And for the final part the controller:

namespace API.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class CellsController : BaseController<Cell, CellDTO>
    {
        private readonly IService<ImageCell> _imageCellService;
        private readonly IService<TextCell> _textCellService;
        private readonly IService<AudioCell> _audioCellService;
        private readonly IService<AudioTextCell> _audioTextCellService;
        private readonly IService<VideoCell> _videoCellService;
        private readonly IService<GameCell> _gameCellService;

        public CellsController(
            IService<Cell> service,
            IService<ImageCell> imageCellService,
            IService<TextCell> textCellService,
            IService<AudioCell> audioCellService,
            IService<AudioTextCell> audioTextCellService,
            IService<VideoCell> videoCellService,
            IService<GameCell> gameCellService,
            IMapper mapper) : base(service, mapper)
        {
            _imageCellService = imageCellService;
            _textCellService = textCellService;
            _audioCellService = audioCellService;
            _audioTextCellService = audioTextCellService;
            _videoCellService = videoCellService;
            _gameCellService = gameCellService;
        }

        [HttpGet]
        public override async Task<IActionResult> Get()
        {
            var result = new List<Object>();

            // Add ImageCells
            ICollection<ImageCell> imageCells = (ICollection<ImageCell>)await _imageCellService.ListAsync();
            result.AddRange(_mapper.Map<ICollection<ImageCell>, ICollection<CellDTO>>(imageCells));

            // Add TextCells
            ICollection<TextCell> textCells = (ICollection<TextCell>)await _textCellService.ListAsync();
            result.AddRange(_mapper.Map<ICollection<TextCell>, ICollection<CellDTO>>(textCells));

            ...

            return Ok(result);
        }

        [HttpGet("Page/{pageId}")]
        public async Task<IActionResult> GetByPageId(int pageId)
        {
            var result = new List<Object>();

            // Add ImageCells
            ICollection<ImageCell> imageCells = (ICollection<ImageCell>)await _imageCellService.ListAsync();
            result.AddRange(_mapper.Map<ICollection<ImageCell>, ICollection<ImageCellDTO>>(imageCells.Where(c => c.PageId == pageId).ToList()));

            // Add TextCells
            ICollection<TextCell> textCells = (ICollection<TextCell>)await _textCellService.ListAsync();
            result.AddRange(_mapper.Map<ICollection<TextCell>, ICollection<TextCellDTO>>(textCells.Where(c => c.PageId == pageId).ToList()));

           ...

            return Ok(result);
        }

        [HttpGet("{id}")]
        public override async Task<IActionResult> Get(int id)
        {
            if (await _imageCellService.ExistsByIdAsync(id))
            {
                var result = await _imageCellService.GetByIdAsync(id);

                return Ok(_mapper.Map<ImageCell, ImageCellDTO>(result));
            }

            if (await _textCellService.ExistsByIdAsync(id))
            {
                var result = await _textCellService.GetByIdAsync(id);
                return Ok(_mapper.Map<TextCell, TextCellDTO>(result));
            }

            ...

            return NotFound();
        }

        ...
    }
}

This is a highly inefficient implementation to my understanding.

Problems:

  • I can call /Cells to get all Cells the way it was intended with the List<Object>. List<CellDTO> always led to a downcast, which was unintended. The same problem occures in a DTO that is not shown, that has a List<CellDTO> as a property. But I would need the concrete subtypes in this list.

My goals:

  • Remove redundant code in the controller
  • Only register one CellSerivce
  • Correct mapping Entity <=> DTO

Things I have considered, but I could not find information to back my thesis:

  • Writing a CellSpecification that includes all subtypes
  • Creating a DTO that covers all fields from the subtypes

Solution

  • Try the following:

    var cells = (ICollection<Cell>)await _cellService.ListAsync();
    result.AddRange(_mapper.Map<ICollection<Cell>, ICollection<CellDTO>>(cells));
    

    Where _cellService is IService<Cell>