Pull to refresh

Comments 98

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

Условно, если у бизнеса требование, что нажатие на красную кнопку создает 100 новых записей (в разных таблицах), считает 10 годовых отчетов, отправляет результаты на почту начальству и еще деньги переводит сразу всем подрядчикам, то тут, прежде чем пинать на опасность саг и думать на какие bound contexts это все разделить, я бы сначала подумал как вообще тут обеспечить надежность и согласованость всего этого. Ну и поговорил бы с бизнесом о рисках и проблемах с такими "жирными" операциями, и возможным разделением их на части (например две синих кнопки, вместо одной красной).

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

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

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

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

Если есть возможно поменять бизнес исходя из технической необходимости, то я бы усомнился в необходимости DDD.

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

Сложную логику можно и в сервисах писать.

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

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

Если Вы про то, что вместо удаления сущность просто помечается удалённой - то да, это один из подходов позволяющих использовать eventual consistency вместо саги, но он применим не всегда. Например, в классическом примере саги "нужно заказать отель, перелёт и такси используя 3 разных внешних сервиса" одним eventual consistency не обойдёшься.

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

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

насколько рационально натягивать рекомендации и правила из ДДД на такие сложные операции

В том-то и дело, что речь не про большие операции, а про сложные агрегаты. Грубо говоря, в ситуации когда запрос юзера обычно приводит к изменению 3-4 табличек в РСУБД одного микросервиса следование тактике DDD уже может привести к саге. Просто из-за того, что эти 4 таблички в доменной модели представлены как 4 Entity, а бизнес нам скажет, что консистентность между всеми 4-мя вполне приемлемо обеспечить в течении 5 минут, и что ответственностью юзера приславшего запрос является обеспечить консистентность только между первыми 2-мя Entity. По рекомендациям DDD в этой ситуации мы должны в рамках одного микросервиса сделать 3 агрегата: первый из 2-х Entities консистентность которых входит в ответственность юзера, плюс ещё 2 агрегата в каждом из которых по одной Entity. Добавляем связь между ними через пару доменных событий, и получаем либо eventual consistency (если есть гарантия, что обновление второго и третьего агрегата провалиться не может в принципе) либо сагу (если обновление второго и/или третьего может провалиться).

На практике большинство в такой ситуации не станет "заморачиваться" следованию рекомендациям DDD и сходу сделает один большой агрегат на все 4 Entity. Проблема в том, что в этом случае увеличивается вероятность получить конфликты транзакций и/или тормоза. И вероятность эта определяется, опять же, бизнесом: насколько это большие и сложные Entities, работают ли с ними одновременно разные клиенты, какой рейт у запросов на изменение этих Entities. Ну и вторая проблема в том, что этот подход (не заморачиваться) со временем ведёт к тому, что в этот агрегат плюс к уже имеющимся 4 Entities влипнет ещё десяток других Entities, что сильно увеличит вероятность получить проблемы с конфликтами транзакций и/или производительностью.

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

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

Спасибо, понял. Есть над чем задуматься.

А зачем вообще использовать тактический DDD? Он вообще крайне громоздкий и неэффективный и довольно плохо описанный.

Из чисто технических мелочей;
1) Вообще "саги" в современном понимании не обязательно предполагают компенсации. Да и "позвать человека, чтобы исправил" - это, формально, тоже компенсация.
2) Tempora лучше использовать проведения линейной транзакции, без откатов. Вообще нужно строить систему так, чтобы не было понятие "вызов компенсации" и "откат транзакции", только "повтор". Как ни странно, почти во всех случаях это возможно.
3) Как только у вас появляется распределенная система и требуется хоть какая-то целостность - нужно реализовывать механизмы реконсиляции. Для МСА по DDD - это особенно необходимо из-за огромного объема дублирования информации.

И да, в IT есть две настоящие проблемы. DDD помогает с одной за счет другой )

А зачем вообще использовать тактический DDD?

Много лет назад у меня был один проект, где в одном из микросервисов оказалась реально сложная бизнес-логика. Я тогда спроектировал его довольно нетипичным для меня способом, просто потому, что иначе не получалось удерживать в голове корректность реализации бизнес-логики. А когда через много лет после этого я таки осилил разобраться с DDD, то было очень забавно понять, что тот микросервис я спроектировал очень близко к рекомендациям тактики DDD, о которых в тот момент ничего не знал. Так что, отвечая на Ваш вопрос: затем, что в Bounded Context с реально сложной бизнес-логикой сама жизнь заставит Вас так проектировать, знаете Вы про тактику DDD или нет.

При всём том, что я написал в статье, я на данный момент не вижу никаких альтернатив тактическому DDD если в одном Bounded Context оказалась реально сложная бизнес-логика. В своих проектах я стараюсь проектировать разделение на Bounded Context таким образом, чтобы в них просто не было сложной бизнес-логики, что даёт возможность не использовать в них тактику DDD. Но, очевидно, так спроектировать получится не в каждом проекте.

Он вообще крайне громоздкий и неэффективный и довольно плохо описанный.

Всё так.

  1. Вообще "саги" в современном понимании не обязательно предполагают компенсации. Да и "позвать человека, чтобы исправил" - это, формально, тоже компенсация.

Сага всегда предполагает компенсацию. Если компенсация не требуется (т.е. либо компенсировать нечего, либо шаги саги всегда есть гарантия рано или поздно выполнить успешно тупо повторяя их при ошибках) то это не сага а eventual consistency.

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

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

По моему опыту возможность спроектировать систему (почти) без саг (обходясь eventual consistency) даёт как раз отказ от тактики DDD (что, в свою очередь, требует чтобы ни в одном микросервисе не оказалось много сложной бизнес-логики). В этом случае у нас граница транзакций проходит по границе микросервиса (и его личной БД), плюс сами границы микросервисов мы очень стараемся проектировать так, чтобы саги между ними не требовались (иногда для этого приходится уговаривать бизнес что-то изменить в его требованиях/фичах).

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

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

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

Если переходить к DDD, то стратегические паттерны помогают найти в каких местах стоит делать этот компромисс, а тактические предлагают оформить это в виде саг.

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

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

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

А в чём проблема обеспечивать конвергенцию данных без каких-либо откатов?

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

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

Любой из этих "этапов" может провалиться и после завершения саги: в гостинице затопило номер, самолёт задержан из-за нелётной погоды, такси попало в ДТП. В конце концов клиент может просто передумать.

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

Всё верно, именно поэтому в саге этот процесс и называется "компенсация", а не "откат", как в обычной транзакции. Но к чему Вы это написали, что это меняет?

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

Если бы всё было так просто, как Вы описываете, а упомянутых мной проблем не было, то нам бы в принципе не были нужны обычные транзакции (которые ACID).

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

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

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

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

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

Думаю это видео раскроет вопрос лучше:

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

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

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

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

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

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

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

Это вполне себе нормальное состояние, статус которого либо "выполняем заказ", либо "отменяем заказ".

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

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

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

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

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

Да, чисто делать надо не всегда. В тех же РСУБД есть возможность указывать уровень изоляции транзакций, вплоть до read uncommitted - который вполне соответствует ситуации с сагами. Но если не делать чисто - получим в БД неконсистентные данные. О чём, собственно, и статья.

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

Абстракция атомарной транзакции для всего бизнес процесса в принципе не применима

С этим я согласен (при упоре на "всего")…

Купленные билеты, но не забронированный отель - это не неконсистентное состояние с точки зрения бизнес процессов

…а вот с этим - нет.

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

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

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

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

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

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

Саги - есть, как ни притворяйся что их нет. И они отличаются от обычных событий и их обработчиков. Не технической реализацией, а когнитивной нагрузкой на разработчика.

Вы вот прямо сейчас находитесь в таком "неконсистентном состоянии". Вы были рождены, но так и не померли. Так что хорош задерживать бизнес процесс - либо завершайтесь, либо "компенсируйте" своё рождение. А если серьёзно, то не стоит рабочие состояния бизнес процесса называть неконсистентными только потому, что они не финальны для бизнеса.

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

Я так и не понял, почему я не должен использовать специализированный термин предназначенный описывать именно такой тип ситуаций… но мне не трудно. Больше никаких "саг" и "неконсистентностей". Не вопрос.

Что это изменит по сути? Вот возьмём описанный Вами же пример со скидкой на "отменённый заказ". Не называя это табуированным словом, разве нам не нужно в любом случае предусмотреть такую ситуацию, реализовать отправку события "заказ отменён", реализовать бизнес-логику (пере)расчёта уже выданной скидки в обработчике события "заказ отменён"… реализовать отправку события "отмена/изменение скидки вследствие отмены заказа" чтобы это изменение скидки сказалось на новых заказах, которые успел сделать пользователь перед отменой заказа послужившего причиной получения скидки… Учесть все возможные взаимосвязи, нюансы и необходимые операции которые потребуются при отмене какой-то операции намного сложнее, чем при штатном выполнении новой операции. Не используя для описания этой проблемы специальный термин её повышенная сложность никуда не уйдёт.

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

Тут я предложу вам освоить реактивное программирование, и жизнь внезапно станет гораздо проще: https://page.hyoo.ru/#!=386pml_vyd0eg

Я прекрасно понимаю как пользу от реактивного программирования для UI, так и корректность всего сказанного по Вашей ссылке для UI.

Но текущая статья и всё её обсуждение подразумевает, что всё это DDD и саги происходят на бэке, т.е. очень далеко от UI. И у бэка есть своя специфика, игнорирование которой ничем хорошим не закончится.

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

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

Но вот при втором и последующих шагах, которые выполняются из обработчиков событий через какое-то время, никакого юзера уже нет. Так что просто перейти в состояние "ошибка" и считать, что мы своё дело сделали, а дальше пусть кто-то другой как-то с этим разбирается - это… если сказать очень мягко, "нежелательно" в абсолютном большинстве случаев. Данный подход упоминался в статье, в цитате "… or at a minimum to report the failure for pending intervention". Т.е. это вариант "позвать Васю", чтобы тот ручками в БД прода исправил неконсистентное состояние. Проблема в том, что мы все знаем: когда кто-то начинает регулярно ковыряться в БД прода то он часто что-то ломает ещё сильнее, вместо того чтобы починить. Так что этот подход никакую проблему не решает, он просто перевешивает ответственность за некорректные данные в БД с разработчиков на "Васю".

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

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

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

Графическое представление двухмерно, в то время как текст в общем-то одномерен. Выбор зависит от того, что вы хотите показать. Ваш вариант DSL имеет право на жизнь. Для BPMN есть, например, ещё такой https://www.bpmn-sketch-miner.ai/index.html, где баланс сдвинут в сторону естественности языка, нежели краткости записи.

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

Интересная статья, навела вот на какие мысли.

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

Тактические паттерны все же задуманы для использования в пределах только одного Bounded Contex - когда нужно согласовать между собой несколько принадлежащих ему агрегатов. В случае микросервисов, речь обычно идёт о согласовании данных в одной базе. Когда речь заходит о согласовании данных в разных базах - у которых разные владельцы - то про тактические паттерны говорить нет смысла.

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

В общем и целом - да, всё верно.

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

Агрегатами в DDD называют объекты, к которым есть доступ снаружи (исключительно из соображений инкапсуляции). Технически агрегат - это обычный entity, который может содержать другие entities и/или value objects, а может и не содержать другие объекты. Большим агрегат делает не количество содержащихся в нём других объектов, а общее количество данных в нём (включая данные вложенных объектов, если они есть). Чем больше данных нам нужно сохранить в БД в рамках транзакции - тем выше вероятность конфликтов и тем медленнее это работает. А находятся все эти данные в одной таблице с тысячей колонок или в ста таблицах по десять колонок… тоже может, конечно, влиять, но это не насколько важный фактор.

Все верно, дэк имеет смысл поле/ объект вне этого агрегата?

Ко всем остальным Entities (не являющимся агрегатами) и Value Objects доступ снаружи есть только через агрегаты. Так что ответ - нет. Но есть мелкие нюансы.

Все use cases вносящие изменения в модель домена (команды, если в терминах CQRS) должны выполняться используя метод(ы) агрегата(ов). Технически они могут, к примеру, создать самостоятельно какие-то Entities/Values Objects, с целью передачи их параметрами в метод агрегата. Но именно вне агрегата эти Entities/Value Objects смысла не имеют. Более того, даже ID всех Entities не являющихся агрегатами считаются локальными относительно ID содержащего их агрегата, поэтому наружу ID таких Entities не должны передаваться (и приниматься) без ID агрегата.

А вот use cases которые модель домена не изменяют (запросы, если в терминах CQRS) вполне могут использовать совершенно независимые Value Objects, по сути являющиеся отдельными анемичными моделями заточенными под требования UI, которые вполне могут содержать как разные агрегаты (или их урезанные версии) так и любые отдельные Entities и Value Objects (или их урезанные версии).

Спасибо за статью!

По-моему мнению: Eventual consistency - это когда через день/неделю/месяц/год после сбоев саг или других проблем тех поддержка выравнивает в базе консистентность данны.

Мне кажется решение заключается в использовании event sourcing и two phase commit (как в транзакциях у Kafka).

Состояние агрегата хранится в БД в виде последовательности событий которые можно только добавлять. Сага добавляет в БД событие помеченное как uncommited и недоступное для обработки агрегатом. При успешном завершении саги происходит фиксации событий с помощью two phase commit. После фиксации событие становится commited и становится доступным агрегату. В случае отката саги декомпенсация не требуется.

В принципе можно использовать two phase commit сразу для нескольких БД в микросервисах, но это хуже ложится на DDD и не асинхронно.

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

Нередко шаги саги требуют коммуникации со сторонними сервисами через API, а эти сервисы могут быть временно недоступны, и в результате полное выполнение саги может занимать даже не минуты а дни. А за такое время очень высок шанс получить конфликт между разными транзакциями. Насколько в таких условиях будут работоспособны 2PC/3PC - большой вопрос (с напрашивающимся очевидным ответом: не будут).

А промежуточный результат долгой саги доступен пользователям? Если да, то не приведет ли ее компенсация к каскадным откатам всех действий основанных на промежуточных данных?

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

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

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

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

Следующим шагом, надо полагать, будет отказ от логики.

Немного подробностей:

В следствии распределённого характера обработки информации в микросервисах имеем большущую проблему консистентности данных. Для её обхода (не устранения!) придумывают разного рода алгоритмы (автор называет их "сагами" и "компенсациями", накладывая очередной уровень переопределения для запутывания менее посвящённых). Но корень проблемы, оставаясь не устранённым до конца, разумеется, снова и снова создаёт нам боль. Как вылазит корень? Очень просто - в одной БД изменение сохранили, а в другой что-то пошло не так. В результате откат успешно сохранённой части происходит не сразу, а, как это модно говорить, eventually. Это означает, что в процесс в его неконсистентном состоянии может вклиниться кто-то другой, ну и всё испортить.

Обратив внимание на этот неприятный факт, автор текста выше сообщает нам пренеприятное известие:

Данная мина замедленного действия заложена в саму суть DDD

Что называется, "приехали". То есть головная боль микросервисов, внезапно, переложена на DDD. Оказывается, что даже сама суть DDD виновата. Но почему? Очень просто:

избежать этого паттерна можно только если ограничиться в своём проекте применением “стратегии DDD”, полностью отказавшись от “тактики DDD”

Вот так - автор применяет некие "паттерны" и "стратегии", но они оказываются неподходящими. Но виновата суть DDD! Ну а кто же ещё?

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

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

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

То есть головная боль микросервисов, внезапно, переложена на DDD.

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

В DDD практически всё прекрасно, за исключением одного тактического паттерна: определения границы транзакций по агрегату

Нужна ссылка, откуда это взято. Возможно это главная причина проблем, описанных в статье. Ведь всё скорее наоборот: не агрегат определяет границу транзакции, а транзакции определяют агрегат ("aggregate is synonymous with transactional
consistency boundary" Effective Aggregate Design
Part I: Modeling a Single Aggregate). Т.е. то, что должно быть включено в агрегат, определяется инвариантами - тем, что должно оставаться истинным при любых use case-ах.

А что толку от изначально корректных данных в памяти

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

Как Вы сами процитировали - агрегат это синоним границы транзакции. Так что кого по кому определять - не имеет значения и ни на что не влияет.

Т.е. то, что должно быть включено в агрегат, определяется инвариантами -
тем, что должно оставаться истинным при любых use case-ах.

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

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

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

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

Впрочем, я не совсем корректно тут выразился. И без тактики DDD тоже может возникнуть необходимость в сагах между микросервисами, что потенциально ведёт к тому же нарушению целостности в БД. Разница только в том, что:

  • Используя тактику DDD саг будет на порядки больше, и, соответственно, вероятность нарушения целостности данных в БД тоже будет намного выше.

  • Используя тактику DDD необходимость в сагах будет определяться требованиями бизнеса и технари в принципе не могут предотвратить использование саг. А без тактики DDD обычно есть возможность определять границы Bounded Context так, чтобы саги между ними не требовались.

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

Это понятно, что есть eventual consistency, но не имеет отношения тому, что определяет границы агрегата "An invariant is a business rule that must always be consistent. There are different kinds of consistency. One is transactional, which is considered immediate and atomic. There is also eventual consistency. When discussing invariants, we are referring to transactional consistency." (Effective Aggregate Design Part I).

Так что кого по кому определять - не имеет значения и ни на что не влияет.

Важно что первично. Если определять агрегаты по любым другим критериям, кроме как граница транзакций при use-case-х, то очень вероятно получим, что нужны саги. Но если за основной критерий, как и рекомендуют, брать транзакции, то саг в большинстве случаев получается избежать. Представим две крайности: 1 - трактуем каждое свойство каждого объекта как aggregate root и 2 - собираем все объекты со всеми свойствами в один aggregate. В (1) случае у нас почти наверняка будут нужны саги, в (2) - их 100% не будет (при ограничении 1 транзакция = 1 агрегат).

Если так не делать, то рано или поздно бизнес придумает такие use cases, при которых вообще все данные проекта окажутся в одном большом агрегате

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

Используя тактику DDD необходимость в сагах будет определяться требованиями бизнеса и технари в принципе не могут предотвратить использование саг. 

Опять же, следовать тактике, но не DDD в целом - основной антипаттерн в DDD. Вредно применять DDD, когда бизнес тебе враг, когда нельзя с ним обсуждать use case-ы, выясняя истинные потребности и перестраивать модель в коде под эти требования.

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

Разумеется, основной критерий - это транзакции. Но, как Вы же сами и заметили, тут речь исключительно о "transactional consistency". Везде, где нам нужно немедленное согласование - саг быть не может. Саги появляются там, где бизнес допускает согласование с некоторой задержкой. И избавиться от саг в этих случаях можно только избыточно раздувая агрегат, т.е. используя немедленное согласование там, где бизнесу приемлемо согласование с задержкой. А это противоречит первому правилу агрегатов: делайте их как можно меньше.

не рекомендуют применять, например, только тактические паттерны из DDD

Опять же, следовать тактике, но не DDD в целом - основной антипаттерн в DDD.

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

Вредно применять DDD, когда бизнес тебе враг, когда нельзя с ним обсуждать use case-ы, выясняя истинные потребности и перестраивать модель в коде под эти требования.

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

Саги появляются там, где бизнес допускает согласование с некоторой задержкой.

Не придирки ради: тут может быть два варианта - 1 - когда другие агрегаты просто принимают как данность произошедшее в третьем и меняют своё состояние, 2 - когда другие агрегаты тоже проверяют свои инварианты и при их несоблюдении требуется откатить произошедшее в третьем. Я так понимаю мы обсуждаем (2).

И избавиться от саг в этих случаях можно только избыточно раздувая агрегат

Этого (случая 2) позволяет избежать как раз проектирование границ агрегатов от инвариантов в use-case-х. Раздувание происходит как раз когда за агрегаты мы берём не те сущности, которые есть в бизнесе, а которые нам привычны (у нас может появляться User, тогда как вместо него могли быть несколько Buyer, Reporter; Product, вместо BusketItem, InventoryItem - т.е. один из подходов - разделяем сущности по use-case-м так, чтобы use-case укладывался в транзакцию над агрегатом) Да, иногда возникает ситуация, что так, как мы выделили границы агрегатов у нас только 95% операций выполняются транзакционно (с 1 агрегатом на транзакцию), но те 5% нет никаких проблем корректно реализовать сагами (опять же, вместе с бизнесом, обсуждая каждую деталь, что должно быть, когда такси недоступно, а билет на самолёт и в отель мы купили, можем ли мы откатить покупку билета или нужно уточнить у пользователя. И DDD тут только помогает такие кейсы вычленить и на них сфокусироваться с бизнесом).

А какие вообще альтернативы саге в примере про самолёт, отель, такси? Всё в одной транзакции, будем открытой её держать, пока сервис покупки билетов не ответит?

Этого (случая 2) позволяет избежать как раз проектирование границ агрегатов от инвариантов в use-case-х.

Ну т.е. иными словами вы предлагаете дополнить правила формирования границ агрегата ещё одним: если бизнес устраивает согласование с задержкой и согласование не является ответственностью пользователя вызвавшего данный use case, но есть вероятность что согласовать не получится и понадобится компенсация (сага) в пределах одного Bounded Context, то использовать немедленное согласование.

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

Раздувание происходит как раз когда за агрегаты мы берём не те сущности, которые есть в бизнесе, а которые нам привычны

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

но те 5% нет никаких проблем корректно реализовать сагами

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

А какие вообще альтернативы саге в примере про самолёт, отель, такси?

Никаких. Именно поэтому это классический пример саги.

Ну т.е. иными словами вы предлагаете дополнить правила формирования границ агрегата ещё одним

Нет, своего я не предлагал ничего, я про то основное правило - агрегат = инвариант (в терминах, приведённых ранее). Я тут не про те кейсы, когда бизнес допускает согласование с задержкой, но требует саги (отката) (к сожалению и счастью такого не встречал и что-то не могу придумать), я больше про то, что скорей всего бизнес как раз не устраивает с задержкой (например, с UI команда пришла, в которой нужно два агрегата поменять или ни одного и вернуть ответ/ошибку) и вместо того, чтобы пересмотреть границ агрегатов под новые реалии люди лепят сагу.

Встречал кейсы, когда допускается согласование с задержкой, но там не откат, а именно компенсаторное действие и оно отлично без явных саг тоже решается (user добавил товары в корзину, заказал, при сборке заказа обнаружилось, что нет на складе - склад шлёт заказу событие, заказ переводится в состояние, требующее реакции user-а, user может согласиться и опять отправить на склад). В таких примерах нет ничего сложного и нет, никто не предлагает из-за наличия такого use-case-а объединять склад и заказ в один агрегат (не факт даже, что на складе товара нет по причинам concurrency между разными агрегатами, мог просто потеряться/испортится товар).

А где я такое предлагал? 

Тут:

В DDD практически всё прекрасно, за исключением одного тактического паттерна: определения границы транзакций по агрегату. (К сожалению, это ключевой элемент всей “тактической” части DDD. Так что избежать этого паттерна можно только если ограничиться в своём проекте применением “стратегии DDD”, полностью отказавшись от “тактики DDD”.)

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

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

По сути, идея агрегата и границы транзакции по агрегату - ключевая в тактике DDD, и отказ от неё равен отказу от всей тактики DDD.

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

Ну, тут такое дело. Предложенное Вами дополнение к правилам определения границ агрегата - не является официально рекомендованным лидерами DDD. По крайней мере я его в их книгах/статьях/докладах не встречал. (Если Вы встречали - дайте ссылки, плз. И, нет, фраза "по границе транзакции" подразумевает несколько другое и не противоречит использованию саг.) Конечно, если мы вводим правило "если нужна сага, то делаем агрегат больше чтобы сага была не нужна", то саг не будет, как и вызванных сагами проблем. Но повышается вероятность других проблем: из-за крупных агрегатов. Насколько сильно она повышается и критично ли это - я не готов оценивать, тут надо собирать статистику на большом количестве разных проектов.

 Предложенное Вами дополнение к правилам определения границ агрегата - не является официально рекомендованным лидерами DDD

Повторю сказанное выше: никаких дополнений я не предлагал и не предлагал укрупнять агрегаты при возникновении use-case-в требующих саг (делить по другому, создавать новые агрегаты под разные use-case вместо использования одного агрегата - да, но не именно укрупнять)

 И, нет, фраза "по границе транзакции" подразумевает несколько другое и не противоречит использованию саг.

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

Разумеется, основной критерий - это транзакции. Но, как Вы же сами и заметили, тут речь исключительно о "transactional consistency".

или нужны ещё цитаты ссылки? Я вроде только это и утверждал

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

не предлагал укрупнять агрегаты при возникновении use-case-в требующих саг (делить по другому, создавать новые агрегаты под разные use-case вместо использования одного агрегата - да, но не именно укрупнять)

А как именно Вы это "делить по другому" и при этом "не укрупнять" себе представляете?

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

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

Я тут не про те кейсы, когда бизнес допускает согласование с задержкой, но требует саги (отката)

Это странно, потому что в статье обсуждаются именно они. :-) Разве что бизнес требует не конкретно компенсации (отката), бизнес требует просто соблюдать инварианты во-первых, и допускает что в данном месте данные будут согласованы не моментально а с задержкой, вот и всё. А необходимость в саге/компенсации возникает в том случае, если при реализации этой логики выясняется, что какие-то этапы согласования (не считая первого) могут провалиться.

я больше про то, что скорей всего бизнес как раз не устраивает с задержкой

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

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

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

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

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

А как именно Вы это "делить по другому" и при этом "не укрупнять" себе представляете?

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

за агрегаты мы берём не те сущности, которые есть в бизнесе, а которые нам привычны

Т.е. если, например, мы имеем сущности A, B, C и мы решили, что пусть будет два агрегата AB и C, потому что так привычно, а потом оказалось, что нужна транзакция между B и C, то вместо саги, нужно в агрегат объединить как A и BC (т.к. мы не делили изначально по транзакциям, то A и B могли быть вообще не связаны транзакционностью или бизнесу допустима eventual consistency между ними). Если изначально нужна транзакция между AB, а потом ещё и появилось, что нужно между B и C, то понятно, что только укрупнение транзакции/агрегата в ABC или сага.

Нельзя же используя часть данных другого агрегата создать ещё один агрегат, более "удобный" для конкретного use case.

Нет, это про другое, в контексте выделений агрегата по привычке: вот я говорил про User, вместо Buyer, Reporter: представим два use-case 1) создать Order с констрейнтом "не может быть больше 1 активного заказа на User" 2) при проблемах по заказу отправить Ticket в поддержку (констрейнт "1 активный Ticket на одного User-а"). Если бы мы имели один агрегат User, то нам пришлось бы в него объединять и Order-ы и Ticket-ы (пример условный, сейчас за скобками, почему это всё в одном BC). Если же это Buyer и Reporter, то у каждого было бы по своему списку. Если рассматривать это как пример выше с ABC, где User мог бы быть B, а Buyer и Reporter могли бы быть B1 и B2. Тогда в случае с User-м у нас было бы выход только укрупнение до ABC, а в случае с Buyer и Reporter осталось бы AB1 и CB2.

Я в целом, думаю лучше понял статью (спасибо за ответы), понятно, что в случае Transaction Script (ну или если не накладывать таких жёстких ограничений 1 транзакция - 1 агрегат - см. Single transaction across aggregates versus eventual consistency across aggregates NET-Microservices-Architecture-for-Containerized-NET-Applications), саги не нужны будут. По крайней мере если речь идёт о простых случаях, когда саги могли бы быть из-за инвариантов, а не из-за взаимодействий с внешними сервисами.

Мы можем оставить за скобками вопрос почему Order и Ticket вместе с каким-то юзером оказались в одном BC. Но я на всякий случай хочу обратить внимание, что Buyer и Reporter, будучи двумя представлениями одной сущности "юзер", в одном BC оказаться не могут даже в теории и даже примера ради. Так что если мы рассматриваем один BC, в который по какой-то причине попал весь упомянутый функционал, то юзер, скорее всего, будет называться именно User, а не Buyer или Reporter, и у нас будет именно ситуация ABC.

Для этого в таких проектах граница транзакции проходит по микросервису (Bounded Context), а не агрегату.

Представим простую ситуацию, без взаимодействия с внешними сервисами. В bounded context три агрегата - A, B и C. Мы их проектируем согласно границам транзакций, и большинство транзакций меняют только один из этих агрегатов. Но потом появляется use-case, который требует соблюдение инварианта между B и С - согласно рекомендациям из этой статьи мы себя не ограничиваем и создаём транзакцию на use-case вместо саги, т.к. B и C в одном микросервисе/bounded context-е. Но если это так, то как контролируется/ограничивается, что какой-то другой use-case над B или C не нарушит инвариант между B и C?

При этом подходе тактика DDD не используется, бизнес-логика размазывается между слоем приложения и репозиторием, модель данных становится в значительной степени анемичной, а соблюдение инвариантов является ответственностью use case в слое приложения. Так что ответ на Ваш вопрос: это контролирует бизнес-логика в use case (если это можно сделать вне транзакции) и/или реализации репозитория (если это можно сделать только внутри транзакции). Для Bounded Context в которых нет реально сложной бизнес-логики - это работает вполне неплохо. А если разделить сложную бизнес-логику по разным Bounded Context (чтобы в каждом из них ничего настолько сложного не было) не удалось, тогда появляется нужда в тактике DDD и опасность получить кучку лишних саг.

Звучит как Transaction Script vs Domain Model внутри Bounded Context. Да это бесспорно, что если получилось так разделить на микросервисы, что между ними саг нет, и внутри Transaction Script вывозит бизнес-логику - здорово, так и нужно делать. Но к сожалению это не всегда так, и есть ситуации когда Domain Model внутри Bounded Context даёт больше, чем проблем приносит (1-2 саги можно и реализовать на 50 других use-case-в)

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

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

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

А какое преимущество в данном случае нам даст анемичная модель с transaction script если наши агрегаты достаточно небольшие?

В контексте статьи - у нас 100% не будет ни eventual consistency ни тем более саг внутри одного Bounded Context.

В остальном - традиционное преимущество Transaction Script: учёт особенностей инфраструктуры при реализации бизнес-логики. Например: эффективные SQL-запросы, которые меняют только необходимые поля таблиц вместо сохранения агрегата целиком; "короткие" транзакции с намного меньшим шансом на конфликт; возможность автоматически и безопасно повторять отдельные транзакции (но не весь use case) при конфликтах (потому что внутри транзакции никто точно не полезет дёргать внешние API с непонятными побочными эффектами и т.п.); …

Ещё у Transaction Script есть одно, на мой взгляд недооценённое, свойство: модель в памяти и в БД могут кардинально различаться. Нужда в этом, опять же, обычно вызвана ограничениями инфраструктуры. Например, я недавно разрабатывал микросервис для контроля соответствия наших сервисов рейт-лимитам сторонних API. Перед каждой отправкой запроса во внешние API наши микросервисы приходили в этот и спрашивали, когда можно будет отправить нужный им запрос. Инфраструктура (PostgreSQL, в данном конкретном случае) просто не вытягивала такой рейт на запись, ведь нам нужно было учесть все такие запросы относительно доступных в стороннем API лимитов. В результате модель в памяти данного сервиса считала все запросы точно (модель постоянно была в памяти, она считывалась из БД только при запуске этого микросервиса), а в БД "съеденные" лимиты сохранялись периодически но с некоторым "запасом" на будущее (чтобы при креше сервиса не оказаться в ситуации, когда мы не учли часть съеденных лимитов) и в совершенно другом виде. На DDD такое, наверное, тоже можно как-то натянуть, если проявить фантазию, но зачем?

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

И я полностью с Вами согласен. Именно по этой причине для понимания "почему DDD именно такой" полезно учитывать исторический контекст, знать как писали код 20 лет назад. Если в проекте в любом случае используется ООП и ORM, который уже сильно абстрагирует инфраструктуру, причём он ещё и достаточно продвинутый, чтобы отслеживать что именно изменилось в большом агрегате и старается эти изменения эффективно сохранить в БД - то добавление в такой проект DDD не так уж и сильно скажется на эффективности использования инфраструктуры.

Только вот сегодня я пишу на Go, где ООП довольно условное (в частности геттеры и сеттеры не популярны и не реализуются языком "из коробки") плюс ORM почти не используется. И в таких условиях отличие между DDD и Transaction Script в плане эффективности использования инфраструктуры становится ой каким заметным.

Но, кстати, сказки про отдельные умные-эффективные ORM, скорее всего всё-таки в большей степени именно сказки. Потому что AFAIK ни один highload проект не использует ORM, именно из соображений недостаточной производительности. В инете полно статей как проекты по мере роста нагрузки с болью выпиливали ORM… И даже горизонтальное масштабирование не спасает, потому что в какой-то момент отказаться от ORM становится экономически выгодно для бизнеса, позволяя в разы сократить количество серверов.

К сожалению пока не имел опыта работы с highload, но в теории если применить CQRS и убрать ORM из операций чтения, то это может увеличить производительность, если конечно highload не касается операций записи. Но тогда получается что DDD вообще не применим к highload.

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

"короткие" транзакции с намного меньшим шансом на конфликт; возможность автоматически и безопасно повторять отдельные транзакции (но не весь use case) при конфликтах (потому что внутри транзакции никто точно не полезет дёргать внешние API с непонятными побочными эффектами и т.п.)

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

Тут немного другой кейс. Во-первых, повтор транзакции безопасен потому, что транзакция как раз "короткая" и ничего помимо группы SQL запросов не выполняет - в ней нет каких-то побочных эффектов (даже изменения моделей в памяти, не говоря уже об отправке запросов во внешние API и т.п.). А во-вторых, автоматический повтор делается не для любых ошибок коммита транзакции, а для одной конкретной ошибки PostgreSQL: 40001 (serialization_failure). Этот подход конкретно в PostgreSQL позволяет получить лучшее соотношение скорости и надёжности транзакций: использование максимального уровня изоляции транзакций Serializable по умолчанию (чтобы при реализации бизнес-логики вообще не задумываться какие менее жёсткие уровни изоляции могут быть приемлемы в каких-то use cases для увеличения производительности) компенсируя его автоматизацией повтора транзакции по конкретно этой ошибке. В этом случае всё будет работать максимально быстро и максимально надёжно, и при этом юзер практически никогда (только если будет превышен лимит повторов транзакции по этой ошибке) не будет получать ошибки вызванные конфликтом транзакций.

не, на такое желание смотреть пропало) 640 кб read committed должно хватать всем, что-то уж больно специфичное у Вас, не классический энтерпрайз

Да, часто его хватит. Но не всегда же. Проблема в том, что не хочется лишний раз задумываться, может ли он создать проблемы конкретно в этом use case. По факту все уровни изоляции ниже serializable появились ради увеличения производительности, т.е. это trade-off. А с тех пор, как в PostgreSQL смогли реализовать serializable без потерь в скорости (но с оговоркой о необходимости изредка повторить транзакцию по вышеупомянутой ошибке, что на практике почти никогда не требуется и поэтому потери скорости из-за повторов не заметно) - смысл использовать более слабые уровни изоляции просто исчез.

не встречал кейсы, где read committed + стандартная работа через orm приводила к проблемам и нужно каждый use case проверять, не приведёте пример?

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

Проблемы read committed общеизвестны: неповторяющееся чтение и фантомное чтение (плюс к ним есть ещё менее известная проблема serialization anomaly - когда результат успешного коммита группы транзакций может отличаться от результата последовательного выполнения этих транзакций по одной). Могут ли возникнуть именно такие комбинации SQL-запросов в параллельно выполняемых транзакциях конкретно в Вашем приложении, может ли конкретный ORM гарантировать что такого не случится - очевидно специфично для Вашего приложения. Проблема в том, что для уверенного ответа на этот вопрос обычно нужно представлять себе все запросы выполняемые во всех use cases, которые в принципе могут выполняться одновременно. Просчитать это довольно непросто, поэтому об этом обычно просто никто не задумывается - но это не значит, что такого не происходит на практике.

теория ясна, пример бы конкретного use case-а, когда это важно, можно будет прикинуть как это решает ORM

Вот конкретный пример, как раз с учётом ORM: https://vladmihalcea.com/a-beginners-guide-to-database-locking-and-the-lost-update-phenomena/. Будут ли проблемы зависит от того, как работает/настроен конкретный ORM: использует ли SELECT FOR UPDATE или оптимистичные блокировки на ручном версионировании.

Дальнейший поиск статей на эту тему привёл меня к выводу, что в общем случае с ORM "всё сложно", потому что уровень изоляции он будет использовать тот, который мы укажем, а для понимания какой уровень нам нужен и какие с этим уровнем могут быть проблемы нам нужно хорошо понимать какие запросы будет выполнять ORM конкретно в наших use cases, плюс очень желательно понимать что этот конкретный ORM делает ещё: что и как он кеширует, добавляет ли свои колонки для ручного версионирования строк, …

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

Сам по себе DDD-шный подход "начать транзакцию, считать весь агрегат, изменить, сохранить весь агрегат, закоммитить транзакцию"

не встречал такого, это откуда? Обычно транзакции явной вообще нет, UoW - считывается агрегат, модифицируется, транзакция ORM-м только на момент записи в базу изменений происходит.

Так везде, где не используется UoW. Например, IDDD глава 12 Repositories подраздел Managing Transactions: "A common architectural approach to facilitating transactions on behalf of persistence aspects of the domain model is to manage them in the Application Layer.". Все примеры кода в IDDD используют @Transactional (декоратор, полагаю) для оборачивания транзакциями каждого метода (use case) в слое приложения. Но UoW в IDDD несколько раз упоминается как альтернативный подход.

Насколько я понимаю как работает UoW, он действительно избегает открытия транзакции в начале use case. Вместо этого он читает из БД вне транзакции и использует оптимистические блокировки (напр. через версионирование строк в базе в отдельной колонке) чтобы это компенсировать, что позволяет ему использовать транзакцию только в момент коммита самого UoW. При этом UoW всё так же позволяет пользователю задать уровень изоляции транзакций для БД. В результате мы получаем смесь гарантий изоляции частично обеспеченную оптимистическими блокировками самого UoW и частично (не считая выпавшие из транзакции операции чтения) уровнем изоляции транзакций БД.

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

Очевидно, что используя оптимистичные блокировки на базе версионирования строк в БД UoW сможет гарантировать изоляцию при обновлении конкретного агрегата. Но вот что произойдёт если бизнес-логика обновления агрегата будет зависеть от выборки группы каких-то других записей из БД - уже совсем не так очевидно. Штатные транзакции БД эту ситуацию обработают в соответствии с заданным уровнем изоляции транзакций, а вот справится ли с ней UoW (версионирования только строк для этой цели явно недостаточно)?

В качестве конкретного примера этой ситуации можно придумать что-то вроде такого бизнес-требования: первые 10 зарегистрированных пользователей получают статус "early adopter". Для реализации этого при создании нового агрегата User мы должны выполнить запрос в БД возвращающий общее количество пользователей. Как эту ситуацию обработает UoW если одновременно будут выполняться регистрации 10-го и 11-го пользователей и оба увидят что в БД сейчас 9 пользователей? Обычные транзакции (без UoW) эту ситуацию обработают корректно только на уровне Serializable.

Но вот что произойдёт если бизнес-логика обновления агрегата будет зависеть от выборки группы каких-то других записей из БД

Так это и есть инвариант (наш предыдущей топик) - всё должно быть в одном агрегате

10 зарегистрированных пользователей получают статус "early adopter"

Например, будет некий агрегат RegistrationManager с единственным полем userCount и оптимистической блокировкой - двое поменяют c 9 на 10, она сработает и только первому даст. Ну и, кстати, встречный вопрос - а как там это работает в этом же кейсе при конфликте:

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

Там ведь нельзя будет того же нового user-а сохранить, нужно будет вычитать заново, что их уже 10 и поменять тип в user, или как?

Так везде, где не используется UoW

Лучше уж везде UoW использовать, чем Serializable ставить или каждый use-case анализировать, чтобы нужный уровень выбрать, нет?

Ребят, вы не в ту сторону думаете. Пришёл модератор и удалил первого пользователя, так как он оказался фейком. Потом регается 101-й пользователь, видит, что в "early adopter" всего 9, и становится десятым, а остальные 90 офигевают о происходящего. С интерактивной логикой всё очень плохо, как её ни готовь.

Зависит от бизнес-правила. Если бизнесу зачем-то нужно иметь ровно десять активных юзеров со статусом early adopter - одно дело. Но конкретно для этого примера бизнес такого не захочет, потому что в этом нет никакой пользы для бизнеса. Первые 10 юзеров получают этот статус, но никто не требует чтобы юзеров с таким статусом было всегда 10 и статус передавался от удалённых юзеров новым.

а как там это работает

Там - это при использовании DDD с Serializable транзакцией на весь use case или в Transaction Script? :-)

В DDD + Serializable это работает так:

  • Метод (use case) регистрации юзеров в слое приложения открывает транзакцию, вызывает доменный сервис либо конструктор, сохраняет созданный агрегат, закрывает транзакцию. При вышеупомянутой ошибке транзакции всё повторяет.

  • В UserRepository создаётся отдельный метод EarlyAdoptersCount() int возвращающий количество юзеров с этим статусом в БД.

  • Этот метод либо вызывается из доменного сервиса и его результат передаётся параметром в конструктор агрегата User, либо конструктору агрегата выдаётся инстанс UserRepository и он сам вызывает этот метод. Конструктор задаёт поле-флаг EarlyAdopter при создании агрегата User.

  • При вызове UserRepository.Save(user) либо у 10-го либо у 11-го юзера возникнет вышеупомянутая ошибка транзакции, после чего выполнение всего use case целиком будет автоматически повторено и на этот раз транзакция пройдёт без ошибок.

В Transaction Script + Serializable это работает так:

  • В GlobalRepository создаётся метод RegisterUser(*User) error, который:

    • Открывает транзакцию.

    • SELECT COUNT(*) FROM Users WHERE early_adopter = true

    • Изменяет (по ссылке) модель задавая в ней поле-флаг EarlyAdopter.

    • INSERT INTO Users ...

    • Закрывает транзакцию. При вышеупомянутой ошибке выполнение этого метода повторяется.

  • Метод (use case) регистрации юзеров в слое приложения создаёт анемичную модель User не задавая в ней поле-флаг EarlyAdopter, передаёт её (по ссылке) в GlobalRepository.RegisterUser(), возвращает (изменённую репозиторием) модель клиенту вызвавшему этот use case.

Лучше уж везде UoW использовать, чем Serializable ставить или каждый use-case анализировать, чтобы нужный уровень выбрать, нет?

Полагаю, серебряной пули всё ещё нет и это зависит от ситуации.

К примеру, Serializable по умолчанию действительно использовать лучше, чем анализировать требуемый уровень изоляции для каждого use case - но только при условии, что во-первых используется именно PostgreSQL (в остальных РСУБД AFAIK Serializable не оптимизирован аналогичным образом и всё ещё самый медленный из всех уровней изоляции) и во-вторых мы автоматизировали повтор транзакций по вышеупомянутой ошибке.

Что до UoW, то этот подход по сути решает проблему долгих транзакций ценой ручной реализации аналога какого-то уровня изоляции (уже реализованного в РСУБД). Плюс UoW - это ORM. В результате получаем ряд недостатков: это медленно (и потому что ORM, и потому что UoW повторно реализует уже реализованный и гораздо лучше оптимизированный функционал РСУБД и накладывает свой поверх штатного, получая накладные расходы обоих механизмов изоляции), нет ясности какому уровню изоляции соответствует конкретная реализация UoW, без понимания уровня изоляции могут быть баги, а с пониманием могут потребоваться дополнительные хаки (вроде упомянутого Вами создания отдельного агрегата RegistrationManager, который, кстати, придётся обновлять одновременно с созданием агрегата User - что, вообще-то, нарушает рекомендации DDD как в плане использования неясных бизнесу технических сущностей в слое домена так и в плане одновременного изменения нескольких агрегатов в одной транзакции).

Как видите, даже на первый взгляд у UoW хватает недостатков. Если у нас DDD, плюс уже используется ORM, плюс уже есть реальные проблемы из-за длинных транзакций - в таких условиях использование UoW вполне может быть оправдано.

Там - это при использовании DDD с Serializable транзакцией на весь use case или в Transaction Script? :-)

Там - это без повторения всего use case и даже без изменения модели в памяти (как я процитировал ранее), оба примера модель меняют и весь кейс повторяют

потребоваться дополнительные хаки

в DDD как раз рекомендуют не создавать агрегаты из воздуха, а чтобы один создавал другой, так что всё по канонам)

оба примера модель меняют и весь кейс повторяют

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

в DDD как раз рекомендуют не создавать агрегаты из воздуха, а чтобы один создавал другой, так что всё по канонам)

Спасибо за ссылку, я такой подход раньше не видел. (В контексте нашей дискуссии на тему консистентности и изоляции транзакций довольно забавно, что под статьёй написано "96 Comments", но ниже все комментарии пронумерованы и последний из них - 46-ой.)

Никоим образом не ставлю под сомнение квалификацию Udi, но на первый взгляд этот подход противоречит основной идее aggregate root: что он обеспечивает свои инварианты для всех содержащихся в нём entities и value objects. Потому что для того, чтобы он мог это сделать, ему нужно обеспечить инкапсуляцию, ограничив прямой доступ снаружи к содержащимся в нём объектам. Поэтому и считается, что ID вложенных в aggregate root сущностей "локальны" относительно ID самого aggregate root и не должны даже передаваться клиентом отдельно от ID aggregate root. А на второй взгляд это может привести к тому, что начав с какого-то общего корня в память придётся подгрузить вообще все данные, иначе не получится обеспечить инварианты всех задействованных сущностей (Udi про эту ситуацию спросили в комментах 15 и 19, и его ответ в 20-м довольно показателен: "при создании объектов делайте INSERT не используя доменную модель а остальные вопросы решайте проект-специфичными способами").

Конечно, Udi прав в том смысле, что у нас всегда будет цепочка породивших друг-друга сущностей. Но это не означает, что у нас используются "динамические aggregate root" формирующие нужную текущему use case иерархию под единым aggregate root как им угодно. Обычно родительский объект-коллекция (если используется) считается отдельным aggregate root, и хранит только ID входящих в него объектов. В результате этот механизм (часть объектов содержат друг друга непосредственно, по ссылке, а часть содержат косвенно, по ID) позволяет обеспечить фиксированный aggregate root (включающий только те объекты, которые ему доступны по ссылке и обеспечивающий инварианты только для них). Это не мешает коллекции, при необходимости, подгружать в память входящие в неё объекты по ID (не обязательно все одновременно). Но, да, при этом подходе в момент добавления нового объекта в коллекцию у нас доменный сервис иногда будет одновременно оперировать двумя aggregate root (коллекцией и созданным элементом) в рамках одной транзакции и оба сохранять явно либо явно обрабатывать ошибку создания и не сохранять никого (а иногда это не потребуется, например потому что коллекцию можно обновить eventually либо объекта-коллекции вообще нет т.к. нет связанных с ней бизнес-инвариантов).

Сам по себе подход Udi имеет право на жизнь, но, на мой взгляд, ему не стоило называть такие динамически формируемые "под use case" группы объектов термином aggregate root. У этого термина уже было устоявшееся определение и оно сильно отличается от описанного Udi.

Резюмируя: это было очень интересно почитать (особенно комменты и ответы Udi), но как аргумент я этот подход принять не могу. В нашем примере необходимость в отдельной доменной сущности RegistrationManager возникла исключительно из-за того, что конкретно UoW технически не в состоянии реализовать необходимый функционал без заведения такой сущности. И я не удивлюсь, если не все пользователи UoW осознают такие нюансы, и некоторые будут пытаться вместо этой сущности делать запросы по БД (что приведёт к багам из-за некорректной изоляции, но ловить такие баги будет очень и очень непросто).

чем анализировать требуемый уровень изоляции для каждого use case

Как раз необходимость анализировать каждый кейс при UoW пока не доказана, у нас во вселенной с ORM у всех все работает без этих анализов, зато с оптимистическими блокировками и обновлениями только изменившихся полей моделей из коробки и не тормозит ничего, переходите на темную сторону)

у нас во вселенной с ORM у всех все работает

А работает ли? Ошибки изоляции (вроде описанной выше в примере) довольно сложно обнаружить на практике. Код выглядящий корректным и формирующий корректную БД сегодня, завтра может записать в БД что-то некорректное. Обнаружится этот факт может очень не сразу (а может и вообще не обнаружится годами), и вызвавший его баг можно никогда не найти. Корректная изоляция транзакций - это очень непросто, именно поэтому мне так нравится возможность использовать Serializable в PostgreSQL без потери производительности.

А работает ли?

Конечно, нет select же в транзакции - нет проблем. Ну и никто даже теоретически пример не смог придумать, почему не сработает и в какой ситуации

При вышеупомянутой ошибке транзакции всё повторяет

Страдать не приходится, когда I/o есть в use case update-а? Его же нужно как-то вне транзакции разместить, но всё ещё в use case?

Страдать не приходится, когда I/o есть в use case update-а? Его же нужно как-то вне транзакции разместить, но всё ещё в use case?

О каком I/O речь? Что-то вроде запроса во внешние API? Да, такое никогда не делают из методов репозитория (т.е. в середине транзакций), эти вызовы всегда до/после транзакций. Нет, проблем это обычно не создаёт, потому что в Transaction Script нет проблем сделать в use case несколько транзакций, перемежая их вызовами внешних API как необходимо.

Да, внешнее api, я понял, в use case у вас транзакция. С несколькими сохранениями на use case понятно, но тоже нарушает принцип 1 транзакция на запрос ну или всеми нелюбимые саги нужны, чтобы либо все сохранения прошли, либо ни одного

В Transaction Script нет принципа "1 транзакция на запрос", это чисто DDD-шная тема.

Так а если часть не сохранится, какая разница transaction script у нас или нет, неконситетность будет в базе же?

Так а если часть не сохранится, какая разница transaction script у нас или нет, неконситетность будет в базе же?

Не будет никакой неконсистентности.

Если брать простой (без сторонних вызовов API) кейс DDD "считать, изменить, сохранить", то в Transaction Script вместо всего этого будет один вызов репо.

Если брать комбинацию вызова API и модификации модели, то тут ни от DDD ни от Transaction Script ничего не зависит: для консистентности обычно необходимо вызов API делать либо до либо после транзакции но никогда в середине транзакции. Просто в DDD эти "до/после транзакции" зачастую приводят к лишним сложностям (разделение одного use case на два шага и добавление доменных событий для перехода между ними), а в Transaction Script это легко и явно пишется в соседних строках use case.

Если же у нас какая-то распределённая транзакция включающая вызов API (который необходимо отменить другим вызовом API если наша транзакция в БД провалится), то здесь тоже техники и DDD и Transaction Script будут одинаковые: ручная реализация WAL (write-ahead log) запланированных и выполненных действий через последовательность транзакций в нашей БД. Просто, опять же, в Transaction Script вся эта последовательность будет аккуратно записана в одном use case, а в DDD её придётся разделять на шаги и посыпать кучкой доменных событий.

В Transaction Script + Serializable это работает так:
В GlobalRepository создаётся метод RegisterUser(*User) который открывает транзакцию, изменяет (по ссылке) модель

Тогда получается, что в репозитории находится часть бизнес-логики, if (count < 10) это реализация бизнес-требования. Лучше если и транзакция, и "if" будут в сервисе/юзкейсе, а репозиторий будет просто выполнять запрос на получение количества. Как мне кажется, "if" должен быть где-то рядом с "new Order", в одном методе или классе/модуле.

Фактически нам надо обеспечить последовательное выполнение регистраций как бизнес-процессов. Я бы сделал через мьютекс с pg_try_advisory_lock или его аналогом в других БД. Контроллер вызывает сервис (use case), сервис ставит лок с именем "RegisterUser", получает количество пользователей через UserRepository.GetEarlyAdoptersCount(), делает установку флага в модели по условию, сохраняет модель, возвращает в контроллер. Мы делаем Serializable вручную, зато управлять этим проще, можно делать любые сетевые вызовы и транзакции, и работать будет не только на Postgres. Минус в том, что надо ставить лок везде, где могут создаваться пользователи, но таких мест обычно немного, как правило 1.

Тогда получается, что в репозитории находится часть бизнес-логики

Всё верно. В Transaction Script фактически нет слоя домена, есть только слой приложения (use cases) и слой инфраструктуры (реализация репозитория), при этом бизнес-логика действительно размазана между кодом use cases и кодом репозитория. Частично это компенсируется тем, что сигнатуры методов GlobalRepository формируются по тем же принципам, что и методы моделей в DDD - т.е. не InsertUser а RegisterUser, эти методы умеют возвращать бизнесовые ошибки, их документация описывает неочевидные (из сигнатуры) особенности реализуемой ими бизнес-логики и никто их не воспринимает как тупые обёртки над БД.

Лучше если и транзакция, и "if" будут в сервисе/юзкейсе, а репозиторий будет просто выполнять запрос на получение количества.

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

Как мне кажется, "if" должен быть где-то рядом с "new Order", в одном методе или классе/модуле.

В принципе, такое возможно в т.ч. и для Transaction Script - в этом случае в метод репозитория RegisterUser мы передадим параметром необходимый ему кусочек бизнес-логики (отдельным объектом через интерфейс, либо обычной функцией-коллбэком). К сожалению, у этого подхода есть серьёзный недостаток: репозиторий теряет контроль над тем, какой код выполняется в середине транзакции. Если переданный ему коллбэк полезет во внешние API и будет долго работать - это может всё поломать и в результате вместо своих плюсов и своих минусов мы получим решение, которое вообще плюсов не имеет, зато включает все возможные минусы и DDD и Transaction Script.

при этом бизнес-логика действительно размазана между кодом use cases и кодом репозитория

Так а зачем так делать-то?) Можно же не размазывать.

это совершенно другой подход

Он отличается от описанного вами только тем, что "new User" находится в реализации метода RegisterUser, а он вынесен в другой класс (ну или модуль).
"Лучше" потому что оба этих действия находятся на одном уровне детализации, и такой код точнее моделирует требования. "Создать в системе пользователя с данными из формы регистрации. Если он один из первых 10, то пометить его как "early adopter"". Оба требования это первый уровень детализации действия "Регистрация пользователя".

мы передадим параметром необходимый ему кусочек бизнес-логики

Зачем передавать "new User" коллбэком? "if" же мы так не передаем.

Вот конкретный пример, как раз с учётом ORM:

Больше всего тоже смущает (если я правильно понял), что транзакцию открывают уже на SELECT-е, не понятно, с какой целью

Sign up to leave a comment.

Articles