Пиррова победа Domain-Driven Design

d9bf3b072c546f32067e3a7ecc5e0f05

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), а не агрегату.

© Habrahabr.ru