О сущностях, DTO, ORM и Lazy Load

habr.png

Объектно-ориентированная парадигма — стандарт для прикладного ПО. Реляционные СУБД — стандарт хранения данных в прикладном ПО. Да, можно писать и на Haskell и хранить данные исключительно в ClickHouse. Но речь о мейнстриме.

ORM позволяет натянуть сову на глобус сделать вид, что RDBMS’а нет и данные хранятся в объектной модели, более подходящей для ООП. Остается «маленькая» такая проблемка — эта абстракция, как и многие другие, «течет». Там где в объектной модели ссылка на другой объект в базе данных foreign key и id. В момент материализации сущности мы встаем перед выбором:

  1. Загрузить все и упасть с out of memory / timeout
  2. Явно указать какие зависимости мы хотим загрузить, а какие — нет и нарушить принцип tell don’t ask
  3. Загружать зависимости неявно по требованию с помощью Lazy Load и получить проблемы с производительностью где-то в вызываемом коде


Какую-же ногу себе отрезать: левую или правую?

TLDR Lazy Load не так плох, если использовать только для записи и не использовать при чтении. Но все не так просто и есть куча нюансов.
Со временем я пришел ко мнению, что Lazy Load и/или зависимость сущностей от реализации ORM -меньшее из зол при соблюдении некоторых условий.

В read-подсистеме всегда читать только DTO


В 90% случаев проблемы с Lazy Load возникают именно при чтении. Получаем список сущностей, пробегаемся по нему циклом и начинаем выбирать все необходимые данные. Получаем вал запросов к БД. При этом чаще всего единственное, что нужно сделать — это получить данные, сериализовать и отправить их в ответ в виде JSON. Зачем же тогда вообще загружать сущности? Нет никакой нужды добавлять эти данные в change tracker UOW, читать целиком сущность вместе с «лишними» полями. Вместо этого можно всегда писать либо Select, либо ProjectTo. Lazy Load не потребуется, потому что C#-код из Select будет транслирован в SQL и выполнен на стороне БД.

Что делать если моя логика не транслируется в SQL?


Client Evaluation я рекомендую держать выключенным. Во первых, можно «помочь» и дописать поддержку необходимых функций прямо в субд. Не самый плохой вариант, если речь идет о простых вычислениях, а не бизнес-правилах. Вариант номер два: выделить интерфейс из сущности и реализовать его и в сущности и в DTO.

Например, в БД есть два поля: «цена без скидки» и «цена со скидкой». Если поле «цена со скидкой» заполнено, то используем его, если нет — то используем поле с обычной ценой. Добавим еще одно правило. При покупке 3 товаров вы платите только за 2 самых дорогих, при этом обычные скидки также учитываются.

Реализация может быть такой:

public interface IHasProductPrice
{
    decimal BasePrice { get; }

    decimal? SalePrice { get; }
}

public class Product: IHasProductPrice
{
   // ... a lot of code

   public decimal BasePrice { get; protected set;}

   public decimal? SalePrice { get; protected set;}
}

public class ProductDto: IHasProductPrice
{
   public decimal BasePrice { get; set;}

   public decimal? SalePrice { get; set;}
}

public static class ProductCalculator
{
    public static void decimal Calculate(IEnumerable prices)
}


Во write-подсистеме Lazy Load не так страшен


Во write-подсистеме, наоборот, довольно часто только id для записи не достаточно. Всевозможные проверки не редко заставляют читать сущность целиком, потому что объектная парадигма предполагает совмещение данных и операций над ними в рамках объекта класса и его инварианта. Если в проекте используется DDD, то операции записи/изменения должны производиться через корень агрегации, а значит только над одним объектом и его зависимостями. Большое количество запросов может возникнуть только при работе со связанными коллекциями.

Связанные коллекции в агрегатах


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

Что если в агрегате все-таки тысячи записей


Передайте DbContext в конструктор и читайте из него только необходимые в контексте операции данные. Да, нарушаем DIP. Либо так, либо вообще не использовать агрегат в этом случае.

Массовые операции


Импорт файла на 10.000 строк отличная мишень для Lazy Load. Здесь ко всем проблемам read-подсистемы добавляются еще и тормоза ChangeTracker’а. Для массовой записи нужно использовать отдельные инструменты. Я отдаю предпочтения Batch Extensions, потому что опять можно обойтись без создания сущностей. Для особо тяжелых случаев существуют старые добрые хранимые процедуры и даже специальные средства СУБД.

Лайфхак


Если нужно реализовать и массовую операцию и обычную, нужно начинать с массовой. Обычная операция — просто частный случай массовой, кода в последовательности только один элемент.

© Habrahabr.ru