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

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

Здесь "неизбежно" используется ровно в том же смысле, что и во всем знакомом тезисе "Big Ball of Mud неизбежно приводит к невозможности развивать проект". Многие Big Ball of Mud проекты могут либо не просуществовать достаточно долго чтобы столкнуться с неприемлемым увеличением сложности внесения изменений, либо успеть полностью реализовать весь необходимый функционал до наступления этого момента и не нуждаться в дальнейшем развитии. И тогда "неизбежно" для них не случается - просто не успевает. Но неизбежным оно от этого быть не перестаёт…

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

Кстати, лидеры DDD (Eric Evans, Vaughn Vernon, Udi Dahan, Greg Young, …) знают об этой проблеме. В статье Vaughn Vernon "Effective Aggregate Design Part II: Making Aggregates Work Together" от 2011 года (которую они все ревьювили) она описана так: "If complete failure occurs it may be necessary to compensate, or at a minimum to report the failure for pending intervention.". Что значит: если мы не смогли откатить/компенсировать уже выполненные шаги саги (при невозможности выполнить следующий шаг), но смогли хотя бы понять это, то нужно попросить человека ручками исправить проблему в БД прода. А вот почему эта проблема неизбежно возникнет в любом DDD проекте я сейчас расскажу.

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

DDD рекомендует при изменении агрегата сохранять его в БД целиком, в одной транзакции. Но разработчики склонны слишком сильно беспокоиться о целостности данных, поэтому данная рекомендация естественным образом ведёт к тому, что большая часть данных проекта “слипается” в один гигантский агрегат. К сожалению, так это не работает (инфраструктура не справляется) - начинаются тормоза и массовые отмены транзакций. Поэтому, чтобы данный подход работал (не создавал проблем с производительностью и частых конфликтов между транзакциями) добавляется довольно жёсткая рекомендация: делать агрегаты как можно меньше. Рекомендация Эванса: спросить бизнес нужна ли ему немедленная согласованность данного инварианта или можно использовать eventual consistency, и если бизнес устроит второе (подсказка: чаще всего - устроит!) то выносить часть большого агрегата в отдельный(ые) агрегат(ы) и согласовывать их между собой с некоторой задержкой, через доменные события. А чтобы следование этой рекомендации не приводило к другой крайности (в которой почти все агрегаты состоят из одной сущности), Эвансом была добавлена ещё одна: задаться вопросом, является ли обеспечение согласованности между этими частями агрегата ответственностью пользователя вызвавшего текущую операцию (use case) - если да, то оставить эти части в одном агрегате, а если нет (это ответственность кого-то другого или самой системы) то разделить и использовать eventual consistency. К сожалению, хотя последняя рекомендация немного облегчила общую ситуацию, она ничего не изменила принципиально: именно бизнес определяет как разделить данные между агрегатами, где необходимо использовать eventual consistency и где саги - т.е. есть ли у нас саги и насколько их много сами разработчики (почти) не контролируют.

Данный подход неизбежно ведёт к тому, что количество применений eventual consistency в проекте увеличивается на порядки и многие из них потребуется реализовывать как сагу (long-running transaction). (Да, не всегда будет необходимость именно в саге - в некоторых случаях последующие шаги в принципе не могут провалиться, и мы можем обойтись обычной eventual consistency без саг… Плохо то, что сможем ли мы обойтись без саг - определяется требованиями бизнеса, а не разработчиками, поэтому рано или поздно саги потребуются. Причем это может случиться не самым явным способом: когда новая фича потребует добавить к уже существующей цепочке eventual consistency новый шаг, который, в отличие от предыдущих, может провалиться - в такой ситуации легко упустить необходимость преобразования всей цепочки шагов в сагу с реализацией логики компенсации всех предыдущих шагов.) А чем больше в проекте саг, тем больше и количество ситуаций, когда выполнение следующих шагов саги может провалиться. Провал потребует отката/компенсации предыдущих шагов… и вот здесь-то и находится ключевая проблема: логику реализующую компенсацию шагов саги практически невозможно написать и поддерживать корректной. Более того, даже понять что она написана некорректно (или внезапно стала некорректной вследствие изменения бизнес-логики где-то далеко от этого места кода) крайне сложно. И причина этого не в технике, а в людях: тут возникает комбинаторный взрыв, и наш мозг просто не справляется с этой задачей. Собственно, это ровно та же причина, из-за которой невозможно сопровождать проекты Big Ball of Mud.

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

Саги, в отличие от традиционных ACID транзакций, не обеспечивают “I” (изоляцию) и являются ACD. Из-за отсутствия изоляции крайне сложно учесть все возможные изменения данных в БД, которые могли произойти между выполнением одного из шагов саги и более поздним моментом, когда этот шаг потребовалось “компенсировать”. Даже если всё было корректно учтено в момент, когда логика компенсации была изначально реализована, будущие изменения в других частях проекта могут привести к тому, что эта логика перестанет быть корректной. Гарантировано отследить все возможные последствия и взаимосвязи практически вне человеческих способностей, так что логика компенсации шагов саг неизбежно будет содержать ошибки. И чем больше в проекте саг и шагов в этих сагах - тем больше будет таких ошибок. А каждая такая ошибка означает, что целостность данных в БД рано или поздно будет нарушена. Что ещё хуже - в большинстве случаев разработчики даже не узнают о том, что логика компенсации сломана, потому что это практически невозможно надёжно протестировать (для этого в тестах нужно учесть все вышеупомянутые последствия и взаимосвязи, которые на практике люди учесть не способны).

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

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

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

  • Достаточно простой микросервис (Bounded Context), в котором можно было легко обойтись без тактического DDD. Если в нём связанных между собой агрегатов почти нет, то не будет и излишней eventual consistency и, тем более, лишних саг.

  • Вы используете большие агрегаты (что противоречит рекомендациям DDD), но:

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

    • Либо проблемы это создаёт, но их игнорируют (а то и вообще о них не знают из-за недостаточного мониторинга).

  • Поддержка компенсаций не реализована вообще (вплоть до того, что там, где должна быть сага, используется просто eventual consistency) либо реализована слишком упрощённо (недостаточно корректно). Пока всё работает по happy flow - проблем из-за этого не будет. А когда что-то ломается, то далеко не факт, что об этом вообще оперативно узнают (снова вопрос мониторинга). А не оперативно это будет выглядеть так (через много месяцев после возникновения проблемы): "о-па, смотрите, тут в БД какая-то фигня… такого не должно быть в принципе… наверное где-то баг… только хз где и мы всё-равно не найдём кто и почему месяцы назад испортил данные, так что фиг с ним!".

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

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


  1. dexie
    15.03.2024 09:51

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

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


    1. GulDmitry
      15.03.2024 09:51
      +1

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

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

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


      1. dexie
        15.03.2024 09:51

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


      1. powerman Автор
        15.03.2024 09:51
        +1

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

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

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

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

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

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

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

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


    1. powerman Автор
      15.03.2024 09:51
      +1

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

      В том-то и дело, что речь не про большие операции, а про сложные агрегаты. Грубо говоря, в ситуации когда запрос юзера обычно приводит к изменению 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, что сильно увеличит вероятность получить проблемы с конфликтами транзакций и/или производительностью.

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

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


      1. dexie
        15.03.2024 09:51

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


  1. dph
    15.03.2024 09:51
    +3

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

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

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


    1. powerman Автор
      15.03.2024 09:51
      +1

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

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

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

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

      Всё так.

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

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

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

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

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

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


  1. Rion333
    15.03.2024 09:51

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

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

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

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


  1. Sabirman
    15.03.2024 09:51

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


    1. powerman Автор
      15.03.2024 09:51
      +1

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


  1. nin-jin
    15.03.2024 09:51
    +1

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


    1. powerman Автор
      15.03.2024 09:51
      +1

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

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


      1. nin-jin
        15.03.2024 09:51
        +2

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

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


        1. powerman Автор
          15.03.2024 09:51
          +1

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

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

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


          1. nin-jin
            15.03.2024 09:51
            +2

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


            1. powerman Автор
              15.03.2024 09:51
              +1

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

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

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

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


              1. nin-jin
                15.03.2024 09:51
                +1

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


                1. powerman Автор
                  15.03.2024 09:51

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

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

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

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


                  1. nin-jin
                    15.03.2024 09:51

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

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

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


                    1. powerman Автор
                      15.03.2024 09:51

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

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

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

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

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

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

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


                      1. nin-jin
                        15.03.2024 09:51

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


                      1. powerman Автор
                        15.03.2024 09:51

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

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

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

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

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

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

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

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

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

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

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


                      1. nin-jin
                        15.03.2024 09:51

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

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


                      1. powerman Автор
                        15.03.2024 09:51

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

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


                      1. powerman Автор
                        15.03.2024 09:51

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


                      1. nin-jin
                        15.03.2024 09:51

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


                      1. powerman Автор
                        15.03.2024 09:51

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

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

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

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

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

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


  1. funca
    15.03.2024 09:51
    +1

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

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

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

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


    1. powerman Автор
      15.03.2024 09:51
      +1

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


    1. ayrtonSK
      15.03.2024 09:51

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


      1. powerman Автор
        15.03.2024 09:51

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


        1. ayrtonSK
          15.03.2024 09:51

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


          1. powerman Автор
            15.03.2024 09:51

            Ко всем остальным 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 (или их урезанные версии).


  1. hyragano
    15.03.2024 09:51
    +1

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

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


  1. TldrWiki
    15.03.2024 09:51

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

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

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


    1. powerman Автор
      15.03.2024 09:51

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

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


      1. TldrWiki
        15.03.2024 09:51

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


        1. powerman Автор
          15.03.2024 09:51

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


          1. Arlekcangp
            15.03.2024 09:51

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

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


  1. murkin-kot
    15.03.2024 09:51
    +1

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

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

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

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

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

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

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

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

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

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


    1. powerman Автор
      15.03.2024 09:51

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

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

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

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


  1. feruxmax
    15.03.2024 09:51

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

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

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

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


    1. powerman Автор
      15.03.2024 09:51

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

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

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

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

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

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

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

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

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


      1. feruxmax
        15.03.2024 09:51

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

        Это понятно, что есть 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-ы, выясняя истинные потребности и перестраивать модель в коде под эти требования.


        1. powerman Автор
          15.03.2024 09:51
          +1

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

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

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

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

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

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

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


          1. feruxmax
            15.03.2024 09:51

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

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

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

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

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


            1. powerman Автор
              15.03.2024 09:51

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

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

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

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

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

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

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

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

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


              1. feruxmax
                15.03.2024 09:51

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

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

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


          1. feruxmax
            15.03.2024 09:51

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

            Тут:

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

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


            1. powerman Автор
              15.03.2024 09:51

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

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

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

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


              1. feruxmax
                15.03.2024 09:51

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

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

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

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

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

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


                1. powerman Автор
                  15.03.2024 09:51

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

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

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

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

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

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

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

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

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

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

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

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

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


                  1. feruxmax
                    15.03.2024 09:51

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

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

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

                    Т.е. если, например, мы имеем сущности 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), саги не нужны будут. По крайней мере если речь идёт о простых случаях, когда саги могли бы быть из-за инвариантов, а не из-за взаимодействий с внешними сервисами.


                    1. powerman Автор
                      15.03.2024 09:51

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


  1. feruxmax
    15.03.2024 09:51

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

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


    1. powerman Автор
      15.03.2024 09:51

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


      1. feruxmax
        15.03.2024 09:51

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


  1. mike_shapovalov
    15.03.2024 09:51

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


    1. powerman Автор
      15.03.2024 09:51

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

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


      1. mike_shapovalov
        15.03.2024 09:51

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


        1. powerman Автор
          15.03.2024 09:51
          +1

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

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

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


          1. mike_shapovalov
            15.03.2024 09:51

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


            1. powerman Автор
              15.03.2024 09:51

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

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


            1. powerman Автор
              15.03.2024 09:51

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


              1. mike_shapovalov
                15.03.2024 09:51

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


                1. powerman Автор
                  15.03.2024 09:51

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


          1. feruxmax
            15.03.2024 09:51

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

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


            1. powerman Автор
              15.03.2024 09:51

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


              1. feruxmax
                15.03.2024 09:51

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


                1. powerman Автор
                  15.03.2024 09:51

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


                  1. feruxmax
                    15.03.2024 09:51

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


                    1. powerman Автор
                      15.03.2024 09:51

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

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


                      1. feruxmax
                        15.03.2024 09:51

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


                      1. powerman Автор
                        15.03.2024 09:51

                        Вот конкретный пример, как раз с учётом 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-шный подход "начать транзакцию, считать весь агрегат, изменить, сохранить весь агрегат, закоммитить транзакцию" никак от последствий недостаточного уровня изоляции транзакций не защищает.


                      1. feruxmax
                        15.03.2024 09:51

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

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


                      1. powerman Автор
                        15.03.2024 09:51

                        Так везде, где не используется 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.


                      1. feruxmax
                        15.03.2024 09:51

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

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