Всё мне позволительно, но не всё полезно; всё мне позволительно, но ничто не должно обладать мною.

Апостол Павел

Если вы никогда не интересовались паттернами DDD или это было давно и неправда, эта статья, к сожалению, мало чем сможет вам помочь. Если вы никогда не читали Вернона – я настоятельно рекомендую это сделать, его объяснения прекрасны, подробны и системны.

Если же вы знакомы с трудами классиков, но сочли их оторванными от жизни, либо были когда-то ими воодушевлены, попробовали воплотить их идеи на практике, но столкнулись с проблемами и разочаровались, то, возможно, я смогу вам помочь. Не потому что я – лучший в мире архитектор, программист или технический писатель, а потому, что я применяю Domain Driven Design на практике и советы, которыми я хочу поделиться, это не «ещё один пересказ Эванса», а отражение того, как это действительно может работать (как минимум в моей практике) в реальных проектах.

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

Как перейти от стратегического уровня к тактическому?

Самое забавное, что такого перехода в DDD на самом деле нет. Ну или, если хотите, он крайне нелинейный.

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

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

Гений за работой
Гений за работой

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

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

Если вы можете себе позволить серию сессий Event Storming-а – просто сделайте это. Если нет, попробуйте хотя бы приблизиться к этой методике, насколько возможно.

Дальше я пройдусь по основным тактическим паттернам, тезисно напомнив их суть. Но основное внимание я хочу уделить не тому, «как это надо делать», с этим Эванс и Вернон справились куда лучше, а тому, как «это делать не надо» на основе наблюдений за тем, какие ошибки обычно совершаются практикующими DDD разработчиками. А вместо примеров кода я постараюсь сделать то, что, по моему опыту, обычно упускается в других материалах – показать причины, по которым существует паттерн, с какими проблемами и каким образом он борется на практике, а не в теории.

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

Entity – царь паттернов

Entity это просто и скучно. Это просто класс, который идентифицируется по уникальному идентификатору и его состояние можно хранить в какой-то базе данных (хранилище). Но простота entity – обманчива, за ней скрывается ООП во всём своём ужасающем великолепии.

Entities это не просто часть доменного слоя, это бо́льшая часть этого самого доменного слоя, причина его существования. Всё остальное их только в той или иной форме дополняет.

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

Оборотная сторона последнего утверждения – в интерфейсе хорошо спроектированных entities практически отсутствуют методы и свойства, которые не являются частью Единого Языка и которые вы не можете обсуждать с экспертами предметной области. И это создаёт определённые проблемы для проектировщика.

Доклады и презентации создают иллюзию простоты проектирования
Доклады и презентации создают иллюзию простоты проектирования

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

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

Это и есть – ключевой вопрос DDD, то, ради чего эта методология была создана изначально. Проще в моменте не означает проще в долгосрочной перспективе. Хорошую entity сложно спроектировать, но вот использовать  её в дальнейшем – намного проще и, что ещё важнее, благодаря инкапсуляции её куда сложнее использовать неправильно.

Всё дело именно в когнитивной сложности проблемы, предметной области. Если она относительна проста - эти дополнительные усилия никогда не окупятся. И в этом случае вам не просто не нужны хорошо проработанные entities – вам вообще не нужно отделять доменный слой от application layer, это всё – лишняя работа, целиком. Но если предметная область окажется достаточно сложна, то попытка обойтись более простыми методами может привести к тому, что ваш проект со временем флопнется – о чём Эванс как раз со вкусом и с отсылкой к своему богатому практическому опыту и рассказывал.

Ближайший аналог – unit tests. Их внедрение всегда тормозит работу над проектом, потому что от маленького и плохо проработанного набора тестов толку мало, а создание хороших тестов – само по себе немаленькая и непростая работа. Но чем дальше, тем большим оказывается накопительный эффект от хорошего покрытия тестами, и в какой-то момент проект с тестами вырывается вперёд по эффективности и его становится уже не догнать.

Во-вторых, сокрытие свойств entities, не участвующих в доменной модели с точки зрения доменных экспертов, мешает использовать её в коде, прежде всего как раз в части сохранения и восстановления состояния. По этой причине крайне желательно использовать инструменты, специально созданные для облегчения такой задачи (такие, как ef core), иначе налог, который вам приходится выплачивать за всё это удовольствие, может оказаться для вас чрезмерным.

Тут было бы здорово рассказать про паттерн Aggregate, но его осмысленное рассмотрение невозможно без чёткого понимания нескольких других паттернов, поэтому пока что просто упомяну, что любой агрегат это entity, а также все те entities, на которую эта entity (aggregate root) прямо или рекурсивно ссылается.

Repository – весь такой загадочный

Репозиторий это паттерн доменного слоя, который позволяет вам работать с состоянием ваших сущностей (entities, а ещё точнее - aggregates). Не больше и не меньше.

Репозиторий это слишком важно, чтобы игнорировать факты
Репозиторий это слишком важно, чтобы игнорировать факты

Репозиторий – абсолютный чемпион по неправильному понимаю среди всех известных мне паттернов. Отчасти (но только отчасти) это объясняется тем, что этот паттерн используется не только в DDD и за его пределами имеет несколько иную трактовку.

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

Disclaimer – я буду рассказывать о репозитории на примере DbContext из .net core.

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

Репозиторий это не DAO

Репозиторий это не метод работы с базой данных. Репозиторий это не метод сокрытия механизмов работы с базой данных. Репозиторий это не точка доступа к базе данных.

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

DbContext это не репозиторий

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

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

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

Вашим репозиториям не нужен IQueryable (и паттерн Specification тоже)

Часто можно встретить дискуссии на тему «можно или нельзя использовать IQueryable в интерфейсе репозиториев». Кто-то при этом настаивает, что нельзя, нужно обязательно использовать свою собственную реализацию паттерна specification и плевать, что она хуже и неудобнее.

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

Всегда есть альтернатива
Всегда есть альтернатива

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

Не плодить методы

Первый по распространенности ответ - «чтобы не плодить в репозитории десятки методов получения данных». И этот ответ неизменно вгоняет меня в состояние полного, кхм, недоумения.

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

Что делать с запросами, которые очевидно требуют исполнения бизнес-правил, правил доменного слоя? Выносить логику фильтрации в реализацию репозитория, в инфраструктурный слой? Звучит не очень, ответственность слоёв размывается, появляется необходимость обсуждения с доменными экспертами кусков кода из инфраструктуры. Загружать entities в память и фильтровать там? Это масштабируется просто из рук вон плохо.

К счастью, для связки .net + ef core есть в меру кривое почти красивое решение – использовать Expressions и, ценой незначительного замусоривания интерфейса entity, получить лучшее из двух миров.

public class Client : Entity
{
    public ClientStatus Status { get; private set; }
    
    public static readonly Expression<Func<Client, bool>> IsVipExpression = 
        c => c.Status == ClientStatus.Platinum 
          || c.Status == ClientStatus.Diamond;
    
    private static readonly Func<Client, bool> isVipCompiled = 
        IsVipExpression.Compile();
    
    public bool IsVip() => isVipCompiled(this);
}

public class ClientRepository : IClientRepository
{
    public async Task<IReadOnlyList<Client>> GetVipClientsAsync()
        => await dbContext.Clients
            .Where(Client.IsVipExpression)
            .ToListAsync();
}

I-Что-то-там-QueryHandler

Следующий вариант объяснения того, почему, якобы, без specification в репозитории никуда, куда тоньше и, отчасти, опирается на самого Эванса (и на солнце бывают пятна). Ведь нам часто требуется вернуть список каких-то сущностей, который должен быть определённым образом отфильтрован, отсортирован и разбит на страницы. И самый простой и удобный способ это сделать в репозитории - как раз вернуть IQueryable, разве не так?

Нет, не так. Вспоминаем ранее пройденное – репозиторий это паттерн доменного слоя и выражает он доменные концепции. Причём тут фильтры, сортировки и страницы, какое отношение они имеют к доменному слою и репозиториям, если это чистый и незамутнённый application layer?

«А как иначе получить список entities»? А причём тут entities, если всё, что вам нужно, это их данные, причём, скорее всего, не целиком, а в виде проекции, с подзапросами и, забегая немного вперёд, с нарушением границ агрегатов? Вспоминаем ранее пройденное... впрочем, я, наверное, этим уже задрал, но всё равно - репозиторий это не DAO. А entity, соответственно, не DTO.

Никто и ничто не может вам помешать создать другой интерфейс, причём в application слое и в его терминах, обозвать его «I-Что-то-там-QueryHandler», и уже в его реализации (в слое инфраструктуры, разумеется) обращаться к базе данных любым способом, который вы сочтёте оптимальным для вашей конкретной задачи. И до тех пор, пока вы в нём только читаете данные доменных сущностей, не пытаясь их изменять, ваш проект будет жить в полном покое и безопасности.

Вы можете использовать всё тот же DbContext. Вы можете использовать direct sql. Вы можете использовать кеширование. Вы можете даже уйти в полный CQRS и разделить данные для записи и для чтения, формируя одно или несколько представлений для чтения при каждой модификации доменной сущности (если скорость обработки таких запросов для вас действительно критична). Вы не ограничены буквально ничем и этот подход позволяет изящно разрешить процентов эдак девяносто всех претензий к скорости работы приложений, разработанных в парадигме DDD, которые, якобы, «не живут под нагрузкой».

Но и тут вам, скорее всего, не нужен ни IQueryable, ни свой specification, потому что соответствующий метод будет вызываться из вашего же api, для которого вы свой собственный specification уже, очевидно, разработали (иначе как клиент передаст вам информацию о своих потребностях?), причём вы имеете полное право считать соответствующие структуры частью своего application слоя, что ещё сильнее снижает ваши накладные расходы на реализацию.

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

Ну или можно просто смириться с пробитием изоляции слоёв для подобных ситуаций. It’s a numbers game. Не позволяйте архитектурным принципам мешать вам поступать правильно, не подменяйте цель средствами её достижения. Вам не нужно 100% покрытие вашей кодовой базы юнит-тестами, 85% вполне достаточно, если это правильные проценты. Ведь покрытие тестами это не самоцель, а метод поддержки и развития кодовой базы.

Чистоты кода это касается в той же степени, она не является и не может являться целью – это средство. Метод снижения затрат и рисков, связанных с развитием проекта. Держите 95% кодовой базы доменного слоя чистой и вы сможете себе позволить компромиссы в оставшихся 5%. Хотя нельзя не признать, что это требует от команды куда большей зрелости и самодисциплины, чем скрупулёзное следование принципам, так что честно оценивайте свои возможности. Лень, равно как и желание обязательно закрыть спринт без переноса задач, очевидно никудышные поводы для срезания углов в доменной модели.

Вашим репозиториям не нужен базовый интерфейс.

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

А во-вторых, что вы туда хотите положить, какие методы?

Delete

Большинство бизнес-сущностей не имеет семантики удаления, а soft-delete, хоть и часто встречается, но всё же нужен далеко не всегда, да и именовать его лучше в соответствии с терминологией домена, а не технического паттерна (а помещать – в entity, но это отдельная история).

Update

Если вы используете change tracker, то метод Update в вашем случае будет пустым (а если вы его не используете, то вам приходится вручную делать массу лишней работы и сталкиваться с неприятными ошибками из-за невнимательности). Проблема же пустых методов, сделанных «на вырост» в том, что их очень легко забыть вызвать и очень трудно эту забывчивость отслеживать.

List

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

Add

Такой метод в репозитории иметь можно. Но не нужно. Проблема всё та же – его легко забыть вызвать и сложно такую забывчивость отследить автоматическими методами.

Я рекомендую вместо этого пользоваться фабричными методами, которые сразу присоединяют созданный объект к change tracker-у. Конечно, можно забыть его использовать и просто воспользоваться конструктором, но это уже проблема другого порядка. Мало того, что теперь требуется совершить две ошибки подряд, а не одну, но главное, что факт вызова конструктора entity не из фабричного метода и не из проекта с тестами – сравнительно несложно обнаружить на этапе проверки качества кода.

GetById

Шо да, то да, этот метод нужен всем репозиториям (наверное). Но городить целую абстракцию из-за этого?

Вашим репозиториям не нужен базовый класс. Наверное.

Нет, если вы этого действительно хотите (или вынуждены), то вы, разумеется, можете его сделать, это ничему не помешает. Но если у вас есть инструмент уровня DbContext, то вы, ПМСМ, не выиграете на этом ничего, что заслуживало бы дополнительной абстракции в инфраструктурном слое. А если в вашем стеке такого инструмента нет, то, возможно, вы просто пользуетесь не тем стеком?

Репозиторий нужно создавать не для каждого Entity

Забегая чуть вперёд – каждый репозиторий работает исключительно с одним типом entity – которая является корнем для своего агрегата. Связь между агрегатом и репозиторием – строго 1 к 1, но, разумеется, только на уровне интерфейса.

Domain Events – мостик к нормальности.

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

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

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

Социальная изоляция агрегатов
Социальная изоляция агрегатов

По смыслу, доменное событие это просто фиксация факта – в процессе обработки сценария использования в доменной модели что-то произошло.

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

Доменные события совсем несложно реализовать
Доменные события совсем несложно реализовать

Проще всего создать пустой интерфейс IDomainEvent и хранить список реализующих этот (пустой) интерфейс объектов в базовом классе ваших entity, вместе с методами манипуляции ими.

Непосредственно в методе завершения транзакции в вашей реализации IUnitOfWork, необходимо воспользоваться сhange tracker-ом для получения всех изменённых entities, составить общий список накопленных доменных событий, после чего обработать их (рекурсивно, разумеется), а уже затем подтверждать транзакцию. В результате все изменения будут сохранены по принципу всё-или-ничего.

Доменные события и слои приложения
Доменные события и слои приложения

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

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

Единственный неочевидный момент в этом паттерне – несмотря на то, что события эти доменные, их обработчики принадлежат application слою, так как иначе становится неудобно обрабатывать несвязанные с доменной логикой побочные эффекты (прежде всего – публикацию Integration Events в используемую вами шину).

Aggregate – лицо доменного слоя

Царь Агрегат Отважный
Царь Агрегат Отважный

Мой любимый тактический паттерн. Именно об него я, в своё время, и сломался, пытаясь разобраться с DDD в первый раз.

Этот паттерн резко выделяется тем, что он не предоставляет никаких возможностей – он их только отбирает. Пока другие паттерны говорят: «решайте эту проблему вот так», агрегат говорит: «решайте свои проблемы как хотите, но не вздумайте делать так».

В чём его суть? Он претендует на роль точки входа в конкретный сценарий и требует, чтобы:

  1. Каждая entity принадлежала какому-либо агрегату, т.е. либо являлась им по факту, либо была частью свойств/коллекций другой entity, входящей в агрегат.

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

  3. Репозиторий работал только и исключительно с агрегатами, т.е. возвращал только ссылки на корни агрегатов. Получать entity в обход агрегатов или агрегаты без части дочерних entity строго запрещено.

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

  • За каким хреном Зачем это вообще надо?

  • Да какого хрена Как с этим работать-то? Это ж не взлетит!

Зачем?

Классический ответ на первый вопрос – соблюдение инвариантов. Классический пример – золотое трио – Order (Aggregate Root), OrderItem и Discount (entities).

Золотое трио
Золотое трио

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

Вы, конечно, можете, как любил говорить мой хороший товарищ, «просто всё сделать правильно». Просто нигде ничего не забыть. Просто при любой работе с Позициями держать в голове необходимость пересчитать Скидку. Просто быть идеальным 24/7, что может быть проще?

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

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

Мой же личный ответ на этот вопрос тесно переплетён с ответом на второй.

Но как?

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

Зачем вам нужно включить entity в два агрегата сразу? Что вы с ней будете делать?

В большинстве случаев ответ сводится к «потому что я не понимаю паттерн Repository и использую его, как DAO», т.е. к попыткам притянуть этот паттерн к решению проблем application layer-а. Чаще всего это построение списков и подгрузка справочников для отображения пользователю. «I-Что-то-там-QueryHandler» вам в помощь.

А если речь идёт об использовании постороннего entity «по делу», для обращения к его логике, то, как правило, вам и тут нужен полноценный агрегат с правильно подгруженными зависимостями, что очень желательно делать уже через его репозиторий, чтобы ничего не забыть.

Немного диалектики

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

Пример другой (удивительно распространённой) ошибки – игнорирование здравого смысла при проектировании агрегата, выражающееся в его огромном размере. Грубо говоря, люди создают агрегат «Директория», добавляют в него коллекцию entity-файлов, а потом удивляются, что это всё как-то медленно работает и винят паттерн в частности и DDD в целом.

Если у вас есть основания предполагать, что в Заказе в вашей системе вполне могут быть тысячи Позиций, то формировать из них агрегат по вышеописанной схеме – очевидно плохая идея. Ключевые маркеры – желание добавить paging, lazy-loading и/или batch-update. Столкнулись с таким ощущением? Значит, это точно не единый агрегат, а несколько разных. Что делать с инвариантами? Обеспечивать их другими методами, через те же доменные события.

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

Value Object – маленький помощник Entity

Технически, Value Object отличается от Entity только тем, что не имеет уникального Id и в хранилище может попадать только как часть entity, но никогда самостоятельно. Value Object имеет смысл проектировать, как неизменяемый объект, но это, строго говоря, необязательно.

Value Object это паттерн, которого могло бы и не быть. Здание DDD не рухнет, если из него вынуть этот кирпичик. Но это не делает его бесполезным – напротив, он может чувствительно украсить вашу жизнь.

Обычно концепцию Value Object объясняют на примере структуры Address и я считаю, что это основная причина того, почему этот паттерн сильно недооценивается разработчиками. Возможность экономии усилий за счёт повторного использования и явное выделение доменной концепции (в виде огромной структуры данных) это хорошо и правильно, но в результате упускается возможность продемонстрировать сильнейшие стороны этого паттерна. Есть примеры  получше.

Distance

В 1999 году НАСА потеряла аппарат Mars Climate Orbiter (и 327 миллионов долларов) из-за ошибки в пересчёте футов в метры.

Этой ошибки можно было бы избежать, если бы расстояние в их программном коде хранилось не в виде числа с плавающей точкой (или как там они его хранили), а в виде Value Object с названием типа Distance и методами типа FromFeets, FromMeters, ToFeets, ToMeters, но главное – без всякой другой возможности приведения к числу без использования метода, в имени которого чётко прописана единица измерения.

Это называется «защитное программирование» и это одна из мощнейших (и наиболее недооценённых) техник написания кода. А ещё это зазывается «Parse, don’t validate». Т.е. при любой возможности отдавайте предпочтение типам данных, в которых просто не может оказаться ничего неправильного (типа даты рождения в будущем времени). Кстати, о дате рождения.

Birthday

Представьте, что вам нужно спроектировать класс Person и вы знаете, что его частью должна быть информация о дате рождения рекомой персоны. Вероятно, могучий инстинкт заставит вас реализовать в классе Person свойство Birthday типа Date (а то и DateTime), ведь это абсолютно логично.

Но если вы подойдёте к проектированию с точки зрения анализа предметной области, то зададитесь вопросом – а зачем вам хранить дату рождения человека, как она будет использоваться?

Возможно, вам ответят, что это нужно делать для соответствия 666-ФЗ Ст.13 – ок, не вопрос. Но вполне возможно, что вам скажут, что это нужно для определения того, является ли пользователь совершеннолетним, его знака зодиака и количества дней, оставшихся до его дня рождения.

В последнем случае вы можете превратить эту дату в Value Object (под названием Birthday) с соответствующими методами, разгрузив тем самым публичный интерфейс Person (а это куда важнее, чем может показаться). Но, если вам хватит опыта, вы можете пойти дальше и задать следующий вопрос – а что если человек не захочет указывать свою дату рождения, мы должны заблокировать ему возможность использовать систему и спровоцировать на указание заведомо неверных данных?

Так в интерфейсе Birthday могут появиться NaN в качестве дней до дня рождения, «секретный агент» в качестве знака зодиака и автоматическое приравнивание таких скрытников к несовершеннолетним.

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

DateTime тут не так, чтобы очень подходит, верно?

Read Model

Если вы серьёзно интересовались техниками DDD, то, вероятно, слышали про Event Storming. В ходе соответствующих сессий вы можете быстро (но очень дорого) выявить ключевые доменные концепции и связи между ними. И одной из этих концепций является Read Model – информация, необходимая для исполнения какого-то действия в рамках какого-то сценария.

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

И если вам кажется, что это подозрительно напоминает DTO, то вам не кажется. Возможно, это не самая изящная часть реальной практики DDD, но она работает, а это же и есть главное, разве нет? Но если вам важнее именно шашечки – вы можете просто не использовать Value Object таким способом.

Domain Service – паттерн, который выжил

Этот паттерн – злобный брат-близнец паттерна Entity. Доменная логика без своего собственного состояния. Он был чрезвычайно популярен до изобретения доменных событий, но в наше время его былая слава заметно потускнела.

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

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

Если вы знакомы с Event Storming, то можете помнить о таком его артефакте, как Policy. К сожалению, в ООП это имя зарезервировано под другой паттерн, поэтому необходимо пояснение.

Когда вы, разговаривая с доменными экспертами о поведении некоего агрегата, задаёте невинный (на ваш взгляд) вопрос о логике связи между вызовом метода и возникновением того или иного доменного события, а в ответ слышите многообещающее «О-о-о-о!!!», предваряющее объяснение в духе «тут играем, тут не играем, а тут мы рыбу заворачивали», то вот эту самую логику принятия решения как раз и имеет смысл вынести в доменный сервис, соответствующий Policy в терминологии Event Storming-а. Дабы разгрузить многострадальный entity и притормозить его превращение в god object.

При этом Policy вполне может (и, скорее всего, будет) использовать в своей логике и другие агрегаты, так что тут применение именно Domain Service как нельзя более уместно.

Почему бы просто не использовать доменные сервисы в качестве основного метода описания домена? Дело в том, что у них, каким бы странным это не показалось, нет своего состояния (иначе они были бы entity). Следовательно, доменные сервисы вынуждены полагаться на состояние других сущностей, иначе им, в общем случае, трудно выполнить какую-то полезную работу.

Но если другая сущность (entity) имеет состояние, то почему бы этой сущности заодно не реализовывать методы использования этого состояния самой, не играя в испорченный телефон и не раскрывая вовне ненужных подробностей, которые захламляют доменную модель и провоцируют использовать их разными предосудительными способами? Л-логика. Поэтому анемичная модель в DDD отстой работает хуже классической (хотя всё равно работает, почему нет-то?).

Когда-то давно в каком-то учебнике по ООП мне попался пример того, что принцип «держите свои методы поближе к данным» работает далеко не всегда. Если мы переводим деньги с одного счёта на другой, то какому из них принадлежит метод перевода, отправителю или получателю? Правильный ответ был – классу Transfer, отвечающему за эту операцию. Впрочем, по забавной иронии, на практике такой класс тоже будет entity, а не сервисом.

Иными словами – не нужно стесняться использовать доменные сервисы, когда вам это кажется уместным, но если их количество в вашем проекте становится сопоставимым с количеством entities, то вы, вероятно, что-то делаете не так.

Как всё это (может) работать вместе

Disclaimer – это действительно может так работать (у меня же работает), но это не значит, что нет других (и, вполне возможно, лучших) способов делать то же самое. Они есть всегда.

Итак, вы каким-то образом выделили ключевые агрегаты, доменные события и команды, исполнение которых заставляет агрегаты генерировать доменные события (да, это толстый намёк на Event Storming). Вы кластеризовали агрегаты по принципу слабой зацеплённости и сильной связанности и, с учётом когнитивной сложности, разбили домен на поддомены, чтобы выделить контексты.

Вы можете создать отдельный проект (solution) и отдельный проект (папку) в git для вашего контекста. Раздельный CI/CD и минимизация конфликтов слияния бывают исключительно полезны, но решать, разумеется, вам.

Но это же куча работы

Если вы делаете это не в первый раз – у вас есть коллекция seed классов, с которыми вы сразу можете начать работать. Возможно, у вас даже есть шаблон проекта с wizard-ом, в этом случае я искренне завидую вашей организованности. В противном случае – лучше взять какой-то пример в качестве источника вдохновения и доработать его под ваши нужды (в мире .net есть открытый проект под названием eShop, он достаточно близок к реальности).

Лет десять назад создание такого шаблона было достаточно серьёзной работой, доступной только крупным и зрелым коллективам. Сейчас (при должной доли умения) вы можете слепить его из open source за пару недель силами одного человека.

Каждая команда воплощается в подпроекте, ответственном за application layer, каждый агрегат становится отдельной папкой в подпроекте, ответственном за доменный слой. В каждую папку помещаются все entities агрегата, интерфейс репозитория, определения доменных событий, domain services (если нужно) и value objects (по мере выявления).

Очень приблизительно так
Очень приблизительно так

На этом этапе вы можете заметить, что ваши агрегаты начали напоминать микросервисы. У них есть публичный api (открытые методы aggregate root), хранилище (repository) и событийный интерфейс. Отличия, разумеется, тоже есть (один агрегат вполне может принимать другой в качестве параметра метода, к примеру), но в целом изоляция агрегатов друг от друга остаётся очень высокой. Такие агрегаты очень удобно тестировать с помощью unit-тестов, все их зависимости уже изначально разорваны.

В ваших интересах по максимуму унифицировать обработку команд в application layer вне зависимости от источника происхождения (rest/gRPC или Integration Events). Это может потребовать некоторой дополнительной работы в infrastructure layer (такой, как прокидывание контекста для OpenTelemetry), но оно того стоит. Обработчики доменных событий, однако, лучше с ними не унифицировать, так как они всегда вызываются из другого сценария и не нуждаются в куче обвязки, включая управление транзакциями.

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

Что касается побочных эффектов исполнения команд (т.е. всего, что не сводится к изменениям состояния ваших агрегатов), то их можно поделить на два типа – интеграционные события, которые нужно отправить в шину и прочие эффекты (вызов rest-сервиса, отправка e-mail и т.п.).

Интеграционные события я настоятельно рекомендую отправлять через паттерн Outbox. Он предполагает, что любое событие, которое вы пытаетесь отправить в шину, сначала сохраняется в базе данных. Таким образом все изменения состояния всех ваших агрегатов и намерения отправить все сообщения в шину будут зафиксированы в одной транзакции (опять же – всё-или-ничего) и только потом будет осуществлена попытка эти сообщения отправить по настоящему.

Это позволяет подступиться к решению одной из наиболее страшных проблем создания распределённых систем – eventual consistency, что должно означать «согласованность в конечном итоге», но на практике слишком часто означает «согласованность, если всё пойдёт хорошо». Да, обработка интеграционных событий всё ещё может пойти не так, но с паттерном outbox вы хотя бы можете гарантировать, что у обработчика этого события будет шанс облажаться всё сделать, как следует.

Что делать с побочными эффектами, не сводящимися к интеграционным событиям? Свести их к интеграционным событиям и нет, я не издеваюсь. Вы не можете заменить вызов чужого rest-api  отправкой события в шину, но вы можете разнести обработку пользовательской команды и логику отправки POST-запроса по разным транзакционным контекстам с помощью промежуточного, внутреннего интеграционного события. И уже в его обработчике сделать всё то, что вы сделать хотите (повторные вызовы при сбое, таймауты, инициация логики компенсации при сбое и т.п.).

Как насчёт запросов к другим контекстам и сторонним сервисам? Я ни разу не пурист, но это тот редкий случай, когда я всеми конечностями за CQRS, т.е. чёткое разделение запросов на модификацию (интеграционные события) и на чтение. Но и в последнем случае я настоятельно рекомендую тоже попытаться свести вопрос к интеграционным событиям. Нет, честное слово, я не издеваюсь.

Дело в том, что если метод вашего агрегата требует данных из другого контекста, то возникает резонный вопрос – а действительно ли это данные другого контекста? А может, всё-таки этого? Да, мастер-системой для этих данных, несомненно, является другой сервис, а вам нужна только readonly-реплика. Но действительно ли вам нужно каждый раз получать эти данные от другого сервиса, по сети, завязываясь на его работоспособность в моменте? Или лучше будет затащить к себе нужный кусочек этих данных, чтобы получить возможность делать join в ваших «I-Что-там-QueryHandler»-ах?

Смотрите по ситуации (как часто данные обновляются, каков их объём, как часто и для чего они нужны). В любом случае, нет никаких причин тащить метод получения этих данных в доменный слой – всегда можно сделать это из application layer для последующей передачи в доменный с помощью anti-corruption layer.

Вишенка на торте – интеграционные тесты. Каждый важный инвариант каждого api метода и каждого обработчика интеграционных событий желательно покрыть своим тестом (от точки входа,  через пайплайны, через все слои до базы данных и отправки интеграционных событий). Опять же, если вы используете .net, могу порекомендовать связку из WebApplicationFactory, TestContainers и Respawn, как отлично показавшую себя на практике. Но для встраивания их в pipeline придётся использовать docker-in-docker (например, kubedock).

Заключение

Domain Driven Design это не набор абстрактных догм, а живая, развивающаяся практика, которая в прямых руках позволяет эффективно разрабатывать эффективно функционирующие, современные, распределённые приложения.

И если предметная область, над которой вы работаете, достаточно сложна, а проект достаточно долгоживущий (2+ года активного развития), то этот подход может оказаться оптимальным по соотношению цена/качество, не говоря уже о том, что на определённом уровне сложности задачи он может оказаться единственно возможным выбором (если вас, конечно, интересует результат).

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

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

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


  1. Kerman
    04.08.2025 08:13

    зачем вам возвращать IQueryable из методов вашего репозитория?

    Вот зачем

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

    orders.Where(o => 
                 o.Created.Year < fiveYearsAgo 
                 && o.State = OrdrerState.Coordination
                 && o.AccountManager.Fired);


    1. holgw
      04.08.2025 08:13

      Я так понял, что по DDD нужно (рекомендуется) иметь отдельный метод под каждое назначение:

      interface IOrdersRepository
      {
        Order[] GetOrdersOfFiredManagers(int year, OrdrerState state);
        Order[] GetOrdersSortedByEndDate(int year);
        Order[] GetOrdersByManager(string accountManagerName);
      }
      


      1. Kerman
        04.08.2025 08:13

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

        В любом случае, если рассматривать IQueryable как инструмент (коим он и является), то с ним у меня есть возможность писать в логике условия, а без него - нет.


        1. holgw
          04.08.2025 08:13

          если заинлайнить критерии отбора в логику доменной сущности или какого-нибудь репорта, разве это станет меньше DDD? Я думаю нет.

          По мнению автора статьи -- да, так мы идеологически отдаляемся от DDD. Потому что репозиторий при таком подходе недостаточное явно отражает доменные концепции ¯\(ツ)

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

          Я вообще рассчитываю, что в тред придет адепт DDD и расскажет как с этой проблемой бороться.


          1. VladimirFarshatov
            04.08.2025 08:13

            Сам фильтр критериев отбора сам по себе не может быть "набором данных" и входить в DDD как поддомен или что-то ещё? Что этому мешает? Как последовательно развивать фильтрацию от "выбери по ИД" до "фасетного и таргетированного поиска"?


            1. holgw
              04.08.2025 08:13

              Эти вопросы скорее к автору статьи. Ради ответов на них я и вписался в дискуссию.


  1. OlegZH
    04.08.2025 08:13

     Domain Driven Design ...

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

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

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

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

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


  1. totsamiynixon
    04.08.2025 08:13

    Вспоминаем ранее пройденное – репозиторий это паттерн доменного слоя и выражает он доменные концепции.

    Например, какие? Кто является главным потребителем Repository? Application Layer. Соответственно и абстракция должна быть удобной для данного слоя. Но если пойти еще дальше, то у репозитория может быть только один метод на получение - Get(Identity id). И метод записи уже зависит от того, как реализован UoW. То, как вы выбираете данные, отношения к DDD не имеет, хоть SQL запросы прямо из вьюхи делайте.

    Вашим репозиториям не нужен IQueryable (и паттерн Specification тоже)

    К счастью, для связки .net + ef core есть в меру кривое почти красивое решение – использовать Expressions 

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

    List

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

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

    Domain Events – мостик к нормальности.

    Единственный неочевидный момент в этом паттерне - ...

    Это далеко не самый неочевидный момент в этом паттерне. Самый неочевидный момент, это то, что все обработчики доменных событий выпоняются синхронно. Это ок если все ваше приложение живет InMemory. Ваш RAM выступает в качестве базы данных, тразакция это запись в RAM. Но если это веб-приложение или вроде того, этот эффект ловины приведет к тому, что накопится такое количество Aggregate для коммита, что вероятность ошибки транзакции из-за оптимистичной конкурентности (а я надеюсь она у читателя настроена) стремится к бесконечности. По схеме как будто бы не понятно, в какой момент происходит транзакция. Описанный мой флоу такой command handler -> uow -> event handler -> uow -> event handler -> uow -> коммит транзакции. Если флоу такой: command handler -> uow -> транзакция -> event handler ... , то возникает проблема гарантий доставки ивента. Т.е. у комманды A есть сайд эффект B, но если база отвалилась во время выполнения сайд эффекта B - то система зависнет в неконсистентном состоянии. Поэтому я предпочитаю интеграционные события. Доменные у меня используются только для аудит логов и заполнения Outbox.

    Дальше пока устал читать и писать, может позже дополню :)


  1. OlegZH
    04.08.2025 08:13

    Entity – царь паттернов

    А что это такое? По моим узким представлениям, это т.н. сущности, то есть — набор базовых понятий, с которыми мы имеем дело: пользователи, контрагенты, заказы, товары, склады и т.д. и т.п.

     Диаграмма хорошо спроектированных классов всегда вызывает эстетическое наслаждение, она элегантна и лаконична.

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

     Если она относительна проста - эти дополнительные усилия никогда не окупятся. И в этом случае вам не просто не нужны хорошо проработанные entities – вам вообще не нужно отделять доменный слой от application layer, это всё – лишняя работа, целиком. Но если предметная область окажется достаточно сложна, то попытка обойтись более простыми методами может привести к тому, что ваш проект со временем флопнется...

    Может быть, стоит всегда отделять один слой от другого?

    Давайте посмотрим на этот вопрос, собственно, с точки зрения разработки программного обеспечения. У нас есть: (1) сущности предметной области; (2) сущности языка программирования (всякие там структуры); и (3) те сущности, которые мы выстраиваем на базе сущностей (2), чтобы воспроизвести сущности (1).

    Предположим, у нес есть таблица покупок Покупки, каждая строка которой описывает отдельный купленный товар (наименование, цена, количество и стоимость). Покупки, сделанные за одно посещение (одного и того же магазина), естественным образом группируются в блоки, и мы можем ввести новую сущность ПосещениеМагазина (дата и время, название магазина). В реляционной базе данных естественным образом возникают две таблицы: основная (содержащая первичные ключи) и подчинённая (ссылающаяся на основную при помощи вторичных ключей). Мы понимаем, что стоимость — это вычисляемый столбец, который можно не хранить в базе данных, но мы можем воспринимать таблицу покупок как документальное выражение результатов действий покупателей. Далее, цены бывают разные. Мы фиксируем в таблице ту цену, по которой нам фактически продали купленный нами товар. Но есть разные цены. Есть базовая цена товара (и, при этом, на определённую дату!), а есть цена со скидкой, разные скидки (сезонные, по акции, накопительные и т.д. и т.п.). Мы можем усложнить таблицу покупок, и вести параллельный учёт различных цен, определяя фактический размер скидки простыми запросами к данной таблице. Если посмотреть в сторону магазинов, то у магазинов есть адреса (ещё одна сущность!), а это значит, что нам нужен справочник адресов. Не очень буйная фантазия представляет этот справочник как иерархический, так что при выборе адреса можно выбирать требуемый уровень (город/улица/дом/корпус/строение). Попутно заметим, что в каждом магазине может быть принята своя система наименования товаров, нам же нужны исходные данные, а это значит, что нам нужен такой же иерархический справочник Номенклатура. И ещё. Посещение магазина — это некое точечное событие. Мы может иметь единую таблицу, где описываются такие события, и тогда мы таким же единым образом будем описывать и Ваше посещение магазина, и приезд к Вам курьера, и помещение на склад продукции, и приход в научно-исследовательский институт государственного задания, по которому этот самый НИИ получает бюджетное финансирование. И это всё суть какие-то события, а это снова отдельная сущность. И если Вы её выделяете, и тратите дополнительные усилия на реализацию этой сущности (элемент семантики), то Вы получаете мощный механизм управления своими данными. Таким образом, важнейший вопрос — это вопрос о том, а какие на самом деле должны быть сущности, чтобы отобразить домены (сущности) предметной области, на сущности (классы/объекты) языка программирования.


  1. michael_v89
    04.08.2025 08:13

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

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


  1. VladimirFarshatov
    04.08.2025 08:13

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


  1. OlegZH
    04.08.2025 08:13

    public class Client : Entity
    {
        ...
    }

    У меня такой вопрос: а почему мы обнаруживаем код, явным образом описывающий логику работы приложения? И что это за код? Это код конечного реализуемого приложения? Почему в коде явным образом указывается условие поиска, почему это условие не фигурирует в пользовательском интерфейсе (как один из вариантов)?