Надо смотреть. В общем случае число if/else кейсов будет одинаковым в сравнении с подходом написать в лоб в процедурном стиле, просто скрыто за полиморфизмом или слабой связанностью. Но именно удобство чтения и расширения будет больше, при условии, что получается смоделировать какие-то ключевые узлы механик.
Я всегда в таком случае просто пишу 2 версии и сравниваю, чтобы понять, что выглядит более удачно. Занимает больше времени на старте, но даёт возможность сравнить и принять решение. Пока что я чаще всего выбирал ООП подход, но где-то можно и процедурами сделать, если механика непонятная и весь этот оверхэд на скелет может не окупится.
Но я не разработчик игр, я могу очень многое упускать из виду.
Не вижу проблемы. AttackInteraction должен все это учесть. Он должен пройтись по текущим эффектам на цели и обнаружить, что на ней весит эффект левитации. Далее через слабое связывание (например, рефлексию) он должен посмотреть, какие специальные правила работают для левитирующих объектов во время атаки (например все атаки ближнего боя гарантированно промазываются). Далее с помощью рефлексии создаётся объект этого правила и ему прикидывается контекст взаимодействия. Првило рассчитывает шанс промаха 100%. Но он так же проверяет, есть ли среди эффектов атакующего перк IgnoreLevitationForMeleePerk. Если нет - ставим шанс промаха 100%, если да то 0% условно. И так с базовой атакой и так же с другими модификаторами. Т.е. каждое взаимодействие является скетелом механики и позволяет подключать к ней любые частные случаи за счёт Dependency Inversion и Inversion of Control.
Интерфейс может выглядеть так: IDefenceEffectHandler<LevitationEffect> { HandleEffect(LevitationEffect effect, IAttacker, IAttackTarget).
Т.е.если у вас такое количество комбинаций, важно создать такой скелет, чтобы можно было что угодно с чем угодно комбинировать.
Соответственно интерфейсов будет много. Одни будут считать общие правила по эффектам. Другие будут описывать правила как тролли взаимодействуют с призраками и ТД (другая плоскость/матрица расчета) IAttackerAndTargetSpecialRule<Troll, Ghost>. Далее надо будет определить, что считается в первую очередь - общие правила по эффектам или специальные правила.
А если в комбинации и эффект и то на ком он висит и то кто атакует - можно сделать третий интерфейс. А можно добавить if в специальное правило для троллей и призраков. Идеального решения нет. Все зависит о того, насколько способ такого взаимодействия частый и достойно ли это отдельного интерфейса и встраивания в фреймворк механики, или лучше это сделать как исключение.
По поводу нужен 3-тий объект, чтобы связать 2 других - согласен.
По сути 3-тий класс это некий Interaction между объектами: AttackInteraction, MagicInteraction, DialogInteraction, BarterInteraction(Inventory buyerIntentory, Inventory sellerInventory, IBarterActor buyer, IBarterActor seller), LootInteraction, StealInteraction и тд. Этих взаимодействий должно быть ровно столько, сколько их есть в игре. Вычленяются они из естественного языка: Игрок атакует Моба; Моб атакует Игрока; Игрок лутает Моба; Игрок берет квест, Игрок и тд.
Т.е. есть изолированные Components: Статы, Инвентарь, Дерево Умений, Изученные заклинания, Перки, Ячейки заклинаний и тд. Например при нажатии кнопки Loot у убитого врага, открывается UI вашего инвентаря и убитого моба. При попытке забрать айтем запустится new LootInteraction(Inventory source, InventoryItem item, Inventory target).Execute()/CanExecute(), которая вызовет target.CanPutItem(item) и если true, уберет айтем из source инвентаря и положит в target инвентарь.
А такие классы как у вас в примере: AuraDamageProcessor являются "чистой выдумкой" из GRASP и по большому счету нужны просто для декомпозиции кода. Жить такие классы всегда будут где-то в той же папке, где описывается Interaction или Component.
Но не согласен, что это становится неподдерживаемым, если в проекте 200+ таких классов: сложность понимания флоу и механик для разработчика должена быть сопоставима с сложностью понимания флоу и механик для игрока. Если получилось 200+ компонентов и интеракций, ну значит игра не простая в плане механик, легкой поддержка точно не будет простой при любых раскладах.
Зато все классно тестируется. Можно просто запустить целую игровку сессию, задать стартовые состояния объектов кодом, и в рамках юнит теста, вызывать методы компонентов и взаимодействия между ними, симулируя поведение игрока и мира, и делать snapshot мира (save game) после каждого действия, и сравнивать его с ожидаемым значением. И можно запилить интеграционный тест, и то же самое провернуть с игровой сессией уже в рамках интеграции с движком.
По-другому это как? Юнит может иметь разные навыки в зависимости от того, в отряде он или нет? Он обладает всеми навыками сразу. А вот какие будут использоваться зависит от стратегии - одиночка, малый отряд, батальон.
За этими микросервисами в 99.9% случаев будет стоять stateful база данных. Поэтому да, это интерфейс доступа к базе, препроцессинга/пост процессинга данных, маппинга, интеграций, аггрегаций и ТД. И масштабируется такие микросервисы очень легко автоматически по различным метрикам: CPU/RAM/среднее время респонса за последние 5 минут.
А именно ключевой игрок здесь база данных, и именно ее сложнее всего масштабировать. Просто добавить больше нод по необходимости, а потом убрать их, когда деманд спадет, просто не выйдет.
Поэтому масштабирование микросервисов без масштабирования базы данных зачастую бессмысленно.
Кроме остальных 0.01% микросервисов предназначенных для stateless процессинга in/out вроде обработки фото, видео, нейронок где нужно много CPU/RAM/GPU и есть смысл выделить этим функциям отдельный compute. И там соответственно масштабирование тоже относительно простое и автоматическое по метрикам потребления ресурсов.
если вероятность конкурентных обновлений существенна - разрывайте транзакцию
Вы уже на ходу свою позицию адаптируете, в вашей статье про это ни слова, более того выше вы отстаивали, что данное решение нерабочее.
ошиблись? исправляйте ошибку в application layer, доменный слой при этом останется незатронут
Я вам уже объяснял, что дешёвым это изменение может не быть. Потребуется изменить и UI/UX, и протащить это по системе. Так что правило надо инвертировать: сразу смотрим что за ценность у модуля, какие риски, и если их мало - делаем в одной транзакции, но как правило в demanding модулях делаем асинхронный флоу.
Ладно, я устал спорить, в данном случае это путь в никуда.
Я привожу вам примеры и кейсы, когда ваше решение и виденье не работает, с явками и паролями. А вы просто обвиняете, называете мое понимание "неправильным", без примеров, по сути голословно.
Читатель комментариев пускай сам решает, кто на его взгляд прав и проверяет на практике.
Что-то я не очень понимаю, в чем мое решение нерабочее?
Мое решение как раз рабочее безотказно, но к сожалению с бОльшим оверхэдом на проектирование. Т.е. придется сразу перевести аккаунт в состояние "Архивируется". Это предотвратит дальнейшее его изменение в процессах не связанных с архивацией. Далее на интеграционное событие я отреагирую установкой триггера через пару секунд. Это позволит завершится всем операциям записи голосовой почты. Далее я точно буду знать, что новых записей голосовой почты в этот аккаунт поступать не будет, т.к. юз кейс на добавление записей проверяет статус активации аккаунта. Все, далее по вышеописанному триггеру можно безопасно пагинировать записи, зная, что никаких конфликтов не будет. Когда процесс архивации каждой записи отдельно закончится - я отправлю команду в аккаунт на изменение статуса с "Архивируется" на "В архиве". Решение масштабируемое, независимое от технологий SQL/no-SQL, с четкими границами консистентности, без подводных камней.
Просто иногда я не уверен, стоит ли данный процесс такой надёжности и продуманности, оцениваю насколько сложно будет это переписать, и принимаю осознанное решение сделать все в одной транзакции, потому что сейчас надо побыстрее запилить и чтобы циферки в планах у менеджеров сошлись. Или если я на 90% уверен, что идея хлам и бизнес сам выкинет это решение через месяц другой, зачем тогда напрягаться.
Ваше странное понимание против нашего странного понимания
А в чем смысл я объяснил. Хотите максимально сильную защиту всех инвариантов в системе, проектируйте систему через один агрегат (например, Банк). Но перфомансу и масштабируемости придется сказать пока.
Ошибки конкурентного обновления точно также могут встречаться и внутри одного агрегата - да сплошь и рядом.
Это и есть причина, почему агрегаты дробят на более маленькие - снизить вероятность конкурентного обновления, повысить вероятность успешной транзакции. Чем больше независимых агрегатов вы включаете в транзакцию, тем больше шанс отказа транзакции.
Агрегаты защищают инварианты, а не спасают от конкурентных обновлений :)
Агрегаты защищают свои инварианты, инварианты других агрегатов не должны влиять на успешность транзакции. Защита инвариантов без транзакционности невозможна в конкурентных системах.
Ну да, я же, убогий, за 20+ лет практики реальных-то приложений не видел...
Я вас таким не называл. Мысленный эксперимент с различными базами в различных BC и разными требованиями с масштабируемости должен навести вас на мысль, что не все так просто. Ваш доменный слой по идее отличается не будет никак, если вы используете синхронные ивенты или обновляете несколько агрегатов в одной транзакции. (Т.к. у вас репозиторий в домене, то будет меняться репозиторий). А вот апликейшен слой будет отличаться. Особенно, когда меняются требования к инфраструктуре. И вот этот момент надо понимать. Переписать с синхронного флоу на асинхронный требует не только изменить контекст выполнения кода, а вообще переосмыслить весь бизнес процесс, пользовательский опыт и ТД.
А причём тут вообще DDD? А он тут совершенно и непричём. Вообще никакого отношения к вопросу не имеет.
Имеет ещё как. DDD это не только про доменный слой. Сам по себе без оркестрации апликейшен слоем собрать какой-то мало-мальски полезные процесс не получится. Это и есть User Story. И когда вам надо менять стори, потому что поменялась инфраструктура - это текущая абстракция от инфраструктуры.
На этом этапе тоже возникает противоречие. Типа как так, мы собрали агрегаты по требованиям бизнеса, но из-за инфраструктурного консерна вроде масштабируемости приходится менять устройство домена, путем нарезания агрегатов на более маленькие. Но жизнь вообще противоречива. Возможно в масштабах из мануальных процессов эти агрегаты работали, а когда ускорили процесс в тысячи раз за счёт IT, оказалось, что они просто чудом избегали конфликтов и ТД.
Стремиться надо к такому проектированию, когда инфраструктура на домен и апликейшен влияет минимально. Тогда будет расти гибкость.
Нет, никакие границы тут, разумеется, не нарушаются, да и с чего бы вдруг?
Вот действительно. Например, мы хотим архивировать аккаунт юзера в телефонии. Берём аккаунт, берём его историю звонков и архивируем. Что может пойти не так?
Пока архивирали аккаунт, кто-то позвонил, счетчик числа пропущенных звонков на аккаунте обновился, транзакция упала, т.к. не прошла оптимистичная конкурентность по аккаунту. Или вы можете ее проигнорировать и получить lost write - решать вашему бизнесу, только не мешало бы дать ему знать о возможности такого кейса.
Пока архивировали аккаунт, появилось новое непрослушенное сообщение на голосовую почту. Помимо того же кейса со счётчиком, появляется так же новая голосовая почта, которая не будет зархивированна. Система находится не в консистетном состоянии. Критично ли это - нужно спросить у бизнеса, и подготовить план возврата системы в клгстстентное состояние (можно ли будет повторно за архивировать юзера или конкретную запись, позволяют ли это ваши бизнес правила и ТД).
Ваша система масштабируется, юзер хранится на одном инстансе одной базы, а история в другом; или например юзер в MySQL а история в elastic search. В вашем подходе абстракция течет, говоря о том, что строго настрого для выполнения операции архивации пользователя он сам и его история должны принадлежать к одному UoW. Т.е. не получится партиционировать историю звонков по годам. Строго говоря, в домене такого ограничения нет. Когда появится необходимость оптимизировать хранилище (инфраструктуру), записи за 2025 год хранить на теплом инстансе, а за 2023 в холодном сторадже, придется объяснить бизнесу, что для этого придется переписать кусок логики.
А теперь представьте архивацию целой группы юзеров, шанс отказа из-за оптимистической конкуренции или появления lost write растет пропорционально. Не говоря уже о том, что в вашем исполнении партиционирование становится на порядок более сложным. (Теперь все архивируемые юзера должны гарантированно находится в одном партишене, текущая абстракция).
Поэтому граница агрегата это как раз граница транзакционности. Можно вообще всю систему спроектировать как один агрегат, и тогда консистентность системы будет максимальная, но перформанс минимальный. Поэтому агрегаты дробят, осознанно снижая консистентность в угоду перфомансу и масштабируемости.
Да, разумеется, и это главная фича доменных событий.
Жизнь заставит вас пересмотреть свои взгляды, когда наиграетесь в DDD на локалхосте и начнёте масштабироваться. Когда окажется, что одни данные в MySql, другие в elastic, mongo и тд, синхронные макеты вам не помогут. А если начнёте с одной базы и синхронных макетов, готовьтесь переписать под приложения, чтобы внедрить поддержку асинхронности на должном уровне.
Если это в вашем конкретном случае вероятно, значит, вам следует разорвать транзакционный контекст на несколько с помощью интеграционных событий и паттерна outbox
В этом месте корабль DDD затонул из-за дырявой абстракции. В этом месте вы явно объявляете, что раз модуль А ловит событие модуля Б синхронно, то одно из двух: либо есть риски неконсистетного состояния, либо ваш UoW обслуживает оба модуля. Во втором случае это текущая абстракция. Модуль А независимым от модуля Б, но по факту неявно они делят контекст и без одного не может быть другого. И когда у вас произойдет смена хранилища из-за разных требований к масштабированию модулей (деманд модуля Б растет, а модуля А нет) - придется лопатить весь апликейшен код.
Текущая абстракция = при изменении инфраструктуры, неизбежно изменение application code.
Так что если вы добавите в свое представление о модулях (BC) требование, что у каждого BC может быть свое хранилище и разные требования к масштабируемости, вы поймёте, что никакой функции для получения данных в репозитории, кроме GetById быть не должно, иначе абстракция течет, делая техническую реализацию явной.
Я окей с тем, чтобы использовать синхронные ивенты, давать абстракциям мягко и иногда даже жёстко подтекать, если человек отдает себе отчёт, что он делает: вот эти модули масштабироваться не будут (какие-то разовые конфигурации), а эти будут: но в принципе они простые и если что быстро перепишем; а вот это быстро не перепишем и тд.
Идентификация - я такой-то такой-то. Аутентификация - вот мой паспорт/пароль/куки/токен, которые подтверждают, что я тот, за кого себя выдаю.
Получается, что идентифицировать мы однозначно не можем. А аутентифицировать тем более. Мы знаем, что это кто-то из группы, но кто конкретно не знаем. Ширина группы технически неограниченна.
Получается задача выполнена, мы можем авторизовать пользователя, не аутентифицируя его в системе. (Шаг аутентификации может быть выполнен вне системы, например друг у вас попросил доступ через ТГ, или не выполнен вовсе). Системе это безразлично.
Наверное ссылки на Гугл доки могут быть примером. Их можно распространить среди ограниченной группы людей. Но кто конкретно отрецензировал документ узнать будет, вроде как нельзя. Получается пользователь авторизован, но не аутентифицирован.
Хз почему мюнусят. Я сейчас конкретно в веб приложениях, когда работаю с сущностями, у которых достаточно простое поведение, использую сущности как хранилище состояний без инвариантов. А сами инварианты, стейт транзишены и регистрацию событий в outbox пишу прямо в command handler. Да, теряется гибкость использования тактических паттернов. Но модули простые, и тащить сложности их реализации в этот код не хочется. Поэтому у меня микс - самое сложное ядро с насыщенным доменом, различными реализациями стратегий, где сложность богатой доменной модели оправдана - использую ее. В случаях, когда это нафиг не надо - использую DbContext напрямую вместе с анемичной моделью. Самое страшное, что может произойти с таким модулем, это смена персистенса на такой, который не удастся подружить с DbContext на уровне конфигурации. И то, я такой модуль за пол дня перепишу на другой движок (Mongo, DynamoDB и тд). Особенно если он покрыт тестами.
Так что с в целом я с комментарием согласен. На количество стейт транзишенов не влияет богатая у вас модель или анемичная. Инварианты может соблюдать там и там.
По поводу ListAll : я обычно делаю какой-то отдельный интерфейс для этого, если прямо очень надо. Так основной класс репозитория не засоряется и надо ещё поискать этот интерфейс под конкретную команду (обычно это какой-то cleanup или архивация). Проблема очевидная, но неявная (не отражено в домене) - сущности должны быть на одном партишене.
Но обычно все-таки я делаю вызов снаружи: есть квери, который возвращает список сущностей (чаще всего с пагинацией) и потом вызываю для каждой сущности команду. Это лишает разработчика иллюзии, что все транзанкционно и если кто-то добавит новую запись в процессе обработки, она обработана не будет.
Если это ок - ну ок. Если нет - явные / неявные локи, очереди и тд.
Вспоминаем ранее пройденное – репозиторий это паттерн доменного слоя и выражает он доменные концепции.
Например, какие? Кто является главным потребителем 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.
Дальше пока устал читать и писать, может позже дополню :)
Там уже можно использовать более сложные ключи группировки очередей, чем просто product_id. Например, сделать product_id:warehouse_id; или же product_id:category_id и параллелить еще глубже. Можно сделать руками теггирование через админку и особо нагруженные продукты обрабатывать в отдельной очереди.
В принципе все заказы обрабатываются настолько параллельно, насколько это возможно, при учете того, что главный конкурирующий ресурс здесь это сток продукта. Причем прием заказов максимально конкурентный и это главное. Тут тоже можно оптимизировать, и например смотреть, что если запас товара на скаде большой, а покупатель берет всего пару единиц, можно оптимистично ему вернуть, что заказ успешно принят, потому что вероятнее всего это так. И если уже все-таки не удалось забукать - переводим флаг "оптимистичное бронирование данного товара" в false и ждем в ручном режиме, и звоним покупателю. Такое будет случаться только с какими-нибудь "лабубу", для такого типа товара флаг "оптимистичное бронирование данного товара" всегда должен быть false. Соответственно заказ будет отмечен, как принятый, когда все товары, у которых "оптимистичное бронирование данного товара" = false пройдут полноценное бронирование.
В общем я тут на ходу изобретаю, комбинации оптимистик + пессимистик можно придумать очень разные :)
Надо смотреть. В общем случае число if/else кейсов будет одинаковым в сравнении с подходом написать в лоб в процедурном стиле, просто скрыто за полиморфизмом или слабой связанностью. Но именно удобство чтения и расширения будет больше, при условии, что получается смоделировать какие-то ключевые узлы механик.
Я всегда в таком случае просто пишу 2 версии и сравниваю, чтобы понять, что выглядит более удачно. Занимает больше времени на старте, но даёт возможность сравнить и принять решение. Пока что я чаще всего выбирал ООП подход, но где-то можно и процедурами сделать, если механика непонятная и весь этот оверхэд на скелет может не окупится.
Но я не разработчик игр, я могу очень многое упускать из виду.
Не вижу проблемы. AttackInteraction должен все это учесть. Он должен пройтись по текущим эффектам на цели и обнаружить, что на ней весит эффект левитации. Далее через слабое связывание (например, рефлексию) он должен посмотреть, какие специальные правила работают для левитирующих объектов во время атаки (например все атаки ближнего боя гарантированно промазываются). Далее с помощью рефлексии создаётся объект этого правила и ему прикидывается контекст взаимодействия. Првило рассчитывает шанс промаха 100%. Но он так же проверяет, есть ли среди эффектов атакующего перк IgnoreLevitationForMeleePerk. Если нет - ставим шанс промаха 100%, если да то 0% условно. И так с базовой атакой и так же с другими модификаторами. Т.е. каждое взаимодействие является скетелом механики и позволяет подключать к ней любые частные случаи за счёт Dependency Inversion и Inversion of Control.
Интерфейс может выглядеть так:
IDefenceEffectHandler<LevitationEffect> { HandleEffect(LevitationEffect effect, IAttacker, IAttackTarget)
.Т.е.если у вас такое количество комбинаций, важно создать такой скелет, чтобы можно было что угодно с чем угодно комбинировать.
Соответственно интерфейсов будет много. Одни будут считать общие правила по эффектам. Другие будут описывать правила как тролли взаимодействуют с призраками и ТД (другая плоскость/матрица расчета)
IAttackerAndTargetSpecialRule<Troll, Ghost>
. Далее надо будет определить, что считается в первую очередь - общие правила по эффектам или специальные правила.А если в комбинации и эффект и то на ком он висит и то кто атакует - можно сделать третий интерфейс. А можно добавить if в специальное правило для троллей и призраков. Идеального решения нет. Все зависит о того, насколько способ такого взаимодействия частый и достойно ли это отдельного интерфейса и встраивания в фреймворк механики, или лучше это сделать как исключение.
"Критикуешь - предлагай".
По поводу нужен 3-тий объект, чтобы связать 2 других - согласен.
По сути 3-тий класс это некий Interaction между объектами: AttackInteraction, MagicInteraction, DialogInteraction, BarterInteraction(Inventory buyerIntentory, Inventory sellerInventory, IBarterActor buyer, IBarterActor seller), LootInteraction, StealInteraction и тд. Этих взаимодействий должно быть ровно столько, сколько их есть в игре. Вычленяются они из естественного языка: Игрок атакует Моба; Моб атакует Игрока; Игрок лутает Моба; Игрок берет квест, Игрок и тд.
Т.е. есть изолированные Components: Статы, Инвентарь, Дерево Умений, Изученные заклинания, Перки, Ячейки заклинаний и тд. Например при нажатии кнопки Loot у убитого врага, открывается UI вашего инвентаря и убитого моба. При попытке забрать айтем запустится
new LootInteraction(Inventory source, InventoryItem item, Inventory target).Execute()/CanExecute()
, которая вызоветtarget.CanPutItem(item)
и еслиtrue
, уберет айтем из source инвентаря и положит в target инвентарь.А такие классы как у вас в примере:
AuraDamageProcessor
являются "чистой выдумкой" из GRASP и по большому счету нужны просто для декомпозиции кода. Жить такие классы всегда будут где-то в той же папке, где описывается Interaction или Component.Но не согласен, что это становится неподдерживаемым, если в проекте 200+ таких классов: сложность понимания флоу и механик для разработчика должена быть сопоставима с сложностью понимания флоу и механик для игрока. Если получилось 200+ компонентов и интеракций, ну значит игра не простая в плане механик, легкой поддержка точно не будет простой при любых раскладах.
Зато все классно тестируется. Можно просто запустить целую игровку сессию, задать стартовые состояния объектов кодом, и в рамках юнит теста, вызывать методы компонентов и взаимодействия между ними, симулируя поведение игрока и мира, и делать snapshot мира (save game) после каждого действия, и сравнивать его с ожидаемым значением. И можно запилить интеграционный тест, и то же самое провернуть с игровой сессией уже в рамках интеграции с движком.
По-другому это как? Юнит может иметь разные навыки в зависимости от того, в отряде он или нет? Он обладает всеми навыками сразу. А вот какие будут использоваться зависит от стратегии - одиночка, малый отряд, батальон.
За этими микросервисами в 99.9% случаев будет стоять stateful база данных. Поэтому да, это интерфейс доступа к базе, препроцессинга/пост процессинга данных, маппинга, интеграций, аггрегаций и ТД. И масштабируется такие микросервисы очень легко автоматически по различным метрикам: CPU/RAM/среднее время респонса за последние 5 минут.
А именно ключевой игрок здесь база данных, и именно ее сложнее всего масштабировать. Просто добавить больше нод по необходимости, а потом убрать их, когда деманд спадет, просто не выйдет.
Поэтому масштабирование микросервисов без масштабирования базы данных зачастую бессмысленно.
Кроме остальных 0.01% микросервисов предназначенных для stateless процессинга in/out вроде обработки фото, видео, нейронок где нужно много CPU/RAM/GPU и есть смысл выделить этим функциям отдельный compute. И там соответственно масштабирование тоже относительно простое и автоматическое по метрикам потребления ресурсов.
Есть подозрение, что вас кто-то в данной статье недолюбливает и минусует комменты. Приходится исправлять.
Все по факту сказано, согласен.
Вы уже на ходу свою позицию адаптируете, в вашей статье про это ни слова, более того выше вы отстаивали, что данное решение нерабочее.
Я вам уже объяснял, что дешёвым это изменение может не быть. Потребуется изменить и UI/UX, и протащить это по системе. Так что правило надо инвертировать: сразу смотрим что за ценность у модуля, какие риски, и если их мало - делаем в одной транзакции, но как правило в demanding модулях делаем асинхронный флоу.
Ладно, я устал спорить, в данном случае это путь в никуда.
Я привожу вам примеры и кейсы, когда ваше решение и виденье не работает, с явками и паролями. А вы просто обвиняете, называете мое понимание "неправильным", без примеров, по сути голословно.
Читатель комментариев пускай сам решает, кто на его взгляд прав и проверяет на практике.
Что-то я не очень понимаю, в чем мое решение нерабочее?
Мое решение как раз рабочее безотказно, но к сожалению с бОльшим оверхэдом на проектирование. Т.е. придется сразу перевести аккаунт в состояние "Архивируется". Это предотвратит дальнейшее его изменение в процессах не связанных с архивацией. Далее на интеграционное событие я отреагирую установкой триггера через пару секунд. Это позволит завершится всем операциям записи голосовой почты. Далее я точно буду знать, что новых записей голосовой почты в этот аккаунт поступать не будет, т.к. юз кейс на добавление записей проверяет статус активации аккаунта. Все, далее по вышеописанному триггеру можно безопасно пагинировать записи, зная, что никаких конфликтов не будет. Когда процесс архивации каждой записи отдельно закончится - я отправлю команду в аккаунт на изменение статуса с "Архивируется" на "В архиве". Решение масштабируемое, независимое от технологий SQL/no-SQL, с четкими границами консистентности, без подводных камней.
Просто иногда я не уверен, стоит ли данный процесс такой надёжности и продуманности, оцениваю насколько сложно будет это переписать, и принимаю осознанное решение сделать все в одной транзакции, потому что сейчас надо побыстрее запилить и чтобы циферки в планах у менеджеров сошлись. Или если я на 90% уверен, что идея хлам и бизнес сам выкинет это решение через месяц другой, зачем тогда напрягаться.
Nothing except of scalability and consistency of course.
Я же вас не предлагаю не использовать это, я говорю использовать это осознанно и с большими оговорками, а не "ваше понимание странное".
Ниже я даже писал, что сам так иногда делаю, в супер простых кейсах. Ровно как и @michael_v89.
Ваше странное понимание против нашего странного понимания
А в чем смысл я объяснил. Хотите максимально сильную защиту всех инвариантов в системе, проектируйте систему через один агрегат (например, Банк). Но перфомансу и масштабируемости придется сказать пока.
Это и есть причина, почему агрегаты дробят на более маленькие - снизить вероятность конкурентного обновления, повысить вероятность успешной транзакции. Чем больше независимых агрегатов вы включаете в транзакцию, тем больше шанс отказа транзакции.
Агрегаты защищают свои инварианты, инварианты других агрегатов не должны влиять на успешность транзакции. Защита инвариантов без транзакционности невозможна в конкурентных системах.
Я вас таким не называл. Мысленный эксперимент с различными базами в различных BC и разными требованиями с масштабируемости должен навести вас на мысль, что не все так просто. Ваш доменный слой по идее отличается не будет никак, если вы используете синхронные ивенты или обновляете несколько агрегатов в одной транзакции. (Т.к. у вас репозиторий в домене, то будет меняться репозиторий). А вот апликейшен слой будет отличаться. Особенно, когда меняются требования к инфраструктуре. И вот этот момент надо понимать. Переписать с синхронного флоу на асинхронный требует не только изменить контекст выполнения кода, а вообще переосмыслить весь бизнес процесс, пользовательский опыт и ТД.
Имеет ещё как. DDD это не только про доменный слой. Сам по себе без оркестрации апликейшен слоем собрать какой-то мало-мальски полезные процесс не получится. Это и есть User Story. И когда вам надо менять стори, потому что поменялась инфраструктура - это текущая абстракция от инфраструктуры.
На этом этапе тоже возникает противоречие. Типа как так, мы собрали агрегаты по требованиям бизнеса, но из-за инфраструктурного консерна вроде масштабируемости приходится менять устройство домена, путем нарезания агрегатов на более маленькие. Но жизнь вообще противоречива. Возможно в масштабах из мануальных процессов эти агрегаты работали, а когда ускорили процесс в тысячи раз за счёт IT, оказалось, что они просто чудом избегали конфликтов и ТД.
Стремиться надо к такому проектированию, когда инфраструктура на домен и апликейшен влияет минимально. Тогда будет расти гибкость.
Вот действительно. Например, мы хотим архивировать аккаунт юзера в телефонии. Берём аккаунт, берём его историю звонков и архивируем. Что может пойти не так?
Пока архивирали аккаунт, кто-то позвонил, счетчик числа пропущенных звонков на аккаунте обновился, транзакция упала, т.к. не прошла оптимистичная конкурентность по аккаунту. Или вы можете ее проигнорировать и получить lost write - решать вашему бизнесу, только не мешало бы дать ему знать о возможности такого кейса.
Пока архивировали аккаунт, появилось новое непрослушенное сообщение на голосовую почту. Помимо того же кейса со счётчиком, появляется так же новая голосовая почта, которая не будет зархивированна. Система находится не в консистетном состоянии. Критично ли это - нужно спросить у бизнеса, и подготовить план возврата системы в клгстстентное состояние (можно ли будет повторно за архивировать юзера или конкретную запись, позволяют ли это ваши бизнес правила и ТД).
Ваша система масштабируется, юзер хранится на одном инстансе одной базы, а история в другом; или например юзер в MySQL а история в elastic search. В вашем подходе абстракция течет, говоря о том, что строго настрого для выполнения операции архивации пользователя он сам и его история должны принадлежать к одному UoW. Т.е. не получится партиционировать историю звонков по годам. Строго говоря, в домене такого ограничения нет. Когда появится необходимость оптимизировать хранилище (инфраструктуру), записи за 2025 год хранить на теплом инстансе, а за 2023 в холодном сторадже, придется объяснить бизнесу, что для этого придется переписать кусок логики.
А теперь представьте архивацию целой группы юзеров, шанс отказа из-за оптимистической конкуренции или появления lost write растет пропорционально. Не говоря уже о том, что в вашем исполнении партиционирование становится на порядок более сложным. (Теперь все архивируемые юзера должны гарантированно находится в одном партишене, текущая абстракция).
Поэтому граница агрегата это как раз граница транзакционности. Можно вообще всю систему спроектировать как один агрегат, и тогда консистентность системы будет максимальная, но перформанс минимальный. Поэтому агрегаты дробят, осознанно снижая консистентность в угоду перфомансу и масштабируемости.
Жизнь заставит вас пересмотреть свои взгляды, когда наиграетесь в DDD на локалхосте и начнёте масштабироваться. Когда окажется, что одни данные в MySql, другие в elastic, mongo и тд, синхронные макеты вам не помогут. А если начнёте с одной базы и синхронных макетов, готовьтесь переписать под приложения, чтобы внедрить поддержку асинхронности на должном уровне.
В этом месте корабль DDD затонул из-за дырявой абстракции. В этом месте вы явно объявляете, что раз модуль А ловит событие модуля Б синхронно, то одно из двух: либо есть риски неконсистетного состояния, либо ваш UoW обслуживает оба модуля. Во втором случае это текущая абстракция. Модуль А независимым от модуля Б, но по факту неявно они делят контекст и без одного не может быть другого. И когда у вас произойдет смена хранилища из-за разных требований к масштабированию модулей (деманд модуля Б растет, а модуля А нет) - придется лопатить весь апликейшен код.
Текущая абстракция = при изменении инфраструктуры, неизбежно изменение application code.
Так что если вы добавите в свое представление о модулях (BC) требование, что у каждого BC может быть свое хранилище и разные требования к масштабируемости, вы поймёте, что никакой функции для получения данных в репозитории, кроме GetById быть не должно, иначе абстракция течет, делая техническую реализацию явной.
Я окей с тем, чтобы использовать синхронные ивенты, давать абстракциям мягко и иногда даже жёстко подтекать, если человек отдает себе отчёт, что он делает: вот эти модули масштабироваться не будут (какие-то разовые конфигурации), а эти будут: но в принципе они простые и если что быстро перепишем; а вот это быстро не перепишем и тд.
Идентификация - я такой-то такой-то. Аутентификация - вот мой паспорт/пароль/куки/токен, которые подтверждают, что я тот, за кого себя выдаю.
Получается, что идентифицировать мы однозначно не можем. А аутентифицировать тем более. Мы знаем, что это кто-то из группы, но кто конкретно не знаем. Ширина группы технически неограниченна.
Получается задача выполнена, мы можем авторизовать пользователя, не аутентифицируя его в системе. (Шаг аутентификации может быть выполнен вне системы, например друг у вас попросил доступ через ТГ, или не выполнен вовсе). Системе это безразлично.
Наверное ссылки на Гугл доки могут быть примером. Их можно распространить среди ограниченной группы людей. Но кто конкретно отрецензировал документ узнать будет, вроде как нельзя. Получается пользователь авторизован, но не аутентифицирован.
Хз почему мюнусят. Я сейчас конкретно в веб приложениях, когда работаю с сущностями, у которых достаточно простое поведение, использую сущности как хранилище состояний без инвариантов. А сами инварианты, стейт транзишены и регистрацию событий в outbox пишу прямо в command handler. Да, теряется гибкость использования тактических паттернов. Но модули простые, и тащить сложности их реализации в этот код не хочется. Поэтому у меня микс - самое сложное ядро с насыщенным доменом, различными реализациями стратегий, где сложность богатой доменной модели оправдана - использую ее. В случаях, когда это нафиг не надо - использую DbContext напрямую вместе с анемичной моделью. Самое страшное, что может произойти с таким модулем, это смена персистенса на такой, который не удастся подружить с DbContext на уровне конфигурации. И то, я такой модуль за пол дня перепишу на другой движок (Mongo, DynamoDB и тд). Особенно если он покрыт тестами.
Так что с в целом я с комментарием согласен. На количество стейт транзишенов не влияет богатая у вас модель или анемичная. Инварианты может соблюдать там и там.
А что там с db context?
По поводу
ListAll
: я обычно делаю какой-то отдельный интерфейс для этого, если прямо очень надо. Так основной класс репозитория не засоряется и надо ещё поискать этот интерфейс под конкретную команду (обычно это какой-то cleanup или архивация). Проблема очевидная, но неявная (не отражено в домене) - сущности должны быть на одном партишене.Но обычно все-таки я делаю вызов снаружи: есть квери, который возвращает список сущностей (чаще всего с пагинацией) и потом вызываю для каждой сущности команду. Это лишает разработчика иллюзии, что все транзанкционно и если кто-то добавит новую запись в процессе обработки, она обработана не будет.
Если это ок - ну ок. Если нет - явные / неявные локи, очереди и тд.
Тогда без гарантий выполнения сайд эффекта. А если с гарантиями, то доменное событие превращается в интеграционное.
Например, какие? Кто является главным потребителем Repository? Application Layer. Соответственно и абстракция должна быть удобной для данного слоя. Но если пойти еще дальше, то у репозитория может быть только один метод на получение - Get(Identity id). И метод записи уже зависит от того, как реализован UoW. То, как вы выбираете данные, отношения к DDD не имеет, хоть SQL запросы прямо из вьюхи делайте.
Несмотря на то, что я считаю, что паттерн Specification именно в DDD не нужен, но может быть удобен, если есть четкий слой абстракции для доступа данных для Query стороны. Но в Вашем утверждении есть явное противоречие.
Опять же, Aggregate это граница транзакционности. Если вы получаете несколько агрегатов через List или аналоги, а потом обновляете их все вместе - вы нарушаете данные границы - теперь у транзакции причин завалиться в N раз больше, где N число задействованных агрегатов. Для такого случая нужно либо использовать Saga, либо делать Aggregate больше, либо пересматривать модель более детально.
Это далеко не самый неочевидный момент в этом паттерне. Самый неочевидный момент, это то, что все обработчики доменных событий выпоняются синхронно. Это ок если все ваше приложение живет InMemory. Ваш RAM выступает в качестве базы данных, тразакция это запись в RAM. Но если это веб-приложение или вроде того, этот эффект ловины приведет к тому, что накопится такое количество Aggregate для коммита, что вероятность ошибки транзакции из-за оптимистичной конкурентности (а я надеюсь она у читателя настроена) стремится к бесконечности. По схеме как будто бы не понятно, в какой момент происходит транзакция. Описанный мой флоу такой command handler -> uow -> event handler -> uow -> event handler -> uow -> коммит транзакции. Если флоу такой: command handler -> uow -> транзакция -> event handler ... , то возникает проблема гарантий доставки ивента. Т.е. у комманды A есть сайд эффект B, но если база отвалилась во время выполнения сайд эффекта B - то система зависнет в неконсистентном состоянии. Поэтому я предпочитаю интеграционные события. Доменные у меня используются только для аудит логов и заполнения Outbox.
Дальше пока устал читать и писать, может позже дополню :)
Там уже можно использовать более сложные ключи группировки очередей, чем просто product_id. Например, сделать product_id:warehouse_id; или же product_id:category_id и параллелить еще глубже. Можно сделать руками теггирование через админку и особо нагруженные продукты обрабатывать в отдельной очереди.
В принципе все заказы обрабатываются настолько параллельно, насколько это возможно, при учете того, что главный конкурирующий ресурс здесь это сток продукта. Причем прием заказов максимально конкурентный и это главное. Тут тоже можно оптимизировать, и например смотреть, что если запас товара на скаде большой, а покупатель берет всего пару единиц, можно оптимистично ему вернуть, что заказ успешно принят, потому что вероятнее всего это так. И если уже все-таки не удалось забукать - переводим флаг "оптимистичное бронирование данного товара" в false и ждем в ручном режиме, и звоним покупателю. Такое будет случаться только с какими-нибудь "лабубу", для такого типа товара флаг "оптимистичное бронирование данного товара" всегда должен быть false. Соответственно заказ будет отмечен, как принятый, когда все товары, у которых "оптимистичное бронирование данного товара" = false пройдут полноценное бронирование.
В общем я тут на ходу изобретаю, комбинации оптимистик + пессимистик можно придумать очень разные :)