Pull to refresh
356
1.1
Alex Efros @powerman

Systems Architect, Team Lead, Lead Go Developer

Send message

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

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

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

Также с Rich domain model в сущность помещаются все изменяющие ее бизнес-действия. Это приводит к тому, что сущность превращается в God-object, и код получается более сложный в поддержке.

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

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

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

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

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

Никакая логика не принадлежит сущности

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

Потом кто-то встречает статью Фаулера про Anemic Domain Model, где он говорит, что в сервисах логики быть не должно, и начинаются попытки ее оттуда убрать.

Есть такое, но это просто от непонимания сути вещей и слепого доверия авторитетам. Анемичная модель не является анти-паттерном в общем смысле. Она является анти-паттерном в DDD. Если проект пишется не по DDD, а как Transaction Script, то анемичная модель вполне корректный паттерн.

Другой пример - фильтры на странице списка сущностей. Это именно бизнес-логика, работа полей обсуждается на уровне бизнеса.

Не в DDD. Когда DDD обсуждает Rich Domain Model то под бизнес-логикой подразумевается исключительно то, что изменяет эти модели. В терминологии CQRS - речь исключительно о командах. Запросы же, которые не изменяют сущность, в DDD рекомендуется делать ровно так же, как и в Transaction Script: грузить прямо из БД специализированными запросами в анемичную модель, сформированную под конкретный use case (т.е. фактически - под текущие нужды UI).

Логику фильтров часто помещают в репозиторий. Репозиторий не должен содержать бизнес-логику, это не его ответственность.

См. выше. В данном случае, по DDD, это не бизнес-логика и ей самое место в репозитории.

Свойства сущности это детали ее реализации, которые нужно скрывать.

Речь не о том, что их нельзя прочитать, а о том, что их не должно быть возможности изменить снаружи. Возьмите любой пример из книжки IDDD - там все свойства обычно объявляются { get; private set; }.

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

Ну как это не сможет. Создаем в сущности новый метод, там устанавливаем свойства как нам нужно.

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

Сущность должна сама проверять свои инварианты.

Часто нет таких правил, которые должны соблюдаться во всех сценариях.

Давайте я начну слово, а вы сами его договорите: со-фи-…! Из того, что бывают требования специфичные не для модели, а для конкретного use case, никак не следует отсутствие требований, которые специфичны именно для модели и должны соблюдаться именно всегда. Поэтому одни разумно реализовать методами модели, а другие в доменных сервисах.

Что моделирует сервис?

Может появиться вопрос - если сервис это часть доменного слоя, то что он моделирует?

Ничего. Именно поэтому в DDD доменные сервисы должны быть stateless. У них нет собственных данных. По сути сама идея доменного сервиса именно как "сервиса" во многом вызвана ограничениями конкретных ООП языков того времени, которые не поддерживали другие парадигмы кроме ООП, из-за чего в них не было "просто функций". Поэтому и понадобился "доменный сервис", как пустой объект без данных, который позволил объявить на себе группу обычных функций как методы этого пустого объекта. В мультипарадигменных языках вполне можно вместо доменного сервиса использовать обычные функции, суть DDD от этого никак не пострадает.

Цель статьи

Полагаю: Chaos, panic and disorder - my work here is done! ©

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    • INSERT INTO Users ...

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Information

Rating
1,237-th
Location
Харьков, Харьковская обл., Украина
Date of birth
Registered
Activity