MediatR за межами Vertical Slice Architecture. Чому ви можете використовувати його неправильно

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті.

Мене звати Юрій Івон, я співпрацюю з компанією EPAM як Senior Solution Architect. Хотів би поділитися своїми міркуваннями щодо доцільності використання бібліотеки MediatR в .NET проєктах.

Основною ціллю Архітектури вертикальних зрізів (Vertical Slice Architecture), яка запропонована Джимі Богардом, є спрощення підтримки через організацію коду навколо елементів функціоналу (features) замість рівнів (layers). Я б виразив загальні рекомендації цього підходу у двох реченнях:

  • Тримайте всі елементи коду, які належать до однієї бізнес-операції, якомога ближче один до одного.
  • Тримайте всі бізнес-операції ізольованими на рівні коду одна від одної, наскільки це можливо.

Наступна діаграма ілюструє різницю між традиційним багаторівневим підходом і архітектурою вертикальних зрізів:

Батагорівнева архітектура

Архітектура вертикальних зрізів

Дотримуючись цієї ідеї, можна побачити такі наслідки для практики програмування:

  • Абстракції не створюються заздалегідь. Наприклад, на другій діаграмі немає репозиторіїв.
  • Жоден код не вважається спільним заздалегідь. Спільне використання додається через рефакторинг тільки тоді, коли воно дійсно потрібне. Деякі доменні об’єкти використовуються тільки в межах однієї операції, обробник замовлень не використовує код, який був написаний для читання продуктів із бази даних у межах іншої операції.

Кожній бізнес-операції відповідає окремий клас обробника, що нагадує шаблон проєктування Command. Схожа абстракція може бути додана для спрощення реалізації та підтримки обробників. І це одна з причин, чому бібліотека MediatR була створена — вона надає абстракцію для обробників операцій та гнучкий пайплайн, через який запити йдуть до обробників. Це означає що MediatR дуже добре вписується в архітектуру вертикальних зрізів і не повинен викликати ніяких питань в цьому контексті.

Але така архітектура — не «срібна куля» і водночас з розв’язанням деяких проблем з підтримкою коду, вона вразлива до деяких інших, таких як дублювання та жорстка зв’язаність бізнес-логіки з доступом до даних. Як підкреслив сам Джимі Богард, ваша команда повинна дуже добре розуміти типові проблеми в коді (code smells) і рефакторинг для того, щоб досягти успіху.

MediatR в інших архітектурах доданків

Я бачу, що MediatR стає все більш популярним в .NET проєктах, які не слідують архітектурі вертикальних зрізів. Нещодавно я стикнувся з проєктом, де відносно невеликий WEB API сервіс використовує MediatR для виклику бізнес-логіки з контролерів, в той час, як решта коду побудована на традиційному багаторівневому підході. Такий варіант викликає питання і сумніви, якими я хочу поділитись.

Ось що ви можете отримати при використанні MediatR в багаторівневій архітектурі:

Щоб зрозуміти, чи дає цей підхід якісь переваги, підсумуймо, як прихильники MediatR обґрунтовують його використання:

  • Сприяє слабкій зв’язаності коду.
  • Допомагає слідувати принципу одного обов’язку (Single Responsibility Principle).
  • Розв’язує проблему надмірної кількості залежностей в конструкторі.
  • Спрощує навігацію в коді через розбиття великих сервісів на невеликі класи, які легко можна знайти в Solution Explorer.
  • Дає можливість реалізовувати наскрізний код (cross-cutting code).

Сприяє слабкій зв’язаності коду?

Так, але в чому покращення в порівнянні з традиційним підходом, коли контролери залежать від інтерфейсів сервісів?

Технічно, MediatR дійсно робить код дуже слабко зв’язаним: клієнт нічого не знає про сервіс, окрім класу з параметрами обробника. Чи можна це розглядати як перевагу? На мою думку — ні. Такий варіант ізоляції все ж не захищає розробника від низькорівневих деталей у контракті: деякі типи запитів та параметри все одно можуть бути дуже специфічними для реалізації і ви отримаєте логічну зв’язаність, не дивлячись на використання шаблону, який наче спрямований на запобігання цьому.

Це одна з типових проблем в сучасній розробці програмного забезпечення — люди надто багато уваги приділяють конкретним шаблонам та технологіям, забуваючи про фундаментальні принципи проєктування, що стоять за ними. Жодний шаблон автоматично не зробить ваш код легшим у підтримці, тому треба завжди дотримуватися базових принципів і слідкувати за якістю коду незалежно від того, який шаблон використовуєте.

І MediatR, і традиційний підхід дотримуються принципу інверсії залежностей, тому сприяють слабкій зв’язаності коду.

Допомагає дотримуватися принципу одного обов’язку (Single Responsibility Principle)?

З традиційним підходом у вас може з’явитися великий сервіс з багатьма обов’язками й сотнями методів, що буде важко підтримувати. З MediatR, розробники змушені інкапсулювати кожну бізнес-операцію в окремому класі, таким чином зменшуючи ймовірність появи «god object» майже до нуля. Звучить перспективно, чи не так?

Почнемо з більш детального прикладу використання MediatR у веб сервісі.

Тут можна чітко побачити, що MediatR дає більш гранулярне розділення обов’язків між класами бізнес-логіки. Однак, ця діаграма дуже спрощена, тому треба розглянути ще деякі речі.

Перше питання — як реалізовувати спільну логіку для декількох обробників. Всі операції читання можуть використовувати одні й ті самі базові запити до бази даних, операції створення та оновлення можуть щось записувати в загальні таблиці після того, як основні зміни вже зроблені. Досить часто така загальна логіка є деталлю реалізації сервісу і повинна бути ізольована, тому, зазвичай, вона знаходиться в приватних методах того самого сервісу. Додавання допоміжного класу з публічними методами, методу розширення або окремого сервісу для такої загальної логіки не є варіантом, тому що буде порушена ідея інкапсуляції: деталі потрібні тільки одному сервісу будуть доступні іншим частинам коду. Таким чином, єдине що залишається — базові класи обробників, які реалізують загальні методи.

Якщо потрібні методи, загальні для всіх операцій, то може бути ще один рівень наслідування, який дає базовий клас для GetOrderHandlerBase та PersistOrderHandlerBase. Це вже виглядає складно, але згодом може ще ускладнитись, адже у вас може з’явитися декілька базових класів, і буде неочевидно, від якого саме наслідувати нові операції.

Ви можете спитати «А в чому різниця з архітектурою вертикальних зрізів? Нам все одно треба буде мати справу з загальною логікою й там». Але різниця є: у традиційному підході розробники намагаються повторно використати й узагальнити якомога більше, у той час, як вертикальні зрізи зміщують фокус із повторного використання. Ймовірність опинитися в ситуації з переускладненим загальним кодом набагато менша, якщо ви слідуєте ідеології вертикальних зрізів. Більше рекомендацій про те, як боротись із дублюванням коду в рамках такої архітектури можна знайти в статті Джимі Богарда.

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

Вирішує проблему надмірної кількості залежностей в конструкторі?

Розглянемо приклад коду з такою проблемою:

public OrdersService(ILogger logger,
    IOrdersRepository ordersRepository,
    INotificationService notificationService,
    IProductsRepository productsRepository,
    IAuditService auditService,
    ISecurityService securityService,
    ILegacyOrdersService legacyOrdersService,
    IPriceCalculationService procesCalculationService,
…

Якщо ви розіб’єте сервіс на декілька класів, де кожен буде відповідати за свою операцію, скоріш за все кожен з них буде мати менше параметрів в конструкторі. Але подивімося краще на першопричину, ніж на її наслідки.

Якщо в конструкторі багато залежностей, скоріш за все цей сервіс має багато обов’язків. Популярне практичне правило каже, що краще не мати більше ніж п’яти залежностей. Звісно, можуть бути винятки, але ця метрика є хорошим і простим індикатором того, коли треба розглянути винос деяких обов’язків в окремий сервіс. Тож чому б не розглядати це насамперед, а не покладатися на шаблон, який ніби сам по собі розв’яже проблему? Як я вже сказав, жоден шаблон автоматично не полегшить подальшу підтримку вашого коду.

MediatR ніби розв’язує проблему надмірної кількості залежностей в конструкторі, але якщо розробники не дотримуються базових принципів ООП, то це просто відкладає проблему у часі.

Спрощує навігацію в коді?

Я чув від деяких розробників, що класи з багатьма методами важкі для навігації в коді, тому краще мати більш гранулярні класи для того, щоб можна було легко знайти потрібні частинки коду в дереві проєкту. Це може здатися розумним, але не всі розробники з цим погодяться. Якщо ви боїтесь класів з багатьма методами, то використовуйте команду «Collapse to definitions» (Ctrl+M, O), яка згорне тіла всіх функцій, і ви зможете розгорнути тільки те, що потрібно. Я б сказав, що краще боятися змішування обов’язків у межах одного класу, ніж боятися просто кількості методів.

Водночас є декілька помітних недоліків:

  • Складніше знайти обробник для запиту при читанні коду. Зазвичай, коли вам потрібно знайти реалізацію методу, що викликається, ви натискаєте «Go to implementation» — це просто й очевидно. З MediatR це вже не працює, і у вас є дві альтернативи:
    • Відкрити діалог пошуку (Ctrl + T) і вставити ім’я класу запиту + «Handler». Або ввести перші великі літери з усіх слів, що входять в назву класу.
    • Викликати команду «Find references» на класі запиту, і якщо у вас небагато коду, що посилається на нього — знайти відповідний обробник в результатах пошуку.
  • Більше зусиль на створення нового методу. Для додання нового методу в сервіс з традиційним підходом, вам потрібні тільки дві прості зміни — додати сігнатуру методу в інтерфейс та додати його реалізацію в сервіс. З MediatR вам потрібно створити два нових класи — запит та обробник. Це забирає більше часу та більше дратує, особливо, коли ваші обробники мають декілька залежностей.

Чи спрощує навігацію в коді поділ одного сервісу на декілька класів — це суб’єктивне питання. Я вважаю, що краще мати всі згуртовані (cohesive) частини в одному файлі, водночас деякі розробники думають навпаки. Ви можете звикнути до таких переходів від типу запиту до відповідного обробника, але будьте готові, що нові члени вашої команди будуть трохи не раді таким практикам на початку. Створення нових методів в контролерах може викликати незручності, але зазвичай немає потреби реалізовувати сотні API методів за короткий проміжок часу.

Загалом, я не можу впевнено сказати, що MediatR спрощує навігацію в коді чи якісь інші тривіальні дії розробників.

Дає можливість реалізовувати наскрізний код?

Так, дає. Але перед тим як робити висновок, чи це може бути аргументом для використання MediatR, давайте трохи поговоримо про альтернативи.

Якщо ви пишете звичайний REST сервіс з використанням ASP .NET Core, то у вас вже є можливість реалізовувати наскрізний код за допомогою middleware або фільтрів. Ви можете заперечити, що це засоби транспортного рівня, а вам потрібно мати наскрізну логіку незалежно від зовнішнього інтерфейсу, тому що ви плануєте використовувати той самий код сервісів через різні протоколи та канали комунікації, можливо навіть плануєте зробити окрему бібліотеку з бізнес-логікою.

В сучасному світі мікросервісів повторне використання бізнес-логіки декількома сервісами через спільну бібліотеку не вважається хорошою практикою. Крім того, є тренд на перенесення наскрізної логіки за межі коду сервісів, щоб розробників ніщо не відривало при роботі над бізнес-логікою. Всі ці API gateways та service meshes переважно про це. Таким чином, навіть якщо вам дійсно потрібно виставити той самий набір операцій через декілька каналів, швидше за все це буде зроблено у вигляді декількох правил на API gateway або додаткових мікросервісів, які адаптують інші протоколи до REST API вашого сервісу.

Якщо такий «передовий» підхід не відповідає вашим умовам і все ще є потреба повторно використовувати сервіс через декілька каналів зв’язку, ви можете розв’язати проблему за допомогою аспектно-орієнтованого програмування та відповідних бібліотек. Якщо швидкість виконання критична, має сенс брати compile-time AOP фреймворки такі як AspectInjector. Щоб мати більше гнучкості під час виконання — Castle Core, Castle.Core.AsyncInterceptor або їх аналоги. Будь-який із них забезпечить більшу гнучкість в реалізації наскрізного коду, ніж MediatR, і не вплине на структуру вашого сервісу та його внутрішні класи.

Мотивація вибору MediatR винятково здатністю реалізувати наскрізний код є помилкою — це все одно, що закручувати гайку плоскогубцями, маючи в ящику інструментів гайковий ключ лише тому, що плоскогубці «можуть це зробити». Те, що MediatR надає такі можливості, є радше приємним бонусом, ніж основною причиною його вибору.

Висновки

Якщо ви зацікавлені в архітектурі вертикальних зрізів і вважаєте, що ваша команда достатньо зріла, щоб працювати над постійним рефакторингом — спробуйте. MediatR є природним вибором у цьому випадку та не викликає зайвих питань.

Однак, якщо ви збираєтеся додати MediatR до багаторівневої програми — подумайте двічі, оскільки цей шаблон сам по собі має кілька недоліків:

  • Не дає жодних переваг в контексті слабкої зв’язаності у порівнянні з традиційним підходом.
  • Допомагає дотримуватися принципу одного обов’язку, але може ускладнити структуру класів.
  • Не виглядає, що він полегшує розробникам тривіальну роботу з кодом, таку як навігація в коді або створення нових операцій. Якщо якесь покращення і є, воно дуже суб’єктивне.
  • Його використання не може бути обґрунтоване тільки потребою реалізовувати наскрізний код.

Коли ми розглядаємо MediatR в контексті архітектури вертикальних зрізів, деякі з цих проблем стають менш вираженими, тому що не дивлячись на описані раніше ускладнення, цей архітектурний підхід може полегшити подальшу підтримку. Це досягається завдяки організації коду навколо елементів функціонала так, що зміна в одному ніяк не може вплинути на інші, і вам не треба стрибати між багатьма різними місцями в коді, щоб реалізувати або змінити окрему функцію.

Коротко кажучи, я б не використовував MediatR в багаторівневій архітектурі. Найбільша проблема, яку я тут бачу, полягає в тому, що інструмент, створений для підтримки однієї ідеології, використовується в іншій без повного розуміння, чому він взагалі створений.

Це переклад моєї статті з medium.com.

Сподобалась стаття? Натискай «Подобається» внизу. Це допоможе автору виграти подарунок у програмі #ПишуНаDOU

👍ПодобаєтьсяСподобалось3
До обраногоВ обраному0
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

На мою думку одним з важливих нюансів використання MediatR успішно є передумова побудови так званого Task-based interface, коли команди доволі гранулярні а квері зазвичай вертають багато інформації. З описаного прикладу в мене склаласу думка що за основу взято простий CRUD (Get Order, Create Order, Get Product, Create Product), і тут я погоджусь що змісту в використанні бібліотеки немає. Проте коли мова іде про task-based зазвичай дії більш гранулярні, і часто компонують кінцевий об’єкт (продукт, чи ордер) в декіка кроків.

Архітектурно за допомогою бібліотеки простіше реалізовувати Clean Architecture, так як більшість оркестрації буде в командах, навіть якщо вони залежні на інші інтерфейси бізнес рівня, і таким чином команда є бізнес агрегатором, в той час як залежності на application layer interfaces в контролері і агрегацію юз кейсів там є швидше порушенням Clean Architecture, бо контролери належать до інфраструктури, яка є несуттєвою і часто міняється.

Декілька слів по секціях:
— Допомагає дотримуватися принципу одного обов’язку (Single Responsibility Principle)? Знову ж таки архітектурно бібліотека допомагає реалізувати підхід CQS, або CQRS (залежить від вимог проекту і потреби). Останній передбачає використання Materialized Views — коли читання вертає велику кількість інформації. На моїй практиці це так і працює, тому за декілька років використання не зустрічався з описаною проблемою коли є десяток кверів які детально вертають дані. Швидше за все тут щось не так архітектурно, і вибір бібліотеки для цього кейсу був хибний.

— Вирішує проблему надмірної кількості залежностей в конструкторі? Допомагає, і задає тон на вищому рівні, привчаючи розробників до того що кількість залежностей можна і треба обмежувати.

— Спрощує навігацію в коді? На перших проектах де використовував бібліотеку справді було трохи незручно мати квері і хендлер в різних файлах. Проте в C# 9 додали records, і так як command гранулярна (читай вище чому) то зазвичай record має 2-3 проперті і пишеться в одну лінійку коду не перевищуючи 120-150 символів. Таким чином і команді і хендлер в одному файлі. Проблема навігації вирішена

— Дає можливість реалізовувати наскрізний код? Часково погоджусь, проте автор згадує контректні бібліотеки для AOP, і насправді на ринку для .NET їх доволі обмежена кількість. До того ж перформанс вимагає кращого. Проте не погоджусь з «Будь-який із них забезпечить більшу гнучкість в реалізації наскрізного коду, ніж MediatR, і не вплине на структуру вашого сервісу та його внутрішні класи.» — я пробував різні варіанти для AOP, проте з MediatR вдалось створити найкращий варіант який працює як для REST комунік так і для messaging, коли перед командою виконуються пайплайни. Думаю тут справа смаку, проте памятати потрібно, що AOP це як мін окремий концепт, і який на рівні компіляції може пропускати низку потенційних проблем, у випадку використання runtime AOP.

На основі висновку — застосування в багаторівневій архітектурі, це дуже абстрактне потяння, так як зазвичай є декілька варіантів імплементації цієї архітектури, і так як бібліотека це деталь імплементації, то тут порівняння нетотожне. Я б виходив з аналізу типу реалізації, і розглядав MediatR з точки зору того, які проблеми в цій реалізації вона допоможе вирішити.

Дякую за розгорнуту відповідь, є над чим замислитись. Якщо знаєте проекті на гітхабі, де MediatR успішно використовується не в парадигмі Vertical Slice Architecture — пошарте, будь ласка.

З приводу коментарів по секціях:

Знову ж таки архітектурно бібліотека допомагає реалізувати підхід CQS, або CQRS (залежить від вимог проекту і потреби)

Приклад з CQRS був і у Богарда в поясненні переваг Vertical Slice Architecture, але чисто технічно я не бачу великої проблеми зробити це і без MediatR в «класичній» архітектурі.
Просто в кожен контролер (якщо це треба в межах одного контролеру) буде інжектатися два сервіси — для Query і для Command.

Допомагає, і задає тон на вищому рівні, привчаючи розробників до того що кількість залежностей можна і треба обмежувати.

Воно так, але тут я би ставив акцент що і без MediatR девелопери повинні слідкувати за кількістю залежностей. Тобто принципи — первинні, а не «я використовую MediatR, тому в мене з розділенням обов’язків все завжди буде добре» (доводилося читати і таке в дискусіях).

До того ж перформанс вимагає кращого.

Compile-time фреймворки не програють за перформансом, run-time — програють здебільшого в часі старту програми, а для сервісів ця доля часу зазвичай не критична. Але, як ви вірно замітили, run-time мають ще деякі недоліки.

я пробував різні варіанти для AOP, проте з MediatR вдалось створити найкращий варіант який працює як для REST комунік так і для messaging

Цікаво, а що саме у випадку з AOP виглядало або працювало гірше?

Нажаль на гітхаб проекти таких посилань немаю. Проекти, з якими мені видавалось працювати в приватних репозиторіях.

Приклад з CQRS був і у Богарда в поясненні переваг Vertical Slice Architecture, але чисто технічно я не бачу великої проблеми зробити це і без MediatR в «класичній» архітектурі.
Просто в кожен контролер (якщо це треба в межах одного контролеру) буде інжектатися два сервіси — для Query і для Command.

Я пробував такий підхід на одному з сервісів в якості експерименту — як результат такі сервіси (їх інтерфейси) дуже швидко виросли, і потрібна дисципліна і постійний нагляд за цим. Та й з неймінгом починаються цікаві штуки типу QuerySuperService, etc.

Словом, це все можна, але для чого придумувати те, що вже реалізовано і працює доволі добре? З мого досвіду дизайн системи завжди і без того складний, і простіше використати існуючий інструмент, а не витрачати ресурси на побудову свого, який в кінці буде вирішувати ті ж задачі, і вимагати ефорти для її підтримки.Звичайно, якщо одним з constrains є вимога не використовувати вендорні бібліотеки то мій коментар не є доцільним.

Цікаво, а що саме у випадку з AOP виглядало або працювало гірше?

Проблеми з відсутністю типізації, це те що найбільш вистрілювало в ногу. Все працює, до поки хтось не вирішить змінити ієрархію базових типів, або щось на кшталт того. Останній раз я з цим стикався в 2019-2020, можливо щось змінилось, і я помиляюсь.

Автор непогано почав, але згодом продемонстрував повне нерозуміння суті того, що описує.
Основна перевага шаблону якраз в слабкій звязаності, ізольованості, така собі варіація на тему мікросервісів під одним дахом моноліта.
А в висновках, чомусь:

Не дає жодних переваг в контексті слабкої зв’язаності
може ускладнити структуру класів.
Не виглядає, що він полегшує розробникам тривіальну роботу з кодом, таку як навігація

Ви не читали статтю і не зрозуміли висновки, соррі. Ви напевно кажете про Vertical Slice Architecture, а більша частина статті про те — чому не має сенсу використовувати MediatR в звичайній багаторівневій архітектурі. І про пункти які ви процитували теж детально написано чому так. З MediatR в контексті VSA все якраз норм.

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

В свій час для нового проекту вирішили використати MediatR. Для великих і складних фіч він заходить прямо чудово. Проблеми починають коли потрібно реалізувати пачку невелеких фіч. Приходиться писати просто кучу boilerplate коду що зовсім не зручно. Звичайно можна для таких фіч його не використовувати, але тоді виходить що різні частини проекту написані по різному. Що в свою чергу виглядає дивно.

В результаті вирішили випилить MediatR повністю і підчас його випилювання придумали як взяти краще від всіх підходів. Так, наприклад, якщо треба зробити велику фічу, то її можна винести в окремий хендлер і інжектить тільки його прямо в метод контролера. В новому asp net core ніяких MediatR навіть для цього не потрібно

public class SignInHandler
{
        private readonly IOptions<AuthConfig> authConfig;
        private readonly CipherService cipherService;
        private readonly AuthRepository authRepository;
        private readonly TokenProvider tokenProvider;

        public SignInHandler(
            IOptions<AuthConfig> authConfig,
            CipherService cipherService,
            AuthRepository authRepository,
            TokenProvider tokenProvider)
        {
            this.authConfig = authConfig;
            this.cipherService = cipherService;
            this.authRepository = authRepository;
            this.tokenProvider = tokenProvider;
        }

        public async Task<Result<SignInResponse>> Handle(SignInRequest request, CancellationToken cancellationToken)
        {
            // ...
        }
}

   // ...

[ApiController]
[Route("api/auth")]
public class AuthController : Controller
{
        [HttpPost("signin")]
        public async Task<ApiResult<SignInResponse>> SignIn(
            SignInRequest request,
            [FromServices] SignInHandler signInHandler,
            CancellationToken cancellationToken)
        {
            return await signInHandler.Handle(new Services.Users.SignInRequest(request.Email, request.Password), cancellationToken);
        }
}

Як тут код нормально вставляти?)

UPDATE: Треба використовувати тег pre а не code

Дякую за інформацію з вашого досвіду. А вставляти код тегом «code» можна, наскільки я розумію :)

От якраз ні) треба використовувати тег pre, а не code

Підписатись на коментарі