entity-frameworkasp.net-coreasp.net-core-webapidomain-driven-designrelationship

What is the right way to get data from related tables using an entity and business models separately in ASP.NET Core Web API?


I have a BookEntity and my domain Book model:

public class BookEntity
{
     [Key]
     public Guid BookId { get; set; }
     public string Title { get; set; } = string.Empty;
     public string Description { get; set; } = string.Empty;
     public decimal Price { get; set; }

     public BookDetailEntity BookDetail { get; set; } = null!;

     [ForeignKey("Genre")]
     public Guid GenreId { get; set; }
     public GenreEntity Genre { get; set; } = null!;

     public ICollection<BookAuthorMap> BookAuthor = new List<BookAuthorMap>();
}
namespace BookStore.Core.Models
{
    public class Book
    {
        public const int MAX_TITLE_LENGTH = 250;

        private Book(Guid id, string title, string description, decimal price)
        {
            Id = id;
            Title = title;
            Description = description;
            Price = price;
        }

        public Guid Id { get; }
        public string Title { get; } = string.Empty;
        public string Description { get; } = string.Empty;
        public decimal Price { get; }

        public static (Book Book, string Error) Create (
            Guid id,  string title, 
            string description, decimal price)
        {
            var error = string.Empty;

            if (string.IsNullOrEmpty(title) || title.Length > MAX_TITLE_LENGTH)
            {
                error = "Title cannot be empty or longer than 250";
            }

            if (price < 0) 
                error = "Price cannot be negative";

            var book = new Book (id, title, description, price);

            return (book, error);
        }
    }
}

And I have other entities related to each other like this one:

using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace BookStore.DataAccess.Entities
{
    public class BookDetailEntity
    {
        [Key]
        public Guid BookDetailId { get; set; }
        public string ISBN { get; set; } = string.Empty;
        public int Pages { get; set; }
        public DateTime PublicationDate { get; set; }
        public string Language { get; set; } = string.Empty;

        [ForeignKey("Book")]
        public Guid BookId { get; set; }
        public BookEntity Book { get; set; } = null!;
    }
}

I'm getting my entity Book from a DbSet and then parsing to domain model and then to DTO in the frontend:

public async Task<List<Book>> GetAllBooks()
{
    var bookEntities = await _context.Books
        .AsNoTracking()
        .ToListAsync();

    var books = bookEntities
        .Select(b => Book.Create(b.BookId, b.Title, b.Description, b.Price).Book)
        .ToList();
        
    return books;
}
public class BooksController : BaseController
{
    private readonly IBooksService _booksService;

    public BooksController(IBooksService booksService)
    {
        _booksService = booksService;
    }

    [HttpGet]
    public async Task<ActionResult<List<BooksResponse>>> GetBooks()
    {
        var books = await _booksService.GetAllBooks();

        var response = books.Select(b => new BooksResponse(b.Id, b.Title, b.Description, b.Price));

        return Ok(response);
    }
}

What should be my approach to getting all related data?

Expanding the business model or using repositories methods in one method or something else?

Project is using Clean Architecture (repository - services - API)


Solution

  • Entities can/should serve as your domain, there are very few cases for a middleman. The DTOs/ViewModels can be projected, and ideally you want EF or a mapping library that works with EF's IQueryable to manage the projections so that EF can build an efficient query to pull back just the data needed for the projection.

    You ideally want to avoid code like:

    var bookEntities = await _context.Books
        .AsNoTracking()
        .ToListAsync();
    
    var books = bookEntities
        .Select(b => Book.Create(b.BookId, b.Title, b.Description, b.Price).Book)
        .ToList();
    

    Your "BookService" is effectively acting as a Repository pattern wrapper over EF. EF already provides this via the DbSet. The problem with this approach is you are needing to materialize your entire table into memory with that first statement. And as you have seen, it raises doubts about how to handle situtations where you might want additional relations. Similarly what if one of your controller methods optionally wants more information, or just a count? What if you want to paginate and just display the first (or second) 50 records? Things get complex and inefficient pretty fast.

    Repositories over EF really only ever need to be considered for a few reasons:

    If you do not have these requirements then you save yourself a lot of headaches, performance limitations, and/or complexity by using the DbContext and DbSets as your Unit of Work and Repositories respectively.

    Instead, treating the "Book" Entity as the Domain object and a BookDTO, your controller code would look more like:

    [HttpGet]
    public async Task<ActionResult<ICollection<BooksResponse>>> GetBooks()
    {
        var books = await _context.Books
            .Select(b => new BooksResponse
            {
                Id = b.Id,
                Title = b.Title,
                // ...
                Pages = b.BookDetail.Pages
            }).ToListAsync();
    
        return Ok(books);
    }
    

    Alternatively you can declare child DTOs for related data you want back.

    To tidy that up, you can use a mapper like Automapper, Mapperly, Mapster, etc. that support EF's IQueryable to simplify that expression. Many work with convention for mapping Entity => DTO or you can explicitly configure them for anything specific.

    // Automapper:
    
        var books = await _context.Books
            .ProjectTo<BooksResponse>(config)
            .ToListAsync();
    
    

    where "config" is a mapping configuration set up to handle Book -> DTO.

    If you want something easier to unit test, or that has centralized common rules then you can inject a simple Repository that exposes IQueryable:

        var books = await _repository.GetAll()
            .ProjectTo<BooksResponse>(config)
            .ToListAsync();
    
        return Ok(books);
    

    ... where the repository is a simple thin wrapper:

    
    public class BookRepository : IBookRepository
    {
        IQueryable<Book> IBookRepository.GetAll()
        {
            IQueryable<Book> query = _context.Books;
            return query;
        }   
    }
    

    As an example with common business logic such as a soft-delete:

        IQueryable<Book> IBookRepository.GetAll(bool includeInactive = false)
        {
            IQueryable<Book> query = _context.Books;
    
            if (!includeInactive)
                query = query.Where(x => x.IsActive);
    
            return query;
        }   
    

    Here we now have an abstraction over EF that can more easily be swapped out with a Mock for unit testing. It can also serve to ensure common rules or optimizations are enforced. By adopting IQueryable our consumers still have full control over how the data is consumed. For example how it is sorted, how filtering might be refined, pagination, projection, eager loading related data, etc.

    Keep it simple and it will go a long way, maintaining performance and fewer headaches. The rabbit hole of trying to abstract your code away from EF is far deeper, messier, and confusing than most expect, and 99.5% of the time completely unnecessary/unjustified.