Всё мне позволительно, но не всё полезно; всё мне позволительно, но ничто не должно обладать мною.
Апостол Павел
Если вы никогда не интересовались паттернами 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 в первый раз.
Этот паттерн резко выделяется тем, что он не предоставляет никаких возможностей – он их только отбирает. Пока другие паттерны говорят: «решайте эту проблему вот так», агрегат говорит: «решайте свои проблемы как хотите, но не вздумайте делать так».
В чём его суть? Он претендует на роль точки входа в конкретный сценарий и требует, чтобы:
Каждая entity принадлежала какому-либо агрегату, т.е. либо являлась им по факту, либо была частью свойств/коллекций другой entity, входящей в агрегат.
Каждая entity принадлежала одному и только одному агрегату, т.е. если entity является частью одного агрегата, то уже не может быть частью другого.
Репозиторий работал только и исключительно с агрегатами, т.е. возвращал только ссылки на корни агрегатов. Получать 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 в моих руках не совсем такой, каким он был описан у Эванса, а в ваших руках будет не таким, как у меня. Вы вполне можете не согласиться с рядом высказанных мною тезисов, но если хотя бы часть из тех соображений, которые я изложил в этой статье, пригодятся вам в вашей практике разработки приложений, я буду считать это несомненным успехом.
Практикуйте, совершенствуйтесь и не забывайте делиться наиболее удачными находками с коллегами, потому что это, в конечном итоге, единственный способ двигать индустрию вперёд.
Комментарии (56)
OlegZH
04.08.2025 08:13Domain Driven Design ...
Пытаюсь перевести на русский язык. Получается что-то вроде разработки, определяемой предметной областью.
Если вы автоматизируете работу предприятия, существующая организационная структура всегда будет прекрасной стартовой точкой.
Я предполагаю, что одну и ту же организационную структуру можно отобразить весьма различным способом. И это будет существенным образом зависеть от того, что и как будет называться нами в этой системе.
И, в большинстве случаев, на макро-уровне будет крайне близка к финальной, потому что, скорее всего, менять организационную структуру под ваши гениальные озарения никто не станет, а архитектура, игнорирующая организационную структуру, изначально нежизнеспособна
Это, скорее, философский вопрос. С моей точки зрения, смысл информационных технологий заключается именно в том, чтобы предложить новую более удобную организационную структуру. Но, это, конечно, с моей очень узкой точки зрения.
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.
Дальше пока устал читать и писать, может позже дополню :)
voroninp
04.08.2025 08:13Доменное событие может и асинхронно обрабатываться, аналогично integration event, главное, чтоб без пересечения границ контекста.
totsamiynixon
04.08.2025 08:13Тогда без гарантий выполнения сайд эффекта. А если с гарантиями, то доменное событие превращается в интеграционное.
mrozov Автор
04.08.2025 08:13Кто является главным потребителем Repository? Application Layer.
Да. Равно как основным потребителем Агрегатов. Это ровно то же самое - удобный интерфейс для работы с доменным слоем, не больше, не меньше
Опять же, Aggregate это граница транзакционности. Если вы получаете несколько агрегатов через List
или аналоги, а потом обновляете их все вместе - вы нарушаете данные границыНет, никакие границы тут, разумеется, не нарушаются, да и с чего бы вдруг? Я понимаю, что это ваша трактовка, но в ней же нет никакого практического смысла (с такой ошибкой в восприятии DDD я, к слову, ещё не сталкивался).
Транзакционные границы Агрегата не имеют прямого отношения к транзакциям Хранилища, это вообще про другое - инварианты Агрегата не могут нарушаться из-за частичного обновления, но сохранять или не сохранять несколько Агрегатов вместе - совершенно нормально и ничему, конечно же, не противоречит. А если бы вдруг был такой принцип - так отправляйте его на свалку, он же явно вредительский.
Самый неочевидный момент, это то, что все обработчики доменных событий выпоkняются синхронно
Я не знаю, что конкретно вы подразумеваете под синхронным выполнением, видимо, сохранение данных в рамках одной транзакции. Да, разумеется, и это главная фича доменных событий.
Может ли это привести к ошибкам при параллельном сохранении разных объектов? В зависимости от вашей схемы это может быть как вполне вероятно, так и крайне маловероятно.
Если это в вашем конкретном случае вероятно, значит, вам следует разорвать транзакционный контекст на несколько с помощью интеграционных событий и паттерна outbox. Если это (а как правило так и есть) маловероятно - просто обеспечьте логику retry на исполнение команд при обнаружении исключений, связанных с этим, это легко реализуется и встраивается в command execution pipeline.
totsamiynixon
04.08.2025 08:13Нет, никакие границы тут, разумеется, не нарушаются, да и с чего бы вдруг?
Вот действительно. Например, мы хотим архивировать аккаунт юзера в телефонии. Берём аккаунт, берём его историю звонков и архивируем. Что может пойти не так?
Пока архивирали аккаунт, кто-то позвонил, счетчик числа пропущенных звонков на аккаунте обновился, транзакция упала, т.к. не прошла оптимистичная конкурентность по аккаунту. Или вы можете ее проигнорировать и получить lost write - решать вашему бизнесу, только не мешало бы дать ему знать о возможности такого кейса.
Пока архивировали аккаунт, появилось новое непрослушенное сообщение на голосовую почту. Помимо того же кейса со счётчиком, появляется так же новая голосовая почта, которая не будет зархивированна. Система находится не в консистетном состоянии. Критично ли это - нужно спросить у бизнеса, и подготовить план возврата системы в клгстстентное состояние (можно ли будет повторно за архивировать юзера или конкретную запись, позволяют ли это ваши бизнес правила и ТД).
Ваша система масштабируется, юзер хранится на одном инстансе одной базы, а история в другом; или например юзер в MySQL а история в elastic search. В вашем подходе абстракция течет, говоря о том, что строго настрого для выполнения операции архивации пользователя он сам и его история должны принадлежать к одному UoW. Т.е. не получится партиционировать историю звонков по годам. Строго говоря, в домене такого ограничения нет. Когда появится необходимость оптимизировать хранилище (инфраструктуру), записи за 2025 год хранить на теплом инстансе, а за 2023 в холодном сторадже, придется объяснить бизнесу, что для этого придется переписать кусок логики.
А теперь представьте архивацию целой группы юзеров, шанс отказа из-за оптимистической конкуренции или появления lost write растет пропорционально. Не говоря уже о том, что в вашем исполнении партиционирование становится на порядок более сложным. (Теперь все архивируемые юзера должны гарантированно находится в одном партишене, текущая абстракция).
Поэтому граница агрегата это как раз граница транзакционности. Можно вообще всю систему спроектировать как один агрегат, и тогда консистентность системы будет максимальная, но перформанс минимальный. Поэтому агрегаты дробят, осознанно снижая консистентность в угоду перфомансу и масштабируемости.
Да, разумеется, и это главная фича доменных событий.
Жизнь заставит вас пересмотреть свои взгляды, когда наиграетесь в DDD на локалхосте и начнёте масштабироваться. Когда окажется, что одни данные в MySql, другие в elastic, mongo и тд, синхронные макеты вам не помогут. А если начнёте с одной базы и синхронных макетов, готовьтесь переписать под приложения, чтобы внедрить поддержку асинхронности на должном уровне.
Если это в вашем конкретном случае вероятно, значит, вам следует разорвать транзакционный контекст на несколько с помощью интеграционных событий и паттерна outbox
В этом месте корабль DDD затонул из-за дырявой абстракции. В этом месте вы явно объявляете, что раз модуль А ловит событие модуля Б синхронно, то одно из двух: либо есть риски неконсистетного состояния, либо ваш UoW обслуживает оба модуля. Во втором случае это текущая абстракция. Модуль А независимым от модуля Б, но по факту неявно они делят контекст и без одного не может быть другого. И когда у вас произойдет смена хранилища из-за разных требований к масштабированию модулей (деманд модуля Б растет, а модуля А нет) - придется лопатить весь апликейшен код.
Текущая абстракция = при изменении инфраструктуры, неизбежно изменение application code.
Так что если вы добавите в свое представление о модулях (BC) требование, что у каждого BC может быть свое хранилище и разные требования к масштабируемости, вы поймёте, что никакой функции для получения данных в репозитории, кроме GetById быть не должно, иначе абстракция течет, делая техническую реализацию явной.
Я окей с тем, чтобы использовать синхронные ивенты, давать абстракциям мягко и иногда даже жёстко подтекать, если человек отдает себе отчёт, что он делает: вот эти модули масштабироваться не будут (какие-то разовые конфигурации), а эти будут: но в принципе они простые и если что быстро перепишем; а вот это быстро не перепишем и тд.
michael_v89
04.08.2025 08:13- Опять же, Aggregate это граница транзакционности.
- Нет, никакие границы тут, разумеется, не нарушаются, да и с чего бы вдруг?https://martinfowler.com/bliki/DDD_Aggregate.html
Transactions should not cross aggregate boundaries.
OlegZH
04.08.2025 08:13Entity – царь паттернов
А что это такое? По моим узким представлениям, это т.н. сущности, то есть — набор базовых понятий, с которыми мы имеем дело: пользователи, контрагенты, заказы, товары, склады и т.д. и т.п.
Диаграмма хорошо спроектированных классов всегда вызывает эстетическое наслаждение, она элегантна и лаконична.
А почему это должна быть именно диаграмма классов? Тут ещё и вопрос в том, на каком этапе эта диаграмма используется. Можно себе представить, что эта диаграмма может использоваться на этапе разработки для общения с экспертами в выбранной предметной области, но на этапе реализации эта диаграмма может быть отображена на что-то совсем другое.
Если она относительна проста - эти дополнительные усилия никогда не окупятся. И в этом случае вам не просто не нужны хорошо проработанные entities – вам вообще не нужно отделять доменный слой от application layer, это всё – лишняя работа, целиком. Но если предметная область окажется достаточно сложна, то попытка обойтись более простыми методами может привести к тому, что ваш проект со временем флопнется...
Может быть, стоит всегда отделять один слой от другого?
Давайте посмотрим на этот вопрос, собственно, с точки зрения разработки программного обеспечения. У нас есть: (1) сущности предметной области; (2) сущности языка программирования (всякие там структуры); и (3) те сущности, которые мы выстраиваем на базе сущностей (2), чтобы воспроизвести сущности (1).
Предположим, у нес есть таблица покупок Покупки, каждая строка которой описывает отдельный купленный товар (наименование, цена, количество и стоимость). Покупки, сделанные за одно посещение (одного и того же магазина), естественным образом группируются в блоки, и мы можем ввести новую сущность ПосещениеМагазина (дата и время, название магазина). В реляционной базе данных естественным образом возникают две таблицы: основная (содержащая первичные ключи) и подчинённая (ссылающаяся на основную при помощи вторичных ключей). Мы понимаем, что стоимость — это вычисляемый столбец, который можно не хранить в базе данных, но мы можем воспринимать таблицу покупок как документальное выражение результатов действий покупателей. Далее, цены бывают разные. Мы фиксируем в таблице ту цену, по которой нам фактически продали купленный нами товар. Но есть разные цены. Есть базовая цена товара (и, при этом, на определённую дату!), а есть цена со скидкой, разные скидки (сезонные, по акции, накопительные и т.д. и т.п.). Мы можем усложнить таблицу покупок, и вести параллельный учёт различных цен, определяя фактический размер скидки простыми запросами к данной таблице. Если посмотреть в сторону магазинов, то у магазинов есть адреса (ещё одна сущность!), а это значит, что нам нужен справочник адресов. Не очень буйная фантазия представляет этот справочник как иерархический, так что при выборе адреса можно выбирать требуемый уровень (город/улица/дом/корпус/строение). Попутно заметим, что в каждом магазине может быть принята своя система наименования товаров, нам же нужны исходные данные, а это значит, что нам нужен такой же иерархический справочник Номенклатура. И ещё. Посещение магазина — это некое точечное событие. Мы может иметь единую таблицу, где описываются такие события, и тогда мы таким же единым образом будем описывать и Ваше посещение магазина, и приезд к Вам курьера, и помещение на склад продукции, и приход в научно-исследовательский институт государственного задания, по которому этот самый НИИ получает бюджетное финансирование. И это всё суть какие-то события, а это снова отдельная сущность. И если Вы её выделяете, и тратите дополнительные усилия на реализацию этой сущности (элемент семантики), то Вы получаете мощный механизм управления своими данными. Таким образом, важнейший вопрос — это вопрос о том, а какие на самом деле должны быть сущности, чтобы отобразить домены (сущности) предметной области, на сущности (классы/объекты) языка программирования.
voroninp
04.08.2025 08:13Entity — это нечто, обладающее идентичностью и позволяющее различать объекты (не в ООП смысле), все остальные атрибуты которых одинаковы. Какой доменный концепт обладает идентичностью, зависит уже от бизнеса.
Эванс делает акцент на жизненном цикле и существовании во времени.
michael_v89
04.08.2025 08:13исполнение этого метода по смыслу требует обработки некоего побочного эффекта. Это может быть обращение к внешнему сервису, отправка email, запуск другого сценария с использованием другого агрегата
составить общий список накопленных доменных событий, после чего обработать их, а уже затем подтверждать транзакцию.Угу, и обращение к внешнему сервису произойдет до того, как сущность сохранена. Туда отправятся данные вместе с id сущности, он сохранит их в свою базу, потом решит запросить дополнительную информацию по id, а у нас такой сущности нет, потому что при обработке следующего доменного события произошла ошибка, и транзакция откатилась. Сначала оплачиваем заказ, потом сохраняем информацию, что он оплачен, а уже потом рассказываем всем интересующимся, что он оплачен.
VladimirFarshatov
04.08.2025 08:13Отличная статья по архитектурной разработке. Кое-что вспомнилось из забытого .. положил даже в закладки себе, пусть будет. Это надо перечитывать на несколько раз вдумчиво и с перерывами. Плюсик в карму автору.
OlegZH
04.08.2025 08:13public class Client : Entity { ... }
У меня такой вопрос: а почему мы обнаруживаем код, явным образом описывающий логику работы приложения? И что это за код? Это код конечного реализуемого приложения? Почему в коде явным образом указывается условие поиска, почему это условие не фигурирует в пользовательском интерфейсе (как один из вариантов)?
mrozov Автор
04.08.2025 08:13Этот код описывает доменную логику определения того, является ли клиент VIP или нет.
Но при этом он оформлен в таком техническом виде, что портит открытый интерфейс entity, что плохо.Зачем? Чтобы избежать необходимости выноса логики домена в реализацию репозитория или её дублирования в отдельном классе, что, собственно, не просто не лучше, а ещё хуже. Компромисс.
Если же это условие не нужно доменной логике, то и делать ему в ней, разумеется, тем более нечего.
Об чём я и писал довольно подробно.
akabrr
04.08.2025 08:13Я правильно понимаю, что в 1С ссылочные объекты это Entity, менеджеры объектов - Repository, а движения документов - Value Object?
mrozov Автор
04.08.2025 08:13я совершенно не разбираюсь в 1С и ничего не могу сказать по этому поводу.
интуитивно - очень в этом сомневаюсь, но моя компетенция в этом вопросе строго нулевая
OlegZH
04.08.2025 08:13Birthday ...
Хороший пример.
В базах данных традиционно использовалось понятие домена (уж, извините) в смысле диапазона допустимых значений. Это значит, что при описании таблицы мы ложны указывать домен, а не конкретный тип. У каждой сущности может быть довольно богатое семантическое окружение. Домен включает в себя способ управления хранимыми значениями. В Вашем примере, речь идёт о сущности, которая позволяет определённым образом упорядочить пользователей по некоторой шкале. При этом, в различных ситуациях могут использоваться различные подходы/способы. Вот для реализации этих способов и вводится более "толстый" столбец, который уже не сводится какому-то одному типу данных вроде даты.
Ещё один такой пример — это "Фамилия Имя Отчество". В различных ситуациях, требуются различные представления (как полные, так и сокращённые).
voroninp
04.08.2025 08:13За DbContext отдельное спасибо.
Кстати, я все-таки делаю базовый класс, который эксёпшены EFCore'а переделывает в доменные ошибки, помимоFindById
еще выставляюListByIds
иListAll
потому что на практике иногда удобно забить на масштабируемость и фигачить батчи с агрегатами в одной транзакции.voroninp
04.08.2025 08:13И да, я бы на первое место всё-таки ставил агрегат, а репозиторий — это его скромный слуга.
И главный акцент на поведении. Моделировать поведение, состояние будет производной. Беда EF в том, что люди сразу мыслят ДТОхами, замапленными на таблицы.mrozov Автор
04.08.2025 08:13И да, я бы на первое место всё-таки ставил агрегат, а репозиторий — это его скромный слуга.
это чисто методологический вопрос - как лучше объяснять
мне кажется, что объяснить смысл агрегата без понимания (или, ещё хуже, с неправильном пониманием) репозитория - намного сложнее, но это просто моё мнение
totsamiynixon
04.08.2025 08:13А что там с db context?
По поводу
ListAll
: я обычно делаю какой-то отдельный интерфейс для этого, если прямо очень надо. Так основной класс репозитория не засоряется и надо ещё поискать этот интерфейс под конкретную команду (обычно это какой-то cleanup или архивация). Проблема очевидная, но неявная (не отражено в домене) - сущности должны быть на одном партишене.Но обычно все-таки я делаю вызов снаружи: есть квери, который возвращает список сущностей (чаще всего с пагинацией) и потом вызываю для каждой сущности команду. Это лишает разработчика иллюзии, что все транзанкционно и если кто-то добавит новую запись в процессе обработки, она обработана не будет.
Если это ок - ну ок. Если нет - явные / неявные локи, очереди и тд.
voroninp
04.08.2025 08:13Да я просто устал объяснять коллегам, что тянуть голый DbContext, обосновывая тем, что он, якобы уже репа, сомнительная идея.
При этом они настойчиво мокают IQueryable и DbSet в тестах. Уж лучше тогда интеграционные тесты с TestContainers гонять, чем тестировать моки:-)
onets
04.08.2025 08:13Побольше бы примеров кода и структуры солюшена.
Например что такое QueryHandlers в Application слое и чем они отличаются от DbQueryHandlers в инфраструктуре?
А что за Application Services и чем отличаются от domain сервисов? Что скажите насчет UseCase и UserStory?
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);
А чем это отличается от спецификации? От перекладывания кода из одно места в другое - суть же не меняется.
Как вы создаете сущность из DTO, которое пришло из контроллера? Вот тут мой коммент где я уже собрал несколько вариантов https://habr.com/ru/articles/931866/comments/#comment_28640604
mrozov Автор
04.08.2025 08:13Например что такое QueryHandlers в Application слое и чем они отличаются от DbQueryHandlers в инфраструктуре?
QueryHandlers ~= CommandHandler
DbQueryHandlers - метод исполнения эффективных запросов на чтение
QueryHandlers скорее всего будут использовать DbQueryHandlers, но это не точноА что за Application Services и чем отличаются от domain сервисов?
классы-сервисы могут быть и в application-слое, почему нет?
логики в этом слое вообще немало, просто это не та логика, которую вы бы захотели обсуждать с дедком 82 лет от роду, который называет компьютеры ЭВМЧто скажите насчет UseCase и UserStory?
Скажу, что это прекрасные термины, не имеющие особого отношения к тактическим паттернам DDD
А чем это отличается от спецификации?
Тем, что вам не приходится изобретать свой язык, конструировать его выражения в одном месте и деконструировать его в другом, я полагаю :)
И при этом у вас не возникает дублирование, но метод IsVip по прежнему - часть вашего доменного слоя.
Как вы создаете сущность из DTO, которое пришло из контроллера?
в application layer, в обработчике события, вызываю фабричный метод и сохраняю транзакцию
опять же, см. раздел про Read Model, некоторые методы применения Value Object, на мой взгляд, вполне допустимы
michael_v89
04.08.2025 08:13Просто при любой работе с Позициями держать в голове необходимость пересчитать Скидку. Просто быть идеальным 24/7, что может быть проще?
Или вы можете применить паттерн агрегат по его прямому назначению и оставить возможность менять коллекцию Позиций только через его интерфейсПодмена понятий и логическая манипуляция.
Если эта логика будет в агрегате, то "любая работа с Позициями" будет в "его интерфейсе", и точно так же при создании или изменении любого метода агрегата, который работает с позициями, надо "держать в голове необходимость пересчитать Скидку", то есть "быть идеальным 24/7".Почему-то многие сторонники DDD не понимают, или делают вид, что хоть с логикой в сущности, хоть не в сущности, количество методов, которые ее изменяют, будет одинаковым. Потому что оно идет из бизнес-требований.
totsamiynixon
04.08.2025 08:13Хз почему мюнусят. Я сейчас конкретно в веб приложениях, когда работаю с сущностями, у которых достаточно простое поведение, использую сущности как хранилище состояний без инвариантов. А сами инварианты, стейт транзишены и регистрацию событий в outbox пишу прямо в command handler. Да, теряется гибкость использования тактических паттернов. Но модули простые, и тащить сложности их реализации в этот код не хочется. Поэтому у меня микс - самое сложное ядро с насыщенным доменом, различными реализациями стратегий, где сложность богатой доменной модели оправдана - использую ее. В случаях, когда это нафиг не надо - использую DbContext напрямую вместе с анемичной моделью. Самое страшное, что может произойти с таким модулем, это смена персистенса на такой, который не удастся подружить с DbContext на уровне конфигурации. И то, я такой модуль за пол дня перепишу на другой движок (Mongo, DynamoDB и тд). Особенно если он покрыт тестами.
Так что с в целом я с комментарием согласен. На количество стейт транзишенов не влияет богатая у вас модель или анемичная. Инварианты может соблюдать там и там.
michael_v89
04.08.2025 08:13Репозиторий это не метод сокрытия механизмов работы с базой данных
Именно метод сокрытия таких механизмов. Не всех, а некоторых.
https://martinfowler.com/eaaCatalog/repository.html
In such systems it can be worthwhile to build another layer of abstraction over the mapping layer where query construction code is concentrated.
A Repository mediates between the domain and data mapping layers, acting like an in-memory domain object collection.
Client objects construct query specifications declaratively and submit them to Repository for satisfaction.Conceptually, a Repository encapsulates the set of objects persisted in a data store and the operations performed over them, providing a more object-oriented view of the persistence layer.
Причём тут фильтры, сортировки и страницы, какое отношение они имеют к доменному слою и репозиториям
При том, что фильтры, сортировки и страницы обсуждаются в бизнес-требованиях. Стандартный пример - галочка в фильтре "Только товары с изображениями".
ничто не может вам помешать создать другой интерфейс, причём в application слое и в его терминах, обозвать его «I-Что-то-там-QueryHandler», и уже в его реализации обращаться к базе данных любым способом, который вы сочтёте оптимальным для вашей конкретной задачи
Непонятно, на кой пень нам тогда репозитории, если мы будем всегда получать данные через какой-то другой handler.
Если у вас есть десятки различных причин обращаться к данным, предоставляемым репозиторием, значит у вас есть десятки связанных с ним доменных концепций.
Нет, это означает, что есть десятки комбинаций нескольких доменных концепций. И потенциально таких комбинаций очень много.
Это паттерн доменного слоя и выражает он именно доменные концепции
Вместо этого репозиторий может иметь много аналогичных методов, но с разными параметрами и, главное, разными именами, выражающими доменные концепции.Доменные концепции это часть бизнес-логики, в репозитории не должно быть бизнес-логики.
Репозиторий, как абстракция коллекции, для выборки данных должен иметь методы findById и findByQuery. Это аналог методов прямого доступа по индексу и поиска элементов по условию для обычной коллекции.
Аргументом для findByQuery можно передавать специальный объект спецификации или просто настроенный QueryBuiler из ORM.Для методов вида findSomethingBySomethingAndSomethingAndSomething() обычно получается так, что их становится много, а используются они только в одном месте кода. К тому же есть вопросы сортировки и пагинации. Их удобно мокать в тестах, но в остальном они создают больше сложностей, чем решают, поэтому лучше их не делать.
Это удобно ложится на логику в сервисах. В сервисе есть метод list(), он принимает DTO с фильтром из интерфейса и настройками сортировки и пагинации, настраивает по ним QueryBuilder, передает в репозиторий, получает список сущностей. Для пагинации нужно общее количество, можно в репозитории сделать метод findByQueryWithTotal или отдельно findTotalForQuery. Так все элементы имеют свою ответственность - репозиторий представляет коллекцию, сервис содержит логику фильтров, сущность ничего не знает про списки сущностей, и бизнес-понятия не разбросаны по всему коду.
fenix163
04.08.2025 08:13Для методов вида findSomethingBySomethingAndSomethingAndSomething() обычно получается так, что их становится много, а используются они только в одном месте кода.
Аргументом для findByQuery можно передавать специальный объект спецификации или просто настроенный QueryBuiler из ORM.
Ну и пусть будет куча методов findSomethingBySomethingAndSomethingAndSomething. Вполне может быть, что в этих find* могут появиться внутренние специфические фильтры.
Я писал код и с использование findByQuery и с использование findSomethingBy*. Второй вариант для нас был прозрачнее. Методов не так, чтобы прямо невозможно читать репозиторий. findByQuery используем, но чисто для GetList, для интерфейса, где есть список сущностей и фильтрация по полям, которых может быть спокойно и 15 и 20 (фильтрация по параметрам автомобиля например). Во всех остальных случаях findSomethingBy* из 3-4 элементов.
Ещё в плюс отдельных find* это фильтрация по датам. Где-то строго диапазон дат, где-то явное совпадение. Добавлять это всё в Query идея так себе. Query вырастет очень сильно и поддерживать его будет непросто.
michael_v89
04.08.2025 08:13Я писал код и с использование findByQuery
Добавлять это всё в Query идея так себе.Вы неправильно представляете, как это работает findByQuery принимает не DTO, а QueryBuilder из ORM или более высокоуровневую спецификацию, которая работает аналогично. Поэтому добавлять туда ничего не надо, и никакой проблемы с датами нет. Сервис принимает DTO с полями createdAtFrom, createdAtTo, настраивает QueryBuilder, передает в репозиторий. Делать универсальный класс со всеми возможными полями для фильтров точно не нужно, как раз из-за тех проблем, которые вы описали.
class OrderService { function list(OrderListFilter $filter, Pagination $pagination): OrderListDto { $qb = $this->entityManager->getQueryBuilder(Order::class); ... if ($filter->createdAtFrom) $qb->andWhere('>=', 'createdAt', $filter->createdAtFrom); if ($filter->createdAtTo) $qb->andWhere('<', 'createdAt', $filter->createdAtTo); $this->applyPagination($qb, $pagination); $orderListDto = $this->orderRepository->findByQueryWithTotal($qb); return $orderListDto; } }
fenix163
04.08.2025 08:13Я прекрасно представляю как работает findByQuery. В конечном итоге у вас разрастется OrderListFilter и где-то в любом случае будут возникать баги из-за разного использования фильтров. Либо у вас разрастется OrderService на кучу методов. Тогда какой смысл в OrderService? Прослойка перед репозиторием с двумя методами? Тогда какой смысл в репозитории?
OrderListFilter только для случаев где реально требуется пагинация и фильтрация. Все остальные запросы в различных сервисах и джобах это отдельные методы в репозитории.
michael_v89
04.08.2025 08:13Добавлять это всё в Query идея так себе.
В конечном итоге у вас разрастется OrderListFilterРаз вы говорите эти фразы, значит не представляете.
Список полей в OrderListFilter зависит только от количества полей в форме в UI. Новые поля там появляются только если попросил бизнес, а не потому что программист так захотел, поэтому никуда он не разрастется. OrderListFilter это DTO на бэкенде, которое является моделью формы ввода на фронтенде, он приходит из контроллера и в идеале создается и заполняется автоматически фреймворком. OrderListFilter не создается программистом в коде других методов, и метод list() не используется программистом в другой логике для получения списка заказов. Если нужно получить список заказов, программист настраивает query builder.Тогда какой смысл в OrderService? Прослойка перед репозиторием с двумя методами?
OrderService содержит логику бизнес-действий. Количество методов там соответствует количеству бизнес-действий с сущностью. При этом количество сервисов может быть больше одного - один для пользовательской части, один для админки, один для сообщений из очереди, с разным набором методов. OrderListFilter для пользовательской части содержит одни поля, для админской другие.
OrderListFilter только для случаев где реально требуется пагинация и фильтрация.
Да, я именно так и написал "принимает DTO с фильтром из интерфейса". Метод list() из этого примера возвращает данные для страницы списка заказов например в админке, с фильтром и пагинацией. Для списка заказов в личном кабинете пользователя будет другой сервис со своим DTO, где будут другие поля, и метод list() будет также принимать текущего пользователя, чтобы добавить его id в query builder.
Все остальные запросы в различных сервисах и джобах это отдельные методы в репозитории.
Вот я и объясняю, что у них есть недостатки. Обычно в каждом таком месте нужны свои условия, поэтому удобнее использовать спецификацию.
fenix163
04.08.2025 08:13Сначала вы жалуетесь на методы findSomethingBySomethingAndSomethingAndSomething, а потом мне втираете про list. Передавать в list DTO с фильтрами из интерфейса как вы описали это нормально. Но формировать в других местах эту DTO и вызывать этот же list - это дичь. И у меня сложилось впечатление, что вместо findSomethingBySomethingAndSomethingAndSomething вы формируете как раз DTO
michael_v89
04.08.2025 08:13Но формировать в других местах эту DTO и вызывать этот же list - это дичь.
В третий раз объясняю - OrderListFilter не создается программистом в коде других методов, и метод list() не используется программистом в другой логике для получения списка заказов.
Единственное место, где он создается - API endpoint для получения списка заказов для соответствующей страницы на фронтенде. Этот DTO принимает поля, которые отправляет фронтенд. Если есть другая страница со списком заказов с другим набором полей в фильтре, для нее будет другой endpoint и другой DTO.И у меня сложилось впечатление, что вместо findSomethingBySomething вы формируете как раз DTO
Вместо findSomethingBySomething я формирую объект QueryBuiler.
fenix163
04.08.2025 08:13Вместо findSomethingBySomething я формирую объект QueryBuiler.
Если формированием объекта занимается сервис, то это неправильно.
michael_v89
04.08.2025 08:13В данном случае он играет роль спецификации, поэтому всё нормально. Это соответствует тому, что пишет Фаулер: "Client objects construct query specifications".
QueryBuiler сам по себе достаточно высокоуровневая абстракция, по нему можно построить и HTTP-запрос к стороннему API, а не только SQL. Вы можете сделать отдельный класс спецификации, но он будет работать так же, как QueryBuiler, с методами andWhere и т.д.
fenix163
04.08.2025 08:13QueryBuilder высокоуровневая абстракция? Яснопонятно. Переходим с интерфейсов на QueryBuilder. Аргументов больше не имею
michael_v89
04.08.2025 08:13QueryBuilder это более высокоуровневая абстракция, чем конкатенация строк с SQL. Вы же в курсе, что уровней абстракции много, а не только 2 "высокий" и "низкий"?
Класс спецификации это по определению уровень домена. QueryBuilder от нее ничем не отличается, кроме того, что он в другом неймспейсе, и там вместо условия по бизнес-свойству
andWhere('=', 'isVip', true)
будут более детальные условия по полям таблицыandWhere('in', 'status', [Status.Platinum, Status.Diamond])
. Делать ли отдельный класс ради чистоты слоев дело ваше, но обычно QueryBuilder достаточно.
fenix163
04.08.2025 08:13Слушайте, ну конкатенация строк тоже высокоуровневая абстракция. Не байты же мы объединяем. QueryBuilder достаточно низкий уровень. И если у вас сервис занимается составлением QueryBuilder и потом отправляет его в репозиторий, то это означает что слой с сервисом лишний. Пример isVip и 2 статуса не совсем корректный. И isVip и статусы это бизнес-свойства. Status.Platinum вполне себе бизнес-часть. Вот если если bool isVip, а в таблице tiny int is_vip, то пример был бы удачнее.
Но в любом случае формирование QueryBuilder в отдельном сервисе лично мне не нравится. Но вы делайте как вам удобно. Главное, чтобы бизнесу польза была.
michael_v89
04.08.2025 08:13Слушайте, ну конкатенация строк тоже высокоуровневая абстракция.
Естественно, поэтому ваше ерничание неуместно.
И isVip и статусы это бизнес-свойства.
В таком случае класс, который содержит фильтры по ним, является частью домена, и нарушения слоев вообще нет.
формирование QueryBuilder в отдельном сервисе
Он не отдельный сервис, я же написал, что говорю про случай, когда логика находится в сервисах. То есть они в любом случае есть, они не сделаны специально для работы с репозиторием, поэтому они не "отдельные". Они содержат всю логику бизнес-действий, неважно, репозитории там используются или что-то еще. Вместо репозитория может быть SaaS сервис с GraphQL. Конвертирование OrderListFilter в DTO для GraphQL-запроса это аналог конвертирования OrderListFilter в QueryBuilder для репозитория. Этот код где-то должен быть, и удобно помещать его в сервис, не в контроллере же его писать. А сущностей с SaaS-сервисом у нас нет.
то это означает что слой с сервисом лишний
Не означает. В админке есть действия "показать список товаров, создать товар, изменить товар, удалить товар", сервис ProductService содержит логику всех этих действий. С OrderService аналогично, только там другие действия.
bondarmih
04.08.2025 08:13Большое спасибо за прекрасный материал! Не очень хорошо ориентируюсь во вселенной .net, но правила универсальны, подпишусь под каждым абзацем.
У меня в открытых вопросах остается до сих пор остается выбор способа обработки связанных агрегатов в пределах транзакции.
Писать обработку всех агрегатов в слое application подряд, одного за другим
Добавлять обработчики доменного события для агрегатов.
Первый вариант, на мой взгляд, лишает гибкости и подходит для прямолинейных процессов. Второй - размазывает контекст происходящего, требует дополнительных усилий по документированию. Есть ли у вас какие-то хорошие критерии для выбора?
mrozov Автор
04.08.2025 08:13спасибо
ну а какие критерии могут быть, вы всё правильно говорите
если это связка A->B, то да, прямо в обработчике команды
если это что-то более сложное, то зависит от того, куда проектировщик склоняется в дихотомии оркестрация <-> хореография, т.е. либо доменный сервис, либо доменные событияоркестрация через сервис выглядит проще, но только до определённого уровня сложности/линейности этой логики
я, как нетрудно догадаться, склоняюсь к событиям
имхо, они просто требуют некоторой адаптации, т.е. кажутся запутанными только с непривычкиконечно, это не каждому легко даётся, но ddd изначально заточен на разработчиков заметно выше среднего уровня, мидлам (даже считающим себя синьорами) он просто ломает мозг без особого толку, это всё требует высокого уровня профессиональной зрелости, интуитивного понимания ООП, дисциплины
Kerman
Вот зачем
Чтобы у меня бизнес-логика хорошо читалась, когда в различных местах нужны совершенно разные выборки по фазе луны и др начальника.
holgw
Я так понял, что по DDD нужно (рекомендуется) иметь отдельный метод под каждое назначение:
Kerman
Так если заинлайнить критерии отбора в логику доменной сущности или какого-нибудь репорта, разве это станет меньше DDD? Я думаю нет. Не всегда и не везде нужно такое мелкое дробление. Вынос критериев в отдельные методы хоть и уменьшает основной метод, но заставляет прерывать контекст при чтении кода.
В любом случае, если рассматривать IQueryable как инструмент (коим он и является), то с ним у меня есть возможность писать в логике условия, а без него - нет.
holgw
По мнению автора статьи -- да, так мы идеологически отдаляемся от DDD. Потому что репозиторий при таком подходе недостаточное явно отражает доменные концепции ¯\(ツ)/¯
Мне такой подход (который предлагает автор статьи) тоже не нравится, потому что приводит к переполнению репозитория кучей методов с длинными и очень похожими названиями. Из-за чего зачастую приходится тратить много времени чтобы понять какой именно метод нужно использовать, чтобы получить из БД то что нужно. А если есть еще связанные сущности, то это еще сильнее усугубляет ситуацию.
Я вообще рассчитываю, что в тред придет адепт DDD и расскажет как с этой проблемой бороться.
VladimirFarshatov
Сам фильтр критериев отбора сам по себе не может быть "набором данных" и входить в DDD как поддомен или что-то ещё? Что этому мешает? Как последовательно развивать фильтрацию от "выбери по ИД" до "фасетного и таргетированного поиска"?
holgw
Эти вопросы скорее к автору статьи. Ради ответов на них я и вписался в дискуссию.
LaRN
Так приходится вносить правки в большее количество мест в коде, как минимум в репозиторий и еще в то место где нужен новый метод, это может быть не очень удобно.
voroninp
Так это... Aggregates and Repositories are relevant for C part of CQRS.
Кверики для UI вполне могут формировать представления, объединяющие лютые проекции 100500 агрегатов. Вам же в этом случае инварианты проверять и обеспечивать не требуется, так как нет изменения состояния.
SolidSnack
Там вроде текучий интерфейс просто? Или типо того
voroninp
У EF?
SolidSnack
У IQueryable, как это относится к DDD чето не могу понять
voroninp
Так автор вроде и говорит, что не надо его наружу выставлять, чтоб абы кто мог его теребонькать.
mrozov Автор
Для начала, DDD не знает такого понятия, как "бизнес-логика". Этот слой совершенно сознательно разбивается на два разных - логика приложения и логика домена.
Если вы не согласны с этим исходным утверждением и полезностью этого разбиения, то дальнейшая дискуссия попросту не имеет смысла - DDD не для вас и репозитории вам в принципе не нужны, DbContext - разумный выбор.
Далее, вот этот вот набор фильтров имеет какой-то смысл с точки зрения доменной логики?
Не старше 5 лет назад, на координации и от уволенного менеджера (что, к слову, невозможно сделать в рамках разумного агрегата, сразу вам скажу), вот это вот условие имеет какой-то смысл?
Если да, то ему можно дать имя. И от этого логика вашего доменного слоя станет только лучше и нагляднее. И куда же поместить эту логику? Логику извлечения данных из хранилища по какому-то критерию, куда же её поместить... (щелкает пальцами) а почему бы не в репозиторий?
Если же эта логика (как это обычно и бывает в таких случаях) имеет смысл именно для Application слоя, то вас никто не заставляет в принципе каким-то образом приплетать сюда репозиторий, это изначально ненужное самоограничение.
////
Но что делать, если у вас действительно есть именно доменные критерии фильтрации, требующие сложных логических вычислений и выходящие за пределы разумно определённых агрегатов?
Защищать агрегаты, конечно, а для этого вам придётся конструировать эффективные запросы к вашему хранилищу, не выставляя наружу потроха (в вашем случае AccountManager), а это по факту можно сделать только внутри репозитория, но никак не снаружи.
Не выходящие за пределы агрегатов? Я, опять же, прямо в публикации показал - как именно это можно делать. Но я никому ничего не навязываю, я просто показываю, как это можно сделать эффективно.
Kerman
Я бы не стал так категорично придираться к терминологии. Есть устоявшийся термин - "бизнес-логика". Это логика обработки данных в терминах бизнеса. По сути это логика домена. И что значит "репозитории в принципе не нужны"? Если я, допустим, не следую DDD, то мне репозитории не пригодятся?
В данном случае нет, но в реальном коде у меня есть ещё более всратые примеры. Их много. Их сложно назвать и их незачем выносить в отдельный именованый тип. Потому что когда условие заинлайнено в логику, то логика хорошо читается. Она легко правится.
Ваша позиция мне понятна, но неприятна. Я не считаю необходимым условием именовать каждый фильтр. Это уже крайность.
mrozov Автор
если так, тогда вам не стоит лезть со своим пониманием того, что такое репозиторий, в разговоры про DDD
считайте это созвучным словом на другом языке
если вы не выделяете доменную логику, то вы в этом разговоре напрасно тратите и своё и моё время
просто для примера - в рамках стратегического паттерна DDD про Единый Язык прямо утверждается, что в разных поддоменах один и тот же термин может и будет иметь разные значения
я не знаю, сумел ли Эванс зарегистрировать торговую марку на термин Repository, на самом деле, но лично мне хотелось бы, конечно, запретить его использовать тем, кто DDD не практикует :)
но это так, лирика
Kerman
окей