Данная статья затрагивает некоторые аспекты при выборе подхода к проектированию предметной области для сложных корпоративных систем. В ней исследуются причины возникновения классических подходов и их анализ, для возможного улучшения. Это моя первая статья на данную тему.

В начале хочется сказать, что предложенная концепция не пытается претендовать на роль какой-то панацеи и уж тем более на роль единственно верной, она так же имеет рад своих преимуществ и недостатков. Это скорее попытка переосмыслить опыт, который накопился у меня за несколько лет проектировании корпоративных систем. Материал изложен в виде вопросов и попыток на них ответить.

Богатая или анемичная модель предметной области, что же выбрать?

Думая об этом вопросе необходимо оценить положительные и отрицательные моменты обоих подходов.

  • Анемична модель.

    • Плюсы: Простота понимания, быстродействие при умелом подходе.

    • Минусы: Хаотичность, постоянно растущая сложность, трудно читать, легко обойти(забыть) бизнес-правила, отсутствие структуры самой модели.

  • Богатая модель.

    • Плюсы: Инкапсуляция, все бизнес-правила сосредоточены в сущностях, легче переиспользовать, проще поддерживать.

    • Минусы: Быстродействие, громоздкость (зависит от подхода), сложность понимания.

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

Полнота или чистота модели предметной области?

Отвечая на этот вопрос хочется отметить, что есть сторонники обоих подходов.

  • Сторонники чистоты модели предметной области придерживаются правила, что доменная модель не должна содержать внепроцессорных зависимостей, таких как запросы к БД, а все данные в модель необходимо передавать с уровня приложения.

  • Сторонники полноты утверждают, что невозможно добиться полной инкапсуляции всех бизнес-правил, если модель не может обращаться к БД

Для того чтобы наглядно продемонстрировать, о чем идет речь, давайте представим себе такой простой пример:

Нам необходимо зарегистрировать нового пользователя, для того чтобы это сделать, нам нужно проверить, не занят ли email.

  • В случае отсутствия возможности обратиться к данным на уровне модели, нам либо нужно передать в агрегат/сервис всех пользователей, либо перенести эту проверку на уровень приложения или инфраструктуры, что ведет к перетеканию логики предметной области между уровнями.

  • Если модель полная, то на уровне модели мы просто можем попросить репозиторий найти нам юзера по email и если такого нет, то создать.

И таких примеров на самом деле много, существует ли контрагент, есть ли заказы в обработке и т.д. Более того, часто на основании состояния той или иной сущности мы понимаем нужны ли нам дополнительные данные или нет, что в свою очередь приводит нас к необходимости загружать избыточные данные на уровне приложения, чтобы передать их в домен, на случай если они вдруг все же понадобятся. Так же есть проблема с тем, если разработчик передал не все данные, например какой-то метод требует коллекцию объектов, но разработчик может передать ее не полностью и снова модель станет не валидной.

С другой стороны, из-за существования внепроцессорных зависимостей могут существовать проблемы с быстродействием всей модели, и этот подход требует более скрупулёзного анализа вызываемых методов.

Пара слов про ООП

Хотя этот пункт и не является вопросом, мне хотелось бы затронуть тему объектно-ориентированного программирования (ООП), поскольку это связано с вопросами, рассмотренными в статье. Многие годы в программировании доминировал процедурный подход, при котором данные и операции с ними существовали отдельно, что приводило к недостаточной связности кода. Данные могли изменяться в любой части программы, без явной ассоциации с тем, кто и как их изменяет. ООП решает эту проблему, обеспечивая явную связь между данными и действиями над ними. В объектно‑ориентированном подходе данные и связанные с ними методы находятся в одном месте, что позволяет избежать «разброса» логики и облегчить поддержку и расширение программы.

Агрегаты, сервисы, репозитории…

Возвращаясь к модели предметной области, хочется все-таки отметить определенные преимущества богатой модели для больших и сложных проектов, т.к. это помогает структурировать взаимосвязи и уменьшить сложность, а также позволяет программистам понимать логику предметной области. Многие из нас видели legacy проекты и хаос, который там творится. Даже если применить шаблон CQRS, который бесспорно структурирует приложение, сторона изменения данных все еще будет довольно беспорядочна. При построении богатой модели часто прибегают к подходу, который называется domain driven design или DDD. Но что такое DDD? На ум сразу приходят такие слова как агрегаты, сущности, сервисы, репозитории и т.д. Но почему правила именно такие? Почему мы объединяем сущности в агрегаты? Где границы этих агрегатов? Почему внепроцессорные зависимости часто выносятся в сервисы, что же такое на самом деле репозитории? Куча вопросов, давайте попробуем разобраться.

  • Сущность – есть некий набор данных и поведения, обладающих собственной идентичностью, тут все более-менее понятно.

  • Так зачем же нужно объединять сущности в агрегаты? Агрегат, в общем смысле, определяет границы транзакционности. В понимании отцов основателей DDD, агрегат – достаточный набор данных и поведения, который можно получить из хранилища, чтобы, основываясь на внутреннем состоянии агрегата, как-то его изменить. Но как быть, если проверок очень много? Что если нам нужно загрузить кучу агрегатов, чтобы убедиться в том, можем ли мы изменить какую-то сущность или нет? Многие скажут, что неправильно спроектированы агрегаты, кто-то вспомнит про ограниченные контексты, часть людей предложит вынести проверки на уровень инфраструктуры или уровень приложения. Попробую сразу ответить на эти замечания:

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

    • Чтобы ответить на вопрос про ограниченные контексты, нужно сначала вспомнить, что это такое. Предполагается, что большую систему можно разделить на несколько основных смысловых блоков, каждый из которых будет заниматься своей ответственностью, например финансы, снабжение, производство и т.д. В этом случае предполагается, что один и тот же агрегат в разных контекстах будет выглядеть по-разному. Условный контрагент в доставке и в финансах – совершенно разные по смыслу и функционалу, в доставке нам важен адрес, а в финансах различные банковские данные. Но вот вопрос, далеко не во всех сценариях, относящихся к финансам нам нужны все банковские данные, в каких-то нужен лишь идентификатор, где-то какой ни будь счет, где-то может потребоваться наименование. Так до каких пределов можно «сужать» ограниченные контексты? Если поразмыслить, то до уровня транзакции, что в свою очередь, по сути, приводит нас к анемичной модели, т.к. каждая команда из CQRS и будет границей контекста.

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

  • А что же тогда сервисы предметной области? Опять же в понимании тех, кто стоял у истоков парадигмы DDD, сервисы выражают некое действие, которое по каким-то причинам не относится напрямую ни к одному из агрегатов. Тут нет ни слова о том, что сервисы должны или не должны иметь внепроцессорные зависимости, тем не менее, чаще всего сторонники полноты модели предметной области выносят их именно в доменные сервисы. Так же нет ни слова о том, что сервисы должны заниматься координацией состояния нескольких агрегатов, если в ходе сценария они должны измениться вместе, хотя на практике такой подход тоже встречается. Если отойти от изначальной концепции и считать, что сервис «ассоциирован» с конкретным агрегатом (т.е. просто расширяет агрегат методами, которые используют репозитории), то остается проблема взаимодействия между сервисами. Например, при удалении агрегата, нужно сделать что-то с другими агрегатами, но при этом некоторые действия с другими агрегатами опять же требуют наличие сервисов, в этом случае мы получаем все те же цепочки асинхронных вызовов и сильную связанность, даже если зависимости идут от абстракций.

  • Пару слов про репозитории, многие воспринимают этот шаблон – как способ абстрагироваться от конкретного хранилища или ОРМ, но если посмотреть определение, то шаблон репозитория должен обеспечивать доступ к данным, как будто эти данные лежат в виде коллекции в памяти. При этом данные выражены именно самими объектами модели предметной области. На первый взгляд кажется, что нет особой разницы, как получать данные из репозитория, обращаясь к его методам или к коллекции, но на самом деле разница есть и она ощутима, но об этом мы еще поговорим чуть ниже.

Так как же все-таки писать?

Часто думая о вышеизложенной проблематике, как-то раз мне в голову пришла мысль, а как бы я писал «корпоративное» ПО, если бы не было ограничений в виде базы данных и одновременного доступа к данным из-за большого числа пользователей? Т.е. если это простое однопоточное приложение, где все данные лежат в коллекциях прямо в оперативной памяти.

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

Второй вопрос полнота или чистота, тоже отпадает сам собой, т.к. внепроцессорные зависимости отсутствуют. Команда является своего рода оркестратором, который вызывает различные методы предметной области в соответствии со сценарием. Далее модель предметной области изменяется или не изменяется в соответствии с переданными данными и правилами бизнес-логики. Разберем чуть подробнее:

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

  • Сервисы. Если рассматривать, сервисы как классы, расширяющие сущности методами с внепроцессорными зависимостями, т.е. запросами к репозиториям, то они сразу отпадают, т.к. этих зависимостей больше нет. Если рассматривать сервисы, как способ координации сущностей между собой, то такой подход имеет место быть. Но возникает ряд неоднозначных вопросов:

    Как осуществить передачу сервису право управления приватными методами или свойствами сущностей (т.е. проблемы инкапсуляции)?  Может ли сервис вызывать метод другого сервиса? Чем метод сервиса отличается от метода сущности? А чем вообще бы занимался метод сервиса, если бы он отвечал за координацию? На все эти вопросы можно ответить в зависимости от выбранного подхода, но я задал себе другой вопрос:

    Можно ли как-то еще скоординировать состояния сущностей, если эта координация является частью логики предметной области, чтобы это не нарушало инкапсуляцию и не вводило дополнительную сложность?

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

    • Делегаты. В этом случае, можно передать делегатом приватный метод по уменьшению количества сущности «остаток на складе», сущности «ресурс документа поступления», которая вызовет его в своем методе удаления.

    • События. Приблизительно такого же поведения можно добиться благодаря событиям, это могло бы выглядеть так: при удалении сущности «ресурс документа поступления», вызывалось бы событие, на которое была бы подписана сущность «остаток на складе», и в зависимости от аргументов события количество остатка на складе изменялось бы.


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

  • Репозитории. А как быть, если в ходе сценария часть сущностей уже изменилась, но вдруг очередной метод выбрасывает исключение? Нужно как-то отменить все изменения, простая отметка, что сущность была изменена не даст нам результатов, нужно где-то хранить состояние до изменения. А если мы вдруг на секунду вспомним, что в реальной жизни, мы пишем не однопользовательское приложение, а веб приложение, где много пользователей работают с одними данными, то придем к выводу, что на каждый сценарий (запрос), необходимо создавать отдельную копию коллекции изменившихся данных, плюс как-то блокировать эти изменения в основной коллекции, если они были прочитаны в другом запросе и на основании них были сделаны какие-то изменения. И в этот момент, по сути, началось проектирование in memory data base, просто понял я это не сразу, но когда понял, то осознал, что такое репозиторий.

    Репозиторий – это прежде всего коллекция. Не способ доступа к данным, не абстракция над конкретной ORM или БД, не кэширование — это все детали реализации. По большому счету, репозиторий нам нужен для того, чтобы хранить и работать с собственной коллекцией данных, в рамках сценария (запроса). После выполнения сценария, сделанные изменения, применятся или не применятся к данным, находящимся в хранилище, а уж где будет это хранилище в оперативной памяти или в БД разницы особо нет. Главное — это то, что мы не можем изменять данные в хранилище напрямую.

    Вопрос, как данные из хранилища попадают в коллекцию репозитория, остается открытым. Что из оперативной памяти, что из БД, хранящейся на диске, нужно эти данные в репозиторий передать, и репозиторий каким-то образом должен преобразовать эти данные в собственную коллекцию доменных объектов. Значит репозиторий должен знать что-то про саму сущность, например про ее приватный конструктор.

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

    • Если мыслить только сущностями, то можно сделать абстракцию репозитория вложенной в сущность, тогда репозиторий будет иметь доступ к внутренним членам сущности. В абстракции можно реализовать protected метод restore, который использует приватный конструктор сущности.


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

Приблизительная схема command-части приложения будет выглядеть в этом случае примерно так. Схемы одинаковые, разница лишь в том, чтобы использовать делегаты или события.
Приблизительная схема command-части приложения будет выглядеть в этом случае примерно так. Схемы одинаковые, разница лишь в том, чтобы использовать делегаты или события.

А что же все-таки с внепроцессорными зависимостями?

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

Сначала я подумал, почему бы не просить репозиторий заполнить коллекцию прямо в бизнес‑действии? т. е. прямо в доменном методе сущности вызвать метод репозитория, например, FillByNames, а потом сразу обратиться к его коллекции. На первый взгляд кажется, что таким образом мы удобно можем обеспечить полноту данных для модели домена. Методы домена при этом можно писать сразу так, чтобы они умели работать со списком сущностей для обеспечения производительности. Например, метод создания клиента мог бы выглядеть так:

public record CreateArg(string Name, string Address);
public static async Task<List<Client>> CreateRange(List<CreateArg> args)
{
    var names = args.Select(x => x.Name).ToList();
    await Data.Uow.Client.FillByNames(names);
    if (Data.Uow.Client.List.Any(x => names.Contains(x.Name)))
        throw new DomainException("В системе уже зарегистрирован клиент с таким наименованием");

    List<Client> clients = new List<Client>();

    foreach (var arg in args)
    {
        var client = new Client(Guid.CreateVersion7(), arg.Name, arg.Address, Conditions.Work);
        client.Append(Data.Uow.Client);
        clients.Add(client);
    }

    return clients;
}

 Попробовав такой подход на практике, выявились определенные минусы:

  • На уровне домена нет понимания, были ли ранее загружены данные в коллекцию или нет. Да, реализация репозитория обеспечивает такую проверку, но лишнее обращение к БД все-таки происходит. Частично эта проблема решается кэшированием вызванных функций и их аргументов.

  • Асинхронность, необходимо помечать все эти методы как асинхронные. Так же приходится поддерживать асинхронные делегаты и события. Даже если данные были загружены ранее, и мы используем кэширование, хотя обращения к БД и не будет, но методы будут асинхронными. Частично можно решить это, передавая в виде аргументов сразу сущности, а не их идентификаторы, это будет значить, что сущность ранее уже загружена. В некоторых сценариях это не удобно, т.к. приходится пробрасывать сущности через несколько методов, хотя напрямую они к ним могут не относиться.

  • Возникает сложность тестирования таких методов.

  • Это все какие-то специфичные вещи, которые не должны относиться к чистой модели предметной области.

Да, знание о том, какие данные нужно получить из коллекций – знание домена. Но загрузка этих данных из БД не относится к домену. Уровень приложения должен гарантировать, что все необходимые для домена данные загружены. Было принято решение разделить репозитории на уровне абстракций между доменом и приложением. На уровне домена интерфейс репозитория выглядит так:

public interface IBaseRepository<TEntity> where TEntity : BaseEntity
{
    public IEnumerable<TEntity> List { get; }
    public void AddEntity(TEntity entity);
}

public interface IRepository : IBaseRepository<Client>
{
  protected static Client Restore(Guid guid, string name, string address, Conditions condition)
        => new(guid, name, address, condition);
}

На уровне приложения так:

public interface IClientRepository : Client.IRepository, IRepository
{
    public Task FillByNames(List<string> names);
}

В этом случае мы обеспечиваем чистоту модели, так как репозитории это просто коллекции, но также обеспечиваем и полноту, т.к. домен знает какие данные, по каким параметрам и в какой момент ему нужны. Задача же приложения (команды) заполнить коллекции репозиториев нужными для домена данными.

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

Правда статические методы в сущностях, для обеспечения работы сразу же со списком доменных объектов были оставлены, т.к. они все равно дают некоторый, пусть и не такой значительный, прирост производительности.

    public record CreateArg(string Name, string Address);
    public static List<Client> CreateRange(List<CreateArg> args, IData data)
    {
        var names = args.Select(x => x.Name).ToList();
        if (data.Client.List.Any(x => names.Contains(x.Name)))
            throw new DomainException("В системе уже зарегистрирован клиент с таким наименованием");

        List<Client> clients = new List<Client>();

        foreach (var arg in args)
        {
            var client = new Client(Guid.CreateVersion7(), arg.Name, arg.Address, Conditions.Work);
            client.Append(data.Client);
            clients.Add(client);
        }

        return clients
    }

Как быть, когда необходимо создать новую сущность и передать другим сущностям ее идентификатор?

Тут, конечно, можно подойти с разных сторон, например попросить репозиторий сохранить сущность и вернуть базу данных идентификатор, но самое простое и быстрое решение использовать в качестве идентификатора Guid. Просто создаем новую сущность через публичный конструктор или метод, добавляем ее в коллекцию репозитория, отдаем ее Guid тем, кто в нем нуждается и все.

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

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

Упомяну так же, что для реляционных БД стоит использовать последовательные Guid, такие как Uuid7, для обеспечения последовательности кластеризованного индекса.

Как же репозиторий обновляет данные в хранилище?

Вероятно, коллекция внутри репозитория должна иметь время жизни, равное времени жизни запроса, это не трудно обеспечить с помощью механизмов DI, но также ведь и репозиторий не один, а нам необходимо, чтобы в конце транзакции все изменения в разных репозиториях были занесены в хранилище. В этом случае приходит на помощь известный шаблон Unit Of Work, который содержит в себе список репозиториев, а в конце транзакции он вызывает у всех репозиториев метод Commit. Тогда время жизни Unit Of Work будет равно времени жизни запроса и нам нет нужды задумываться о времени жизни каждой отдельной коллекции внутри репозитория.

Быстродействие или как сделать так чтобы репозиторий не загружал ранее загруженные данные повторно?

Для этого, в примере приложенной к статье, в BaseRepository существует protected метод FillCollection, который проверяет существующую коллекцию на предмет наличия в ней тех же Guid, и если они там существуют, то он не добавляет их в запрос. Для лучшей работы с большими объемами можно использовать Bulk операции и заменить List на HashSet.

    private async Task FillCollection(List<Guid> guids)
    {
        var existGuids = list
            .Select(y => y.Guid)
            .ToList();

        guids = guids.Except(existGuids).ToList();

        if (guids.Count != 0)
        {
            var inDb = await GetFromDbByIdsAsync(guids);
            list.AddRange(inDb);
        }
    }

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

Кто должен управлять базовыми статусами самой сущности?

Сущности имеют 3 базовых статуса, добавлена, удалена, изменена. Эти состояния наследуются от BaseEntity, в которой определены методы для того, чтобы пометить, что сущность является измененной, добавленной или удаленной, при этом добавленная сущность останется добавленной, даже если потом вызвать метод, который должен пометить ее как измененная. Методы помечены модификатором protected, поэтому только сама сущность может определить, как она изменилась в ходе бизнес-действия. Проще говоря, если бы у сущности или репозитория был бы публично открытый метод Remove, то любой вызывающий код мог бы удалить эту сущность в обход любых бизнес правил.

public class BaseEntity
{
    public Guid Guid { get; protected init; }
    public ModificationTypes ModificationType { get; private set; } = ModificationTypes.None;

    public enum ModificationTypes
    {
        None = 0,
        Created = 1,
        Updated = 2,
        Removed = 3
    }

    protected void Append<TEntity>(IBaseRepository<TEntity> repository) where TEntity : BaseEntity
    {
        if (ModificationType == ModificationTypes.None)
        {
            ModificationType = ModificationTypes.Created;
            repository.AddEntity((TEntity)this);
        }
        else
            throw new Exception("Неправильное использование метода Append");
    }

    protected void Remove()
    {
        ModificationType = ModificationTypes.Removed;
    }

    protected void Update()
    {
        if (ModificationType == ModificationTypes.None)
            ModificationType = ModificationTypes.Updated;
        else
            throw new Exception("Неправильное использование метода Update");
    }
}

Так же стоит отметить, что сами репозитории при таком способе проектирования получаются маленькими и очень простыми, нет необходимости дополнительно анализировать изменения вложенных сущностей, делить коллекции на те которые удалились или изменились, коллекция и так сразу разделена.

События или делегаты?

Остался еще один вопрос, который стоит разобрать. Оба подхода имеют свои плюсы и минусы, мы попробовали каждый и пока что сделали выбор в пользу делегатов. Т.к. они проще для понимания и имеют четкую последовательность вызовов.  Дело в том, что при использовании событий бывает сложно отследить последовательность вызовов, это можно сделать через логирование или отладку. Хотя, возможно, их синтаксис проще и приятнее для использования, за ними сложнее следить, иногда приходится создавать/вызывать несколько событий подряд, если важна последовательность.

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

public sealed class ReceiptItem : BaseEntity
{
    private static Action<List<Balance.RemoveRangeFromStockArg>, IData> removeRangeFromStock;
    public static void SetRemoveRangeFromStock(Action<List<Balance.RemoveRangeFromStockArg>, IData> action) 
        => removeRangeFromStock ??= action;
}

public sealed class Balance : BaseEntity
{
    static Balance()
    {
        ReceiptItem.SetRemoveRangeFromStock(RemoveRangeFromStock);
    }
}

Куда же без минусов?

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

[RequestRoute("/Directories/Client/Save", RequestRouteAttribute.Types.Command)]
public class Handler : IRequestHandler<Request, Guid>
{
    public async Task<Guid> HandleAsync(Request request, IServiceProvider provider)
    {
        var uow = (IUnitOfWork)provider.GetService(typeof(IUnitOfWork));

        await uow.Client.FillByNames([request.Name]);

        if (request.Guid == Guid.Empty)
        {
            return Client.CreateRange([new Client.CreateArg(request.Name, request.Address)], uow).First().Guid;
        }
        else
        {
            await uow.Client.FillByGuids([request.Guid]);
            var client = uow.Client.List.FirstOrDefault(x => x.Guid == request.Guid);
            Client.UpdateRange([new Client.UpdateArg(client) with {
                Name = request.Name,
                Address = request.Address
            }], uow);

            return client.Guid;
        }
    }
}

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

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

Так DDD ли это?

Раньше Эрик Эванс в своей книге про DDD в первой ее части рассказывал про такие тактические шаблоны как агрегаты, сущности, сервисы и репозитории, а во второй части уже рассказывал про стратегические шаблоны, такие как единый язык или ограниченный контекст. В последующих изданиях он поменял местами тактические и стратегические шаблоны, так как считал, что роль последних значительно выше. Изложенная выше концепция не претендует на звание DDD подхода, хотя в ней сохраняются такие стратегические шаблоны, как единый язык, предметная область, при необходимости можно использовать ограниченные контексты. Так же, некоторые из тактических шаблонов тоже сохраняются, например сущность и репозиторий, объект-значение, доменный сервис и доменное событие. В любом случае DDD не является какой-либо конкретной технологией или методологией. DDD — это набор правил, которые позволяют принимать правильные проектные решения.

Прикладываю к статье ссылку на репозиторий, в котором продемонстрирован данный подход. Так как проект демонстрационный, в нем отсутствуют транзакции, тесты, логирование и другие аспекты реального проекта. Так же на данный момент в проекте отсутствует сторона запросов (query) и клиент.

Комментарии (17)


  1. onets
    26.01.2025 14:13

    Интересно. Но есть вопрос - в статье упомянут остаток на складе. Как можно было бы решить следующую задачу в рамках представленного подхода:

    Есть остатки на складах, есть входящий поток заказов в несколько параллельных потоков на n физических серверах, БД одна. Для каждого резервируется товар на складе. Получается такой конкурентный доступ к остаткам, и очередному заказу может не хватить.

    В рамках анемичной модели с подгрузкой состояния склада в память сервера и блокировками на уровне приложения - более менее стабильно это работало только на одном сервере. На двух и более серверах остатки и резервы в конце не совпадали.

    Внедрение распределенной блокировки особо не помогло. Заметное увеличение времени обработки и хоть какая-то стабильность только в рамках периода блокировки. Малейшая случайная задержка в любом месте по любой причине на большой массе заказов - выдавало расхождение остатков и резервов.

    И только перенос бизнес логики в хранимую процедуру с использованием специфики БД + короткая транзакция в рамках хранимки + без перегона массива данных между сервером БД и сервером приложений по сети - дало стабильный результат - быстро и без расхождений остатков.

    Но бизнес логика в хранимке - ну никак не натягивается на глобус DDD. Может вы уже решали подобную задачу в рамках DDD и есть чем поделиться?


    1. Gromilo
      26.01.2025 14:13

      Я для себя решил, что если использование чего-то в БД - это важная часть доменной логики, то пусть будет в домене. Не вижу смысла абстрагирования от важной детали реализации. У нас код может буквально построен вокруг этой детали/гарантии/процедуры.


      1. onets
        26.01.2025 14:13

        А как это выглядит в коде? Отдельный метод в интерфейсе репозитория?


        1. Gromilo
          26.01.2025 14:13

          Да, просто отдельный метод. Может быть какая-то бд специфичная терминология. Если нам нужно управлять транзакцией, то в домене будет что-то для этого.


    1. A_Kubit Автор
      26.01.2025 14:13

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


    1. norguhtar
      26.01.2025 14:13

      Единственный верный вариант решать этот вопрос в СУБД. Она для этого и была придумана. Там из коробки ACID реализован, а вы спрашиваете как его в DDD реализовать. А зачем?


  1. Dhwtj
    26.01.2025 14:13

    Слишком длинная статья для хабра.

    Много специфичной терминологии вызывает когнитивную нагрузку, особенно в выходной.

    А можно в двух словах?

    Проблема (ы), известные решения, ваше предложение. Как-то в такой структуре.


    1. hardtop
      26.01.2025 14:13

      А как в дух словах описать сложную систему? Типа, был процедурный подход, спаггети-код... Сделали через ООП в парадигме DDD, но проблема с уровнем абстракции.

      Но вот чего не понимаю, почему мы все стараемся так сильно абстрагироваться от БД?


      1. KrawMire
        26.01.2025 14:13

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

        Применительно же именно к БД, также можно добавить, что сущности в приложении могут быть спроектированы одним образом, а для оптимизации производительности работы БД может быть выбрана немного другая.

        В общем, просто из-за того, что это внешние системы, на которые мы либо не можем повлиять вообще (например, внешние API), либо можем повлиять, но обязательно с учетом определенных особенностей самой внешней системы (БД, например). Также у внешних систем есть свойство время от времени меняться (хоть и нечасто либо вообще никогда), а в таком случае скрытие деталей за абстракциями как раз очень поможет.


  1. ozket
    26.01.2025 14:13

    Анемичной модели предметной области не существует по определению. Предметная область описывается объектами. Объект, по определению из ООД, определяется своим поведением и скрывает состояние. То, что не содержит поведения, а только структуры (чем и оперирует «анемичная модель») - это структура данных. Таким образом «анемичная модель» - это парадигма процедурного программирования, а DDD базируется на объектно-ориентированном дизайне, соответственно, «анемичная модель» в контексте к DDD не может быть определена.


  1. dcwk
    26.01.2025 14:13

    В последующих изданиях он поменял местами тактические и стратегические шаблоны, так как считал, что роль последних значительно выше. 

    Эванс только одну книгу написал. Про то что Эванс признал важность стратегического проектирования уже писал Вернон в своей «красной книге». Там как раз со стратегического проектирования и начиналось все.


  1. hardtop
    26.01.2025 14:13

    Как по мне - анемичность - это какой-то миф. Сделали модели Клиентов, Заказов, Товаров... Добавили луковую архитектуру (сделали сервисный слой). Мы слишком заигрались в абстракции.

    И зачем мы постоянно пытаемся как можно сильнее абстрагироваться от БД? Она же наш друг, хранилище данных, консистентность, атомарость, транзакции и общая благодать. Так то на заре интернета именно база данных была одним из валидаторов данных (ну, когда на перле\пхп писали без всяких типов).


    1. Gromilo
      26.01.2025 14:13

      Основная проблема с БД - её сложно использовать как сервис приложений. Мы так и не придумали её как горизонтально масштабировать и как нормально вести массовую разработку на хранимках.

      Но это может работать! Я работал с системой, у которой апи фактически находилось в БД, а веб-сервер в основном ходил за данными и почти ничего не делал.

      Мне вот не нравится, когда мы начинаем отказываться от плюшек БД, типа транзакций ради "чистоты" домена. Отличный способ усложнить себе разработку на ровном месте.


  1. cupraer
    26.01.2025 14:13

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

    Как именно ООП решает эту проблему, можно узнать? Как вообще в голове у людей укладывается концепция «foo.bar = 42 — плохо, а foo.setBar(42) — хорошо»? Вы видите какую-то разницу? — А её нет.

    Эта проблема решена в Аланокаевском ООП, но даже в Смолтолке — нет. Из существующих языков — она решена только в эрланге и эликсире.


    1. ozket
      26.01.2025 14:13

      В ООП не будет примитивных set/get, будет конструктор и действия. Как только появляется явное изменение параметров - это уже не объект, но структура, и да, иногда их смешивают, что усложняет развитие «объекта+структуры данных».


  1. AlexViolin
    26.01.2025 14:13

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


    1. ozket
      26.01.2025 14:13

      Агрегат - это и есть сущность, но с инвариантом над некоторым кластером сущностей, входящих в него и не имеющим контекстуального смысла за его пределами.

      Если пойти от обратного, то сущность - это агрегат, в котором определен инвариант только над ним самим и который не включает в себя других сущностей.