Этот материал является кросс-постом из моего блога
Следить за обновлениями блога можно в моём канале: Эргономичный код
Введение
Я считаю, что именно агрегаты из Domain-Driven Design лежат в основе поддерживаемых информационных систем. Однако эта концепция малоизвестна за пределами DDD-сообщества и довольно сложна для понимания, поэтому я решил написать очередной пост посвящённый агрегатам. В основном для чтобы структурировать собственное понимание агрегатов и создать «методичку» для своих команд, но и широкой общественности, я надеюсь, этот пост тоже может быть полезен.
Что такое агрегат? (TLDR)
Агрегат — это кластер сущностей и объектов-значений, объединённых общими инвариантами. Любое взаимодействие с агрегатом осуществляется через одну и только одну из его сущностей, называемую корнем агрегата.
Для того чтобы обеспечить соблюдение инвариантов, агрегат должен удовлетворять следующим требованиям:
-
Выступать единицей персистанса (все сущности всегда загружаются и сохраняются вместе). «Точкой входа» персистанса (загружаемым и сохраняемым объектом) является корень агрегата.
-
Все модификации состояния агрегата должны осуществляться через корень.
-
Все сущности должны входить только в один агрегат.
В объектно-ориентированном коде агрегат всегда материализуется минимум в два класса — корень агрегата и репозиторий агрегата. Внутри агрегата связи реализуются ссылками непосредственно на объекты. Между агрегатами связи реализуются через идентификаторы корней агрегатов.
Например, отчёт с непересекающимися отчётными периодами и составителем моделируется двумя агрегатами, которые на Котлине будут выглядеть так:
// Агрегат составителя отчёта data class Author( val id: Long, val name: String ) interface AuthorsRepo { fun save(user: Author): Author fun findById(id: Long): Author? } // Агрегат отчёта data class ReportingPeriod( val from: LocalDate, val to: LocalDate ) { init { require(from <= to) { "$from > $to" } } } data class Report( val id: Long, val reportingPeriods: List<ReportingPeriod>, val authorId: Long ) { init { reportingPeriods.sortedBy { it.from } .windowed(2, partialWindows = false) .find { it[0].to >= it[1].from } ?.let { throw IllegalArgumentException("Report cannot have intersecting intervals: ${it[0]} and ${it[1]}") } } } interface ReportsRepo { fun save(user: Report): Report fun findById(id: Long): Report? }
Почему агрегата именно два, а не один или три? Ответ на этот вопрос лежит в принципах декомпозиции модели информации системы.
Принципы декомпозиции модели информации на агрегаты
При проектировании агрегатов (как и всех других элементов ПО) следует руководствоваться принципом высокой связанности/низкой сцепленности. В случае агрегатов этот принцип выражается в соблюдении следующих ограничений:
-
Агрегаты не должны иметь циклических связей.
-
Агрегаты должны определять область жизни всех сущностей, в них входящих. Эта область определяется областью жизни корня агрегата. Некорневые сущности не могут появляться раньше корня и продолжать существовать после его удаления.
-
Агрегаты должны обеспечивать соблюдение инвариантов. Агрегаты предоставляют такое API, которое не позволит клиенту перевести модель в невалидное состояние.
-
Агрегаты должны обеспечивать возможность реализовать все операции системы так, чтобы в одной транзакции менялся (или удалялся) один агрегат. Притом речь идёт именно об изменении (в том числе в виде удаления) существующих агрегатов — создавать и читать можно сколько угодно агрегатов.
-
Агрегаты должны быть минимального необходимого размера. Имеется в виду и количество типов сущностей в агрегате, и количество экземпляров сущностей и их размер в байтах.
-
Агрегаты должны храниться целиком в одной системе хранения данных на одном узле. Разные агрегаты одной системы могут храниться на разных узлах или в разных хранилищах.
-
Агрегаты могут ссылаться на другие агрегаты только через идентификаторы корней. Внутри агрегата сущности могут свободно ссылаться друг на друга.
Так вот, почему агрегатов всё-таки именно два? Потому что отчёты и составители ценны сами по себе и имеют независимые жизненные циклы. А периоды не имеют смысла без отчёта и инвариант отсутствия пересечения определяется на кластере объектов отчёта и его отчётных периодов.
Методика декомпозиции модели информации на агрегаты
Я предпочитаю идти от обратного и на первом этапе считать каждую сущность отдельным агрегатом, а потом искать причины для объединения сущностей в агрегаты. Поэтому первой версией разбиения информации на агрегаты является сама ER-диаграмма.
Затем я ищу инварианты системы. Самый простой и часто встречаемый инвариант — область жизни одной сущности (А) не должна выходить за пределы области жизни другой сущности (Б). В этом случае сущности А и Б нужно объединить в агрегат с Б в качестве корня.
Но самые важные инварианты определяются конкретными людьми в конкретном контексте и для их выявления не существует универсального алгоритма на базе технических вводных. Чтобы выявить самые важные инварианты я обращаюсь к экспертам — заказчикам, пользователям, владельцам продукта, руководителям проектов,
аналитикам и т.д. Зачастую эксперты самостоятельно не могут сформулировать инварианты, и им необходимо помочь, предлагая свои версии и задавая наводящие вопросы (например, «могут ли пересекаться отчётные периоды?»). Конкретные техники и способы помощи экспертам подробно расписаны в книгах по DDD.
Действительно важные инварианты бизнес так или иначе озвучит — важно их услышать. Если не услышите в процессе разработки, то точно услышите, когда инвариант будет нарушен в промышленной эксплуатации с последствиями для бизнеса:)
Получив список инвариантов, я выбираю те, что затрагивают несколько типов или экземпляров сущностей. Сущности, которые участвуют в обеспечении одного инварианта, объединяю в агрегаты. Если речь идёт о разных типах, то в агрегат я объеднияю сами эти сущности. Если речь идёт о разных экземплярах одной сущности, то я присоединяю их списком к одной из существующих или специально созданной для этого сущности.
Затем я проверяю получившиеся агрегаты на соответствие принципам.
Принцип акцикличных агрегатов я сейчас нарушаю крайне редко, а нарушения сразу же видны на ER-диаграмме. При разбиении циклов я пользуюсь принципом стабильных зависимостей и удаляю ссылку из более «стабильного» агрегата. Стабильность определяется по значимости для бизнеса, вероятности изменений в будущем и количеству входящих связей. Значимость для бизнеса и вероятность изменений определяются посредством гадания на кофейной гуще.
Что такое диаграмма эффектов?
Диаграмма эффектов — это моё изобретение, предназначенное для помощи в декомпозиции модели информации на агрегаты и в декомпозиции системы на модули. Сейчас диаграмма эффектов толком не описана — есть микропост с ранним описанием подхода к декомпозиции в котором есть пара ссылок на открытые статьи с описанием похожих диаграмм и черновик поста о диаграмме эффектов.
Чтобы проверить принцип изменения одного агрегата в одной транзакции, я строю диаграмму эффектов для того чтобы увидеть операции, которые меняют несколько агрегатов. С такими агрегатами можно поступить по-разному:
-
Если агрегаты всегда меняются вместе и размер позволяет — объединить их в один.
-
Если в одной операции смешались разные ответственности и есть возможность — разбить операцию на две.
-
Если в одной операции смешались разные ответственности, но разбиение операции невозможно или ухудшает дизайн — разбить изменения агрегатов на разные транзакции.
-
В первую очередь стоит посмотреть на вариант с использованием шины событий. В этом случае в первой транзакции остаётся изменение первого агрегата и генерация события, а в изменения остальных агрегатов уходят в транзакции обработчиков события.
-
Если разбиение через события приводит к появлению каскада событий, то можно просто разбить операцию на несколько транзакций.
-
-
Если я уверен, что операция имеет высокую связанность, а конкуренция за агрегат низкая (он меняется редко или только одним пользователем) — оставить всё как есть.
Если выполнять декомпозицию по описанной выше методике, то агрегаты с большим количеством видов сущностей у меня ни разу не появлялись. Поэтому для проверки принципа малых агрегатов остаётся удостоверится в отсутствии «больших» атрибутов и связей «один к действительно многому».
«Большие» тексты и массивы байт (картинки) я всегда выношу в отдельные агрегаты, даже когда это приводит к нарушениям принципов общей области жизни и изменения одного агрегата в одной транзакции. «Большой» — понятие относительное, и я выделяю атрибуты, если математическое ожидание их размера превышает ~4 килобайта.
«Действительно многие» связи я также всегда выношу в отдельные агрегаты вопреки
остальным принципам. «Действительно многие» — тоже понятие относительное, и я выношу связи, когда математическое ожидание количества связанных объектов превышает ~20 штук.
Для проверки всех остальных принципов у меня нет устоявшихся инструментария и эвристик и их нарушение я ищу «методом вдумчивого взгляда».
Процесс «проверить-подрихтовать-обновить диаграммы» я повторяю до тех пор, пока не получу результат, проходящий проверку.
Частые ошибки проектирования агрегатов
Моделирование лишних связей
Самой распространённой ошибкой является добавление лишних ссылок между объектами. Предельный случай этой ошибки — модель связного графа объектов.
Но и в контексте проектирования агрегатов можно внести в модель лишние связи. Чаще всего причинами внесения лишних связей являются:
-
удобство навигации — связь добавляется, чтобы была возможность добраться до объекта А, имея на руках объект Б.
-
отражение реальности — связь добавляется потому, что «в реальности» сущности связаны.
-
отражение модели данных — связь добавляется потому, что в логической схеме реляционной БД есть соответствующий атрибут и внешний ключ.
-
отражение пользовательского интерфейса — связь добавляется потому, что в UI в форме ввода или вывода данных, участвуют данные разных сущностей.
Но напомню, что единственной причиной добавления ссылки на объект является вхождение объекта в агрегат, а единственной причиной включения объекта в агрегат является его участие в обеспечении инварианта. Поэтому если связь не требуется для обеспечения инварианта, то её включение необходимо дважды обдумать. Потому что лишние связи ведут к повышению сцепленности дизайна и как следствие усложнению системы и деградации производительности.
Анемичная доменная модель
Ещё одной распространённой ошибкой является анемичная доменная модель. Анемичная доменная модель характеризуется в первую очередь сущностями, у которых все свойства доступны для чтения и записи через геттеры и сеттеры. При этом всё поведение сущности ограничивается геттерами и сеттерами. Эта ошибка ведёт к утери возможности обеспечить соблюдение инвариантов.
Кроме того, последствием анемичной модели становится погребение существенных для агрегата трансформаций в методах сервисов приложения. Что влечёт за собой жёсткую сцепку трансформаций и ввода-вывода. Из-за чего:
-
Усложняется задача тестирования трансформаций.
-
Снижается переиспользуемость трансформаций.
-
Усложняется задача понимания кода из-за смешения разных уровней абстракции в сервисе приложения.
Давайте сравним решения одной и той же задачи с помощью анемичной и «полнокровной» доменных моделей.
В качестве задачи возьмём систему хранения информации о торговле на бирже крипто-валют. В центре этой системы находятся «торги по символу» — торги между парой крипто-валют.
Требования к системе следующие:
-
Каждый пользователь по каждой паре может вести торги с использованием «грида» — по сути, набора значений параметров алгоритма торговли.
-
В каждый момент времени для каждого символа пользователя может быть активен только один из гридов символа.
-
Гриды уникально идентифицируются своим именем.
-
Для каждого грида хранится статистика по торгам с его участием (в примере — только доход).
-
Статистика может меняться только у активного грида.
-
Каждый пользователь может вести торги одновременно по нулю и более символов.
Так же есть ограничение на API системы: обновление информации осуществляется посредством отправки клиентом списка активных в данный момент пар и их гридов.
Реализация этой задачи с анемичной доменной моделью будет выглядеть примерно так:
data class Grid( var name: String, var profit: BigDecimal ) data class SymbolTrading( var symbol: String, var grids: MutableList<Grid>, var activeGrid: Grid? ) data class CustomerTradings( var customerId: Long, var tradings: MutableList<SymbolTrading> ) data class ActiveSymbol( var symbol: String, var gridName: String ) fun fetchCustomerSymbols(id: Long): CustomerTradings = TODO() fun saveCustomerSymbols(customerSymbols: CustomerTradings): Unit = TODO() fun updateCustomerSymbols(customerId: Long, activeSymbols: List<ActiveSymbol>) { activeSymbols.map { activeSymbol -> val trading = customerSymbols.tradings.find { it.symbol == activeSymbol.symbol } if (trading != null) { // (2) trading.activeGrid = trading.grids.find { it.name == activeSymbol.gridName } ?: Grid(activeSymbol.gridName, BigDecimal(0)) } else { val activeGrid = Grid(activeSymbol.gridName, BigDecimal(0)) customerSymbols.tradings.add( SymbolTrading(activeSymbol.symbol, mutableListOf(activeGrid), activeGrid) ) } } saveCustomerSymbols(customerSymbols) // (1) }
Такую реализацию будет относительно сложно протестировать — надо будет либо сетапить и проверять состояние БД, либо использовать моки и делать тесты хрупким и зависящим от деталей реализации.
Также здесь в одном методе смешаны и работа с БД (1) и бизнес-правила (2).
Эти две проблемы можно решить посредством вынесения бизнес-правил в утилитарный метод. Однако это не решит основную проблему — с таким подходом невозможно защитить инварианты. Ничего не остановит клиентский код от удаления активного грида из trading.grids. Как и от изменения статистики по неактивному гриду.
Для того чтобы защитить инварианты, необходимо большую часть логики перенести в доменную модель. Также необходимо исключить возможность неконтролируемых операций записи.
Если оставаться в парадигме изменяемой модели данных, то это можно сделать путём сокращения области видимости сеттеров до внутренней (internal) в случае Котлина. Но тогда придётся выделять агрегаты в разные модули, что очень не удобно.
В том числе (но не только) по этому, я рекомендую пойти простым путём: сделать сущности неизменяемыми, с закрытым конструктором и опубликованным фабричным методом вместо него, который будет гарантировать соблюдение инвариантов.
typealias Symbol = String typealias GridName = String data class Grid( val name: GridName, val profit: BigDecimal = BigDecimal(0) ) data class SymbolTrading private constructor( val symbol: Symbol, val grids: Map<GridName, Grid>, val activeGrid: GridName ) { init { require(activeGrid in grids) { "Active grid ($activeGrid) should be within symbol's grids ($grids)" } } companion object { fun new(symbol: Symbol, gridName: GridName) = SymbolTrading(symbol, mapOf(gridName to Grid(gridName)), gridName) } fun activateGrid(gridName: String): SymbolTrading = if (gridName in grids) SymbolTrading(symbol, grids, gridName) else SymbolTrading(symbol, grids + (gridName to Grid(gridName)), gridName) } data class CustomerSymbols( val customerId: Long, val tradings: Map<Symbol, SymbolTrading> ) { fun activateSymbols(activeSymbols: List<ActiveSymbol>): CustomerSymbols { val updatedTradings = activeSymbols.map { tradings[it.symbol]?.activateGrid(it.gridName) ?: SymbolTrading.new(it.symbol, it.gridName) } return CustomerSymbols(customerId, tradings + updatedTradings.associateBy { it.symbol }) } } data class ActiveSymbol( val symbol: String, val gridName: String ) fun fetchCustomerSymbols(id: Long): CustomerSymbols = TODO() fun saveCustomerSymbols(customerSymbols: CustomerSymbols): Unit = TODO() fun updateCustomerSymbols(customerId: Long, activeSymbols: List<ActiveSymbol>) { val customerSymbols = fetchCustomerSymbols(customerId) val updatedCustomerSymbols = customerSymbols.activateSymbols(activeSymbols) saveCustomerSymbols(updatedCustomerSymbols) }
Такая реализация гарантирует, что любые модификации в данных должны будут пройти через CustomerSymbols. А так как CustomerSymbols является единицей работы с БД, это гарантирует, что в БД не попадут никакие данные в обход кода контроля инвариантов в модели.
«Полнокровная» модель явно очерчивает список доступных операций и повышает их
видимость — все операции над агрегатом находится рядом с агрегатом, а не разбросаны по сервисам и утилитарным методам.
Наконец, вся бизнес логика, которую надо покрыть полноценным набором тестов, ушла в чистую доменную модель которую очень легко тестировать. А код с эффектами — updateCustomerSymbols — стал тривиальным и его достаточно протестировать одним интеграционным, е2е или сценарным тестом.
Всё вместе — гарантия соблюдения инвариантов, упрощение анализа операций записи и упрощение тестирования — позволяет существенно уменьшить количество ошибок и регрессий и, как следствие, сократить стоимость разработки в длительной перспективе.
FAQ
Как программировать связи?
Связи внутри агрегата программируются свойствами со ссылками на объекты (a), а между агрегатами — свойствами с идентификаторами корней агрегатов (b):
data class Report( val reportingPeriods: List<ReportingPeriod>, // (a) val authorId: Long // (b) )
Как защитить инварианты?
Для того чтобы гарантировать сохранность своих инвариантов, агрегат должен не позволять внешним клиентам менять состояние напрямую. Для достижения этого необходимо следовать принципу «Tell Don’t Ask». В случае агрегатов это означает предоставление корнем агрегата API внесения изменений вместо API получения изменяемых объектов внутренних сущностей.
При этом для получения информации об агрегате есть несколько подходов:
-
Использовать неизменяемые классы для моделирования сущностей агрегатов. Объекты таких классов можно безопасно передавать клиентам, поэтому агрегат может предоставить прямой доступ к своим частям.
-
Плюсы: минимум дополнительного кода, хорошо масштабируется по количеству методов запроса информации.
-
Минусы: повышает сцепленность между клиентами и агрегатом.
-
-
Предоставлять API в том числе для получения информации только на уровне корня агрегата. В этом случае внутренние сущности вообще не попадают в публичное API агрегата.
-
Плюсы: полностью скрывает устройство агрегата и минимизирует связанность между клиентами и агрегатом.
-
Минусы: плохо масштабируется по количеству методов запроса информации.
-
-
Использовать копии изменяемых объектов. Этот подход похож на первый, тем что даёт клиентам доступ к частям агрегата, но клиентам выдаются не сами объекты частей, а их копии.
-
Плюсы: может быть использован в случае, когда нет возможности сделать объекты неизменяемыми.
-
Минусы: те же, что и у первого подхода, и необходимость в дополнительном коде копирования объектов в каждом геттере и, как следствие, большей нагрузки на сборщика мусора.
-
-
Использовать «read-only» представления. Похож на третий подход, но вместо копий предполагается возвращать «read-only» представления изменяемых сущностей.
-
Плюсы: нет необходимости в коде копирования объектов и снижение нагрузки на сборщика мусора.
-
Минусы: требует описания дополнительных интерфейсов для представлений и не очень надёжен — никто не запретит клиенту привести объект к изменяемому типу или поменять его через механизм рефлексии.
-
Я сам использую преимущественно первый подход, подключая второй в случаях, когда вижу необходимость в сокрытии структуры агрегата.
Как реализовать выборку данных для UI?
Существует несколько походов, и у каждого из них свои плюсы и минусы.
-
Сборка DTO из агрегатов. Заключается в том, чтобы вытащить нужные агрегаты из репозиториев и собрать из них DTO.
-
Плюсы — минимальная сцепленность модулей, минимум дополнительного кода
-
Минусы — потенциальные проблемы с производительностью из-за нескольких запросов в БД и больше ручной работы по добавлению зависимостей на репозитории и чтению данных из них.
-
-
Сборка DPO из агрегатов. По сути то же, что и первый вариант, только клиенту выдаётся Data Payload Object (DPO), вместо DTO. DPO — это набор агрегатов, из которого клиент сам строит нужные ему структуры.
-
Плюсы — минимальная сцепленность модулей, не нужен код для маппинга агрегатов в клиентские структуры.
-
Минусы — клиенту будут возвращаться лишние данные, что может плохо сказаться на эффективности и безопасности системы.
-
-
Отдельные модели для записи и чтения. В дополнение к модели для записи (агрегаты), создаётся дополнительная денормализованная модель для чтения.
-
Плюсы — эффективная работа с БД и создание DTO средствами ORM.
-
Минусы — неявная сцепленность модуля генерации DTO с деталями реализации всех модулей агрегатов, в два раза больше кода для описания модели данных.
-
-
Сборка DTO в СУБД. Современные СУБД (PostgreSQL, в частности) имеют встроенные средства для формирования JSON и позволяют собрать финальную DTO непосредственно SQL-запросом.
-
Плюсы — самая эффективная работа с БД.
-
Минусы — завязка на диалект определённой СУБД, менее удобный инструментарий для работы с SQL-запросами (чем с кодом на Kotlin, например), примитивные средства переиспользования кода и создания абстракций в самом SQL.
-
Варианты 1-3 подробно рассмотрены в книгах по DDD, вариант 4 хорошо описан в посте Лукаса Едера Stop Mapping Stuff in Your Middleware. Use SQL’s XML or JSON Operators Instead
Я сейчас в качестве варианта по умолчанию использую первый, а третий или четвёртый задействую в «горячем» коде. Второй вариант я пока что ни разу не использовал.
Зачем объединять сущности в агрегаты?
Для того чтобы обеспечить выполнение инварианта, затрагивающего несколько
сущностей. Частым примером такого инварианта являются слабые сущности — сущности
область жизни которых ограничена областью жизни другой сущности.
Почему агрегаты должны быть маленькими?
Из соображений производительности. Так как агрегаты являются единицей персистанса, большие агрегаты приведут к передаче больших объёмов данных по сети. И так как агрегаты являются единицей согласованности, большие агрегаты приведут к «большим» транзакциям (по количеству затронутых объектов и длительности), что повлечёт за собой большое количество конфликтующих транзакций. Это, в свою очередь, станет причиной либо ошибкам согласованности, либо большим накладным расходам на синхронизацию транзакций.
Когда не стоит объединять сущности в агрегаты?
Тогда, когда это приведёт к большим агрегатам. Например, пользователя, его фото и его комментарии лучше разделить по разным агрегатам, не смотря на то, что фото и комментарии являются слабыми сущностями. Фото — просто в силу большого размера. Комментарии — в силу их неограниченного роста.
Когда можно включать в агрегат много видов сущностей?
Агрегат может включать много видов сущностей, при соблюдении двух условий:
-
Агрегат преимущественно изменяется одним пользователем — исключает проблемы с синхронизацией.
-
Агрегат остаётся ограниченным по размеру в байтах — исключает проблемы с производительностью.
Почему в транзакции можно менять только один агрегат?
Во-первых — по определению. Агрегат определяет границы согласованности.
Во-вторых, потому что много маленьких агрегатов — это де-факто один большой агрегат со всеми вытекающими проблемами с синхронизацией и производительностью.
В-третьих, агрегаты могут храниться на разных машинах. А по определению агрегата это значит, что придётся иметь дело с распределёнными транзакциями. С которыми я бы предпочёл иметь дело в последнюю очередь.
Как обеспечить выполнение принципа «модификация одного агрегата в одной транзакции»?
В первую очередь, необходимо понять действительно ли эти модификации
должны быть строго согласованы, или можно обойтись согласованностью в
конечном итоге. Для этого автор Implementing Domain-Driven Design предлагает следующий алгоритм:
-
если обеспечение согласованности изменений является ответственностью пользователя, инициировавшего выполнение операции — то модификации должны быть строго согласованы.
-
иначе — можно обойтись согласованностью в конечном итоге.
Если получилось что, модификации должны быть строго согласованы, то это значит, что вы «открыли» новый инвариант, и новый агрегат для его обеспечения. Если при этом агрегат становится большим — надо взвешивать плюсы и минусы и либо оставлять большой агрегат, либо возвращаться на этап проектирования агрегатов и операций системы и искать новое решение. Возможно несколько потенциальных решений:
-
«Закрыть» этот неудобный инвариант и перейти к согласованности в конечном итоге.
-
Убрать из агрегата «лишние» сущности, которые были включены в него по причинам отличным от обеспечения инварианта.
-
Разбить большой агрегат, новым способом, который обеспечит соблюдение всех инвариантов. Возможно для этого придётся отказаться от некоторых инвариантов.
Если же модификации могут быть согласованными в конечном итоге, то операцию необходимо разбить на две. Для этого надо разбить код на два транзакционных метода в слое сервисов приложения. Затем либо оба этих метода публикуются для клиентов, либо они связываются через публикацию доменного события первым методом и его обработку вторым.
Заключение
Агрегаты — действительно сложная тема:
Clustering Entities (5) and Value Objects (6) into an Aggregate with a carefully crafted consistency boundary may at first seem like quick work, but among all DDD tactical guidance, this pattern is one of the least well understood.
— Vaughn Vernon, Implementing Domain-Driven Design
и её невозможно полностью понять, прочитав один пост.
Но я постарался собрать в этом посте необходимый минимум информации для того, чтобы спроектировать первый агрегат.
Дальнейшее чтение по теме
-
[idddd] An In-Depth Understanding of Aggregation in Domain-Driven Design
-
[ddd] Domain-Driven Design: Tackling Complexity in the Heart of Software
-
[dddmf] Domain Modeling Made Functional: Tackle Software Complexity with Domain-Driven Design and F#
-
[pppofddd] Patterns, Principles, and Practises of Domain-Driven Design
ссылка на оригинал статьи https://habr.com/ru/post/660599/
Добавить комментарий