Комментарии 10
Боль узнаваемая, сам прошёл этот путь на проекте примерно в 200к строк NestJS. Feature-based структура выглядит логично в начале, потом начинается перекрёстное использование сервисов между модулями, circular dependencies, и в итоге модули которые по названию независимы по факту связаны со всем остальным. У нас спас явный запрет на cross-module service injection кроме как через shared-модули, и правило что если сервис нужен в двух местах он переезжает в core. Интересно как вы решаете проблему агрегации данных из нескольких доменов, это всегда самое больное место.
Благодарю за развёрнутый отклик. Я опубликовал остальные части, агрегацию из нескольких доменов в части 4: если коротко, каждый модуль публикует наружу не «весь свой Service», а отдельный external/-порт; use-case, склеивающий данные из двух доменов, живёт в третьем модуле и импортирует только external’ы соседей — граф остаётся DAG’ом. Формальное обоснование, почему это держит форму на дистанции, — в части 5.
Хорошо стыкуется с моим опытом.
Но больше пока ничего не добавлю: статья слишком тяжёлая для пятницы
Ну вот, на самом интересном месте, давайте вторую часть 🫠
Спасибо за интерес! Все пять частей уже доступны часть 2 по ссылке, дальше из неё. В двух словах: 2-3 про то, как декомпозиция и forwardRef оборачиваются убытками в деньгах, 4 про подход, который снимает проблему, 5 формальное обоснование на теории графов.
Насколько я понял основная идея - это выделить каждый метод из service в handler и затем как из кирпичиков строить дом. И направление зависимостей. Но, скажем так, в C#, которым я пользуюсь, у меня никогда не было проблем с циклическими зависимостями. Но проблемы все равно остаются.
External service в итоге вырождается в тот же самый бывший service, только внутри не собственно бизнес код, а вызовы хендлеров.
С тем же успехом можно было сделать так
interafce ICreateUserHandler
{
Result CreateUser();
}
interafce IGetUserByEmailHandler
{
Result GetUserByEmail();
}
interafce IUserServiceExternal : ICreateUserHandler, IGetUserByEmailHandler
{
}
class UserService : IUserServiceExternal
{
Result CreateUser()
{
...
}
Result GetUserByEmail()
{
...
}
}И в контроллер инжектить именно IUserServiceExternal, но не плодить 100500 классов хендлеров. То есть формат размещения кода особо не влияет.
Я то в своих проектах тоже пришел к этим условным handler, и так, чтобы их можно было собирать в цепочки еще, внутри оркестраторов или внутри друг друга (это кстати отражает BMPN / IDEF0). Но по результату могу сказать, что это особо не помогло побороть сложность.
Хендлер вроде бы один, но начинают появляться условные ветки, от которых код внутри хендлера должен вести себя немного по разному. Ну например когда создаем заказ из UI и из API, там немного разные правила. Начинаешь думать, что с этим делать. И там дилемма - либо делать два хендлера, где по началу 95% одинаковое (привет дублирование и любое изменение, которое нужно в двух хендлерах - нужно не забыть делать в двух хендлерах). Либо начинать мутить и выводить общий код в новый общий хендлер, либо делать что-то вроде шаблонных методов, или цепочек декораторов. И все снова скатывается в говнокод и кучу зависимостей.
И еще я все же ждал в статье блок, посвященный транзакциями. Там это реально проблема.
Вот пример недавней новой фичи.
Мы обрабатываем заказы в фоновом режиме. У нас есть хендлер, который качает их из внешней системы, там же обернуто в транзакцию и retry policy, причем как к внешней системе, так и в момент сохранения в БД. Далее у нас есть хендлер на обработку заказа, хендлер на отмену, хендлер на удаление. Каждый из них в транзакции и обернут в retry policy. Эти бизнес-процессы были сделаны ну допустим пару лет назад.
И вот приходит запрос сделать задержку в обработке заказов. Мы его должны скачать, сохранить, обработать частично (чтобы резерв товара прошел), но не отправлять на склад. И потом просто ждать. По истечении времени, например 4 часа - нам надо заново скачать заказ из внешней системы (потому что он мог изменится за 4 часа) и снова прогнать через процессинг. Но! По другим бизнес правилам, мы не можем дублировать заказы и нам надо как-то умудрится снять резерв, не потеряв его (потому что мы пообещали уже, что у нас есть товар под первую копию заказа).
То есть нам сначала надо прогнать отмену и удаление первой копии ордера и только потом создать новый. То есть, набирая этот новый процесс существующими хендлерами - оркестратор выглядит так
1. Handler 1: Скачать новую копию заказа
a) тут внимание, некоторые внешние системы требуют отправить подтверждение,
что мы скачали заказ, но мы не можем это сейчас вызвать, потому что
мы еще не сохранили новую копию заказа, то есть нам надо вызывать этот
хендлер как бы частично
2. Handler 2: Отменить старую копию
а) Handler 2-1: Отменить отгрузки (1 .. N) [у нас на UI есть кнопка
для отмены одной конкретной отгрузки, то есть это реально еще один
вложенный самостоятельный хендлдер]
б) Handler 2-2: Отменить сам заказ [отменить заказ без отмены всех
вложенных отгрузок нельзя]
3. Handler 3: Создать заказ (он уже и так вызывается из трех мест -
UI, API, плагины/вебхуки)
4. Handler 4: Запустить обработку заказа, но без отправки отгрузкок на склад,
это фоновая операция, там у нас очередь [это еще один пример хендлера,
который тоже вызывается из 2-3 мест, но именно тут надо его вызывть
частично как бы]
5. Handler 5: На самом деле это продолжение Handler 1, потому что теперь
наконец-то мы можем подвердить внешней системе, что мы скачали новую
копию заказа И вот теперь представьте, что каждый хендлер тут имеет свою retry policy, этот оркестратор может упасть на ЛЮБОМ этапе, но нужно обеспечить повторяемость этой сложной операции, потому что, если мы потеряем заказ - клиент придет с жалобами и нам придется заплатить штраф.
Ну и чтобы жизнь медом не казалась - DDD тут не совсем поможет, потому что в первую очередь оно начинает тормозить на постоянных операциях восстановления сущности/агрегата из хранилища и потом сохранения нового состояния. Например хендлер "отмена отгрузки" внутри вызывает хендлер "возврат резерва товара". Это работа со складом, которая может быть выполнена только в один поток (чтобы не продать больше, чем есть на складе) и надо максимально ускорить и стабилизировать этот процесс, избавившись от кучи лишних обращений по сети и длинных транзакций. И в итоге этот модуль лежит в хранимых процедурах в БД, и все операций выполняются за один сетевой вызов к БД.
Спасибо за такой развернутый комментарий. На самом деле вы подняли уровень обсуждения сильно выше того, что я рассматривал в серии. Я специально почти не затрагивал архитектуру бизнес-процессов, оркестрацию, транзакционность, идемпотентность, retry policy, компенсации, long-running операции, оптимизацию, восстановление состояния и т.д. Потому что хотел сначала изолированно показать более базовую проблему: как ведут себя зависимости внутри кода и почему одни архитектуры имеют тенденцию к росту связности и размыванию границ, а другие структурно ограничивают этот процесс. То есть серия была скорее про структурную деградацию системы, а не про деградацию бизнес-процессов.
По поводу примера с IUserServiceExternal тоже хороший момент. Но тут есть важная оговорка: серия писалась вокруг NestJS и TypeScript, а у них немного другие ограничения и возможности по сравнению с C#. В C# есть partial class и можно работать через интерфейсы, композицию, по другому организовывать DI и сборку сервисов. Поэтому конкретная реализация вполне может отличаться в зависимости от возможностей языка и фреймворка.
Для меня тут важнее был не сам формат handler vs service, а сохранение направленности зависимостей и DAG-структуры системы. То есть чтобы при росте проекта граф зависимостей оставался ацикличным и не начинал постепенно размывать границы модулей.
И отдельно спасибо за пример с заказами. Он очень показательный, потому что там сложность уже начинает жить не в зависимостях модулей, а в эволюции самих бизнес-процессов, оркестрации и обеспечении повторяемости сложных сценариев.
Да, да, вы как бы больше боролись с графом зависимостей в NestJS и TypeScript. C# как будто бы в этом плане получше и проблема сложности перетекает на новый уровень.
И я еще забыл упомянуть один критерий в плане организации кода. Выделение каждого бизнес-метода в отдельный класс - снижает шанс на конфликты мержа в системах контроля версий. Это когда над проектом работают много людей и, допустим нескольким из них в спринте поставили задачи, которые затрагивают один большой файл - условный UserService. Чем мельче дробление, тем меньше шанс конфликта.

Feature Based Clean Architecture. Часть 1: Эволюция NestJS-приложения в неподдерживаемое состояние