Entity Framework и DDD — загружайте необходимые связанные данные перед передачей сущности на бизнес-уровень

Допустим, у вас есть объект домена:

class ArgumentEntity
{
    public int Id { get; set; }
    public List<AnotherEntity> AnotherEntities { get; set; }
}

И у вас есть контроллер веб-API ASP.NET, чтобы справиться с этим:

[HttpPost("{id}")]
public IActionResult DoSomethingWithArgumentEntity(int id)
{
    ArgumentEntity entity = this.Repository.GetById(id);
    this.DomainService.DoDomething(entity);
    ...
}

Он получает идентификатор сущности, загружает сущность по идентификатору и выполняет на ней некоторую бизнес-логику с помощью службы домена.

Проблема. Проблема связана со связанными данными. ArgumentEntity имеет коллекцию AnotherEntities, которая будет загружена EF, только если вы явно попросите сделать это с помощью методов Include/Load. DomainService является частью бизнес-уровня и ничего не должен знать о сохраняемости, связанных данных и других концепциях EF.

Метод службы DoDomething ожидает получить экземпляр ArgumentEntity с загруженной коллекцией AnotherEntities. Вы бы сказали - это просто, просто включите необходимые данные в Repository.GetById и загрузите весь объект с соответствующей коллекцией.

Теперь давайте вернемся от упрощенного примера к реальности большого приложения:

  1. ArgumentEntity намного сложнее. Он содержит несколько связанных коллекций, и связанные объекты также имеют свои связанные данные.

  2. У вас есть несколько методов DomainService. Каждый метод требует загрузки различных комбинаций связанных данных.

Я мог представить возможные решения, но все они далеки от идеальных:

  1. Всегда загружайте весь объект ->, но это неэффективно и часто невозможно.

  2. Добавьте несколько методов репозитория: GetByIdOnlyHeader, GetByIdWithAnotherEntities, GetByIdFullData для загрузки определенных подмножеств данных в контроллер ->, но контроллер узнает, какие данные загружать и передавать каждому методу службы.

  3. Добавьте несколько методов репозитория: GetByIdOnlyHeader, GetByIdWithAnotherEntities, GetByIdFullData для загрузки определенных подмножеств данных в каждый метод службы -> это неэффективно, запрос sql для каждого вызова метода службы. Что, если вы вызовете 10 сервисных методов для одного действия контроллера?

  4. Каждый метод домена вызывает метод репозитория для загрузки дополнительных необходимых данных (например: EnsureAnotherEntitiesLoaded) -> это уродливо, потому что моя бизнес-логика узнает о концепции EF связанных данных.

Вопрос: как бы вы решили проблему загрузки необходимых связанных данных для объекта перед их передачей на бизнес-уровень?


person Philipp Bocharov    schedule 20.12.2017    source источник


Ответы (3)


В вашем примере я вижу метод DoSomethingWithArgumentEntity, который явно принадлежит прикладному уровню. Этот метод имеет вызов Repository, который принадлежит уровню доступа к данным. Я думаю, что эта ситуация не соответствует классической многоуровневой архитектуре — вы не должны вызывать DAL напрямую из прикладного уровня.

Таким образом, ваш код можно переписать по-другому:

[HttpPost("{id}")]
public IActionResult DoSomethingWithArgumentEntity(int id)
{
    this.DomainService.DoDomething(id);
    ...
}

В реализации DomainService вы можете прочитать из репо все, что ему нужно для этой конкретной операции. Это позволяет избежать проблем на прикладном уровне. В бизнес-уровне у вас будет больше свободы для реализации чтения: с помощью серверных методов репозитория читает полуполную сущность, или с помощью методов SureXXX, или что-то еще. Знания о том, что вам нужно прочитать для работы, будут помещены в код операции, и вам больше не нужны эти знания на уровне приложения.

Каждый раз, когда возникает подобная ситуация, это сильный сигнал о том, что ваша сущность не спроектирована должным образом. Как сказал Кржис, сущность не имеет связных частей. Другими словами, если вам часто нужны отдельные части объекта, вы должны разделить этот объект.

person poul_ko    schedule 20.12.2017
comment
Думаю, стоит уточнить: репозиторий здесь — абстракция, он типа IRepository и внедряется через DI. Интерфейс репозитория — это часть бизнес-логики, а реализация репозитория — часть DAL. Таким образом, DAL не вызывается из прикладного уровня. Проблема 1: передача идентификаторов вместо сущностей на бизнес-уровень часто считается плохой практикой. Проблема 2: что, если вам нужно вызвать DoSomething и DoSomethingElse в одном и том же контроллере? В этом случае оба метода будут загружать объект, поэтому у вас будет два запроса sql вместо одного. - person Philipp Bocharov; 20.12.2017
comment
Я понимаю вашу точку зрения, но логически интерфейс репозитория является абстракцией постоянства, поэтому он логически принадлежит DAL. Рассмотрим еще одну проблему: когда вы будете писать модульные тесты для кода приложения, вы будете заглушать не только доменные службы, но и репозитории. По поводу проблемы 1: возможно, у меня другой опыт. Проблема 2: это проблема инфраструктуры, а не проблема домена. Инфраструктура (DAL) может реализовать некоторое кэширование для таких случаев, когда многократная загрузка стала ощутимой проблемой. - person poul_ko; 20.12.2017

Хороший вопрос :)

Я бы сказал, что «связанные данные» сами по себе не являются строгой концепцией EF. Связанные данные — это допустимая концепция с NHibernate, с Dapper или даже если вы используете файлы для хранения.

Хотя с остальными пунктами в основном согласен. Итак, вот что я обычно делаю: у меня есть один метод репозитория, в вашем случае GetById, который имеет два параметра: идентификатор и params Expression<Func<T,object>>[]. А затем внутри репозитория я включаю. Таким образом, у вас нет никакой зависимости от EF в вашей бизнес-логике (выражения могут быть проанализированы вручную для другого типа среды хранения данных, если это необходимо), и каждый метод BLL может решить для себя, какие связанные данные ему действительно нужны.

public async Task<ArgumentEntity> GetByIdAsync(int id, params Expression<Func<ArgumentEntity,object>>[] includes)
{
    var baseQuery = ctx.ArgumentEntities; // ctx is a reference to your context
    foreach (var inlcude in inlcudes)
    {
       baseQuery = baseQuery.Include(include);
    }
    return await baseQuery.SingleAsync(a=>a.Id==id); 
}
person Akos Nagy    schedule 20.12.2017
comment
Спасибо за обмен вашего опыта. Означает ли это, что вы передаете идентификатор методам BLL и каждому репозиторию вызовов методов BLL для загрузки сущности с необходимыми связанными данными? - person Philipp Bocharov; 20.12.2017
comment
В основном да. Однако передача идентификатора может означать многое: иногда это означает, что методы BLL имеют фактический параметр, иногда это означает, что методы BLL получают доступ к идентификатору через какой-то внедренный сервис. Но, в конце концов, каждый метод BLL каким-то образом узнает об идентификаторе, а затем они сами решают, какие свойства им нужно загрузить, и передают выражения в соответствии с методами репозитория. - person Akos Nagy; 20.12.2017

Говоря в контексте DDD, кажется, что вы упустили некоторые аспекты моделирования в своем проекте, которые привели вас к этой проблеме. Сущность, о которой вы написали, выглядела не очень связной. Если для разных процессов (методов обслуживания) требуются разные связанные данные, похоже, вы еще не нашли подходящие агрегаты. Рассмотрите возможность разделения вашей Сущности на несколько Агрегатов с высокой связностью. Тогда всем процессам, связанным с конкретным Агрегатом, потребуются все или почти все данные, содержащиеся в этом Агрегате.

Поэтому я не знаю ответа на ваш вопрос, но если вы можете позволить себе сделать несколько шагов назад и реорганизовать свою модель, я думаю, вы не столкнетесь с такими проблемами.

person krzys    schedule 20.12.2017
comment
Ну, это не ответ, но он близок к этому. Разделение агрегата создаст еще одну проблему — со связями между агрегатами. Но я только что нашел приемлемое решение в другом вопросе здесь - person Philipp Bocharov; 20.12.2017