Comments 12
Спасибо, получился подробный материал с примерами. Часто статьям про чистоту кода и архитектуру этого не хватает.
На мой взгляд, то, к чему Вы пришли, пытаясь отобразить бизнес-процессы с применением CQRS-принципа в коде конкретного приложения, получилось идейно близко к чистой архитектуре (Clean Architecture https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html).
Я читал CQRS Documents by Greg Young. И там есть некоторые вещи, которые меня смущают:
Изначально Бертран Мейер вводил понятие CQS для объектно-ориентированного дизайна. И определяет read-only и write-only методы, которые меняют объект (состояние, данные). Это ближе к понятию чистой функции, без побочных эффектов.
Грег Янг адаптировал CQS под DDD. Я же адаптирую под анемичную модель. И у него точно такая же сборная солянка:
public void Handle(DeactivateInventoryItem message) // <-- обработчик команды и dto { var item = _repository.GetById(message.InventoryItemId); // <-- запрос внутри команды item.Deactivate(); // <-- собственно бизнес-код по изменению состояния _repository.Save(item, message.OriginalVersion); // <-- старый добрый save-changes }
Грег Янг вводит понятие task-based команд. Т.е. это такие штуки, которые запускают сложный бизнес-процесс, а не просто обновляют часть данных. Моя Story похожа на task-based команду. Там тоже все намешано, но я не называю ее командой, которая изначально призвана только менять состояние объекта.
Далее, в случае с DDD команда будет дергать входную точку доменной модели. Но сначала надо загрузить ее состояние из хранилища в оперативную память. Формально при вызове команды внутри не будет query, так как команда обращается к уже загруженной доменной модели. А по факту чем ее будут грузить? Методами-запросами, которые не изменяют состояние. Почему об этом ни слова?
CQRS — это отдельная тема про разделение модели (и следовательно хранилищ). Например, одно для записи с 3НФ, и несколько оптимизированных для чтения с 1НФ (кстати аналог view в реляционных БД). В моем случае — внутри Story можно отправить dto в очередь сообщений и на той стороне несколько 1НФ хранилищ его подхватят и изменят свое состояние. Там это будет контекст обработчика сообщения из очереди с вызовом команды. Причем это будет чистая идеальная команда — она только поменяет состояние и ничего не возвратит. Story там излишняя.
Event Sourcing — тоже отдельная тема. Не обязательно этим пользоватся в составе CQS/CQRS/DDD. Любой компонент, который отслеживает баланс (денежный или складских остатков) по сути и есть event sourcing.
Если посмотреть статью www.codeproject.com/Articles/5283291/Examples-of-Layered-Application-Architecture-Based, то там Story это фасад слоя логики (facade sublayer logic layer) и рассматривается использование этого фасада в разных типах приложений.
Единственное в чём не могу с Вами согласиться так это то, что одна Story может использовать другие Story. Story это точка входа для каждого юнита логики приложения и на мой взгляд точка входа не должна быть напрямую переиспользована в других точках входа. Хотя безусловно одну и ту же Story можно вызывать из разных контроллеров.
По существу — допустим, но вопрос так и остается — куда складывать переиспользуемый код? В query/command нельзя, если это ни то ни другое. В бизнес-сервис? Есть вероятность, что он начнет пухнуть. Можно конечно принудительно на ревью кода ограничить один класс-один метод. Но тогда это и будет моя Story, просто другое наименование.
Собственно в этом и суть — один класс-один метод. Как их назвать — да как душе угодно. Но метод, по возможности, не должен иметь побочных эффектов, делать только одну вещь (только брать из хранилища, только сохранять в хранилище, только вычислять, только обращаться к внешнему ресурсу) и быть законченным логическим блоком (т.е. делать полную операцию на текущем уровне абстракции/инкапсуляции, а не ее часть). Не везде получится это сделать, но надо стремиться увеличить количество таких методов. Например GetWeatherStory из статьи делает кучу вещей. Но каждая ее составляющая часть делает одну маленькую вещь. А целиком она законченный логический блок — выдает погоду, оставляя хранилище в согласованном состоянии, без дублей и так далее.
Можно даже пойти дальше и прокидывать Action/Func. Я об этом думал в свое время, но там начинаются проблемы с подтягиванием зависимостей. Надо тогда все прокидывать как параметры функции и это всплывает на уровень фреймворка/платформы. Недавно кстати была статья по этому поводу, которая в более полной мере описывает этот подход habr.com/ru/company/jugru/blog/545482.
Насчет UseCase — да, соглашусь, есть условно верхнеуровневые story (которые вызываются из контроллеров) и вспомогательные (рассчитать что-то по алгоритму, обратиться к внешнему сервису, отправить уведомление и так далее — у них как раз вероятность переиспользования выше). Не проблема разделить их с помощью имен. Например GetWeatherUseCase, но внутри нее GetWeatherFromExternalServiceStory (-> и теперь можно сократить имя до GetWeatherStory или RequestWeatherStory). Но тут палка о двух концах. Если работа ведется с бизнес-аналитиком и соответствующей документацией — имена story должны совпадать с блоками в документации, чтобы потом новому программисту было легко их найти и сопоставить. В таком случае стиль именования должен быть согласован на более высоком уровне. Так же возможен случай, когда код в UseCase вдруг станет переиспользоваться. Тогда его придется раскидать заново по новым UseCase/Story.
>>GetWeatherStory из статьи делает кучу вещей
и
>>вспомогательные Story(рассчитать что-то по алгоритму, обратиться к внешнему сервису, отправить уведомление и так далее — у них как раз вероятность переиспользования выше)
могут быть решены достаточно стандартно для многослойной архитектуры.
1. Часть многократно используемого функционала может быть реализована в базовых классах, наследником которых является рассматриваемый Story.
2. Сам функционал слоя логики приложения, который используется объектами Story, реализуется в объектах-сервисах:
— domain services для логики предметной области приложения; если требуется ddd подход вместо сервисов используется функционал, реализованный в объектах-сущностях предметной области;
— infrastructure services для взаимодействия с внешними источниками данных типа очередей сообщений и веб-сервисов;
— persistence services для взаимодействия с внешними хранилищами персистентных данных типа баз данных; в этих сервисах как раз и используются query/command операции.
При таком подходе вспомогательные Story не потребуются. Каждый Story использует нужный ему набор функционала объектов-сервисов.
Базовые классы не очень удобны порой, поэтому лучше их не использовать. Кейс — когда в одной стори надо поведение из двух базовых, а множественного наследования в C# нет. Композиция в данном случае будет лучше.
С сервисами — я уже говорил, что они начнут пухнуть. А потом внутри сервисов захочется обратиться к хранилищу за дополнительными данными (см выше в статье пример с EntityService). Но обращение к хранилищу — это ведь тоже есть запрос или команда. Да и с domain сервисами — вы кажется начинаете затрагивать DDD. В случае с DDD команда будет дергать модель предметной области, а та в свою очередь начнет молотить бизнес-логику внутри себя.
С infrastructure и persistence — да, все так, но вот там такой бизнес кейс, а не инфраструктурный:
1. Покупатель делает заказ в интернет магазине на несколько товаров.
2. Возвращает один из товаров — под этим скрывается некий бизнес-процесс для единицы товара. Это возврат товара на склад и возврат части денег.
3. Но покупатель может так же вернуть все товары полностью. Но ведь внутри этого бизнес-процесса будет повторяется бизнес-процесс для единицы товара в цикле + некоторые оптимизации (иначе например будет N+1 запрос к БД).
Класс фасада имеет один публичный метод которым пользуется слой представления.
Фасад взаимодействует с сервисами напрямую: фасад -> domain services, фасад -> infrastructure services, фасад -> persistence service.
Множественное использование функционала сервисов в классах фасадов и будет переиспользованием кода в слое логики.
Можно использовать наследование и строить иерархию классов фасадов. Но композиция фасадов, когда один фасад является полем внутри другого, выглядит достаточно странно и такой конструкцией не пользуюсь.
Рассмотрим задачу «Покупатель делает заказ в интернет магазине на несколько товаров».
1. Добавление каждого товара или выбранного набора товаров (например при помощи чекбоксов на визуальной форме) это одно отдельное обращение к классу фасада бизнес-логики.
2. Перевод списка выбранных товаров в статус заказа — одно отдельное обращение к классу фасада бизнес-логики.
3. Возврат товара из заказа — одно отдельное обращение к классу фасада бизнес-логики.
4. Возврат всех товаров из заказа — одно отдельное обращение к классу фасада бизнес-логики.
CQS (CQRS) со своим блэкджеком