Это — немного вольный перевод статьи "Maybe it's time to rethink our project structure with .NET 6" от Tim Deschryver про подход к созданию сервисов с помощью Minimal APIs, который может помочь нам сделать архитектуру приложения более чистой, простой и легкой в поддержке и развитии.
Пост кажется мне вдохновляющим продолжением идей vertical slice architecture и некоторым ответом излишней разделенности и несвязности обработчиков MediatR и мест их вызова.
C релизом .net 6 у нас появился новый упрощенный подход для быстрого создания сервисов — Minimal APIs. Эта статья появилась потому, что с новым подходом появились новые вопросы, связанные с организацией кода внутри проекта.
Но давайте вначале посмотрим, как выглядит Minimal APIs.
Что такое Minimal APIs
Minimal APIs правда сводят конфигурацию и код к минимуму, отказываясь от контроллеров и Startup.cs
. Команда dotnet new web
создаст новый проект с одним файлом кода — Program.cs
:
WebApplication
│ appsettings.json
│ Program.cs
│ WebApplication.csproj
В Program.cs
используется top-level statements для настройки, сборки и запуска приложения. Всё занимает 4 строки кода:
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/", () => "Hello World!");
app.Run();
Если запустить этот код, то у вас будет сервис, который умеет принимать запрос и отвечать на него.
Это выглядит непривычно. Раньше структура папок проекта .NET Web API состояла из файла Program.cs
(с методом Main для запуска API), файла Startup.cs
(с методами ConfigureServices и Configure для настройки сервисов и пайплайна обработки запросов) и папки Controllers
с файлом контроллера, содержащим эндпоинты приложения.
WebApplication
│ appsettings.json
│ Program.cs
│ Startup.cs
│ WebApplication.csproj
│
├───Controllers
│ Controller.cs
В большинстве приложений и примеров, которые я видел, эта структура сохраняется и служит основой, а новые слои строятся поверх нее по мере роста проекта и сложности. Структура существующего API, вероятно, выглядит вариацией к такому разделению по "техническим" зонам ответственности в рамках одного проекта, либо разные слои разделяются на несколько проектов.
WebApplication
│ appsettings.json
│ Program.cs
│ Startup.cs
│ WebApplication.csproj
│
├───Configuration/Extensions
│ ServiceCollection.cs
│ ApplicationBuilder.cs
├───Controllers
│ ...
├───Commands
│ ...
├───Queries
│ ...
├───Models/DTOs
│ ...
├───Interfaces
│ ...
├───Infrastructure
│ ...
Подобную структуру можно увидеть в проекте dotnet-architecture/eShopOnWeb, основанном на принципах из книги Architecting Modern Web Applications with ASP.NET Core and Azure.
Это отраслевой стандарт, если следовать ему, то даже в незнакомом проекте по такой структуре можно быстро сориентироваться. Посмотрев на контроллеры и их устройство, мы сразу можем понять, какие действия умеет делать сервис и как он общается с другими уровнями приложения.
Но теперь Minimal API не диктует нам первоначальную структуру проекта. Может быть, самое время пересмотреть её? У нас есть несколько вариантов.
API в одном файле
Самый простой способ добавить функциональность в Minimal APis — это просто продолжить добавлять эндпоинты, обработчики, вспомогательные методы и конфигурацию в файл Program.cs
. Но файл быстро раздуется, а разный код будет смешан в одном месте.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped<ICustomersRepository, CustomersRepository>();
builder.Services.AddScoped<ICartRepository, CartRepository>();
builder.Services.AddScoped<IOrdersRepository, OrdersRepository>();
builder.Services.AddScoped<IPayment, PaymentService>();
var app = builder.Build();
app.MapPost("/carts", () => {
...
});
app.MapPut("/carts/{cartId}", () => {
...
});
app.MapGet("/orders", () => {
...
});
app.MapPost("/orders", () => {
...
});
app.Run();
API с контроллерами
Второй вариант — вернуться к знакомому и привычному. Мы всё ещё можем добавить контроллеры в проект и использовать их. В шаблонах приложений даже остался проект с контроллерами.
WebApplication
│ appsettings.json
│ Program.cs
│ WebApplication.csproj
│
├───Controllers
│ CartsController.cs
│ OrdersController.cs
Чтобы использовать контроллеры, нужно зарегистрировать их в приложении с помощью метода IServiceCollection.AddControllers()
и сделать маппинг обработчиков и маршрутов для них с помощью MapControllers()
:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddScoped<ICustomersRepository, CustomersRepository>();
builder.Services.AddScoped<ICartRepository, CartRepository>();
builder.Services.AddScoped<IOrdersRepository, OrdersRepository>();
builder.Services.AddScoped<IPayment, PaymentService>();
var app = builder.Build();
app.MapControllers();
app.Run();
Проблемы текущей структуры проекта
Такая структура делит приложение на слои по зонам ответственности кода — маршрутизация, бизнес-логика, хранение данных. Чтобы добавить новую функциональность, придётся изменить существующие файлы в нескольких слоях и, возможно, создать новые. Во время отладки вам приходится перемещаться между многочисленными файлами и слоями. Способ реализации простых и сложных эндпоинтов в этом случае не сильно отличается — нужно внести много изменений в нескольких слоях приложения. Простые и сложные эндпоинты движутся по одному конвейеру, а реализация простого эндпоинта становятся значительно сложнее, чем необходимо. К сожалению, чем больше кода требуется, тем выше вероятность совершить ошибку.
А ещё контроллеры тоже имеют тенденцию раздуваться со временем.
Domain Driven Api
Что если мы перейдем от традиционной структуры папок, разделяющей приложение на горизонтальные слои, к доменно-ориентированной структуре, в которой приложение группируется по его доменам. Каждый домен приложения сгруппирован в модуль (или фичу) в отдельной папке.
Структура простого приложения, использующего модульный подход, будет выглядеть примерно так:
WebApplication
│ appsettings.json
│ Program.cs
│ WebApplication.csproj
│
├───Modules
│ ├───Cart
│ │ CartModule.cs
│ └───Orders
│ OrdersModule.cs
На первый взгляд это незначительное изменение. Оно может немного запутать, потому что теперь неочевидно, откуда начинать чтение кода. Чтобы понять преимущества этой структуры, посмотрим подробно на файлы.
Структура похожа на Domain Layer, описанный в статье Domain model structure in a custom .NET Standard Library.
Что такое модуль
Модуль состоит из двух частей:
Внутренней логики и обработчиков.
Методов подключения модуля к проекту.
Минимальная реализация модуля — это класс с двумя методами, первый для настройки DI-контейнера и второй для регистрации эндпоинтов. Это чем-то похоже на старый Startup.cs
или на новый Program.cs
, но для отдельного модуля. Основное преимущество: всё, что нужно модулю, изолировано внутри, и можно быстро понять, какие зависимости он потребляет. Это облегчит поиск ненужного кода. И будет полезно при написании тестов, потому что позволит сделать изолированную систему для модулей.
public static class OrdersModule
{
public static IServiceCollection RegisterOrdersModule(this IServiceCollection services)
{
services.AddSingleton(new OrderConfig());
services.AddScoped<IOrdersRepository, OrdersRepository>();
services.AddScoped<ICustomersRepository, CustomersRepository>();
services.AddScoped<IPayment, PaymentService>();
return services;
}
public static IEndpointRouteBuilder MapOrdersEndpoints(this IEndpointRouteBuilder endpoints)
{
endpoints.MapGet("/orders", () => {
...
});
endpoints.MapPost("/orders", () => {
...
});
return endpoints;
}
}
Чтобы подключить модуль, нам нужно вызвать в Program.cs
два созданных метода из модуля. Когда мы это сделаем, модуль будет подключен к приложению.
var builder = WebApplication.CreateBuilder(args);
builder.Services.RegisterOrdersModule();
var app = builder.Build();
app.MapOrdersEndpoints();
app.Run();
Такой подход позволит сохранить Program.cs
простым и четко разделить модули и их собственные зависимости.
Настройка общей инфраструктуры (например, логирование, аутентификация, мидлвары, swagger, …) приложения также остается в Program.cs
, потому что она используется во всех модулях.
Чтобы узнать, как можно настроить популярные библиотеки, посмотрите Minimal APIs at a glance by David Fowler и MinimalApiPlayground by Damian Edwards.
Чтобы добавить новые модули, мы должны снова создать класс модуля, добавить методы регистрации и настройки, а затем вызвать их в Program.cs
, но с помощью небольшой абстракции часть рутины можно автоматизировать.
Автоматическая регистрация модулей
Чтобы автоматизировать процесс регистрации нового модуля, нам понадобится добавить новый интерфейс IModule
. Этот интерфейс мы будем использовать для поиска всех модулей, реализующих данный интерфейс в приложении. Мы ищем модули с помощью рефлексии и регистрируем каждый найденный модуль в приложении.
public interface IModule
{
IServiceCollection RegisterModule(IServiceCollection builder);
IEndpointRouteBuilder MapEndpoints(IEndpointRouteBuilder endpoints);
}
public static class ModuleExtensions
{
// this could also be added into the DI container
static readonly List<IModule> registeredModules = new List<IModule>();
public static IServiceCollection RegisterModules(this IServiceCollection services)
{
var modules = DiscoverModules();
foreach (var module in modules)
{
module.RegisterModule(services);
registeredModules.Add(module);
}
return services;
}
public static WebApplication MapEndpoints(this WebApplication app)
{
foreach (var module in registeredModules)
{
module.MapEndpoints(app);
}
return app;
}
private static IEnumerable<IModule> DiscoverModules()
{
return typeof(IModule).Assembly
.GetTypes()
.Where(p => p.IsClass && p.IsAssignableTo(typeof(IModule)))
.Select(Activator.CreateInstance)
.Cast<IModule>();
}
}
Отрефакторенный модуль заказов, реализующий интерфейс IModule
:
public class OrdersModule : IModule
{
public IServiceCollection RegisterModules(IServiceCollection services)
{
services.AddSingleton(new OrderConfig());
services.AddScoped<IOrdersRepository, OrdersRepository>();
services.AddScoped<ICustomersRepository, CustomersRepository>();
services.AddScoped<IPayment, PaymentService>();
return services;
}
public IEndpointRouteBuilder MapEndpoints(IEndpointRouteBuilder endpoints)
{
endpoints.MapGet("/orders", () => {
...
});
endpoints.MapPost("/orders", () => {
...
});
return endpoints;
}
}
Program.cs
теперь использует extension-методы RegisterModules()
и MapEndpoints()
для регистрации всех модулей в приложении.
var builder = WebApplication.CreateBuilder(args);
builder.Services.RegisterModules();
var app = builder.Build();
app.MapEndpoints();
app.Run();
С добавлением интерфейса IModule
мы избавляемся от проблемы постепенного раздувания Program.cs
и не даем возможности выстрелить себе в ногу, если забудем зарегистрировать свежедобавленный модуль. Чтобы зарегистрировать новый модуль, достаточно создать новый класс, реализовать интерфейс IModule
, и все.
Этот модульно-ориентированный подход очень похож на проект Carter.
Структура модуля
Преимущество такого подхода заключается в том, что каждый модуль становится самодостаточным и может развиваться независимо.
Простые модули настраиваются просто, а более сложные модули сохраняют гибкость и могут включать более сложную процедуру настройки. Например, простые модули могут разрабатываться в одном файле (то, что мы видели выше) и следовать подходу "API в одном файле", в то время как сложные модули могут быть разделены на несколько файлов:
WebApplication
│ appsettings.json
│ Program.cs
│ WebApplication.csproj
│
├───Modules
│ └───Orders
│ │ OrdersModule.cs
│ ├───Models
│ │ Order.cs
│ └───Endpoints
│ GetOrders.cs
│ PostOrder.cs
Вдохновлено проектом ApiEndpoints Стива "ardalis" Смита. Более подробно об этом паттерне можно прочитать в его статье "MVC Контроллеры это динозавры — используйте API эндпоинты" или в примере dotnet-architecture/eShopOnWeb.
Какие ещё преимущества есть у такой структуры?
Структура на основе доменов группирует файлы и папки по их (под)доменам. Это облегчает навигацию и понимание того, как и с чем работает конкретный модуль. Больше не нужно прыгать между слоями по всем папкам, чтобы найти код, который делает то, что вам нужно, потому что всё находится везде.
И ещё это помогает сделать приложение максимально простым — не начинать с абстракций разных уровней на любой случай. То есть, вы должны начать с простого проекта, содержащего одну или несколько папок с модулями. Модуль должен начинаться как один файл и разделяться, когда в нем становится трудно ориентироваться. Если это произойдет, вы можете разделить модуль на разные файлы, например, извлечь эндпоинты в их собственные файлы. Конечно, если вы хотите сохранить единообразие, то можете использовать одну и ту же структуру во всех модулях. Короче говоря, структура ваших модулей должна отражать простоту или сложность домена.
Например, вы создаете приложение для управления заказами. Ядром приложения является сложный модуль заказов, который разделен на несколько файлов. Несколько других вспомогательных модулей содержат простые CRUD-операции, поэтому они реализованы в одном файле, чтобы сократить время.
В приведенном ниже примере модуль Orders
является основным доменом, содержащим все бизнес-правила, поэтому эндпоинты перемещаются в папку Endpoints
, где каждый эндпоинт получает свой отдельный файл. Модуль Carts
— вспомогательный. Он содержит несколько простых методов, и реализован в виде одного файла.
WebApplication
│ appsettings.json
│ Program.cs
│ WebApplication.csproj
│
├───Modules
│ ├───Cart
│ │ CartModule.cs
│ └───Orders
│ │ OrdersModule.cs
│ ├───Endpoints
│ │ GetOrders.cs
│ │ PostOrder.cs
│ ├───Core
│ │ Order.cs
│ │───Ports
│ │ IOrdersRepository.cs
│ │ IPaymentService.cs
│ └───Adapters
│ OrdersRepository.cs
│ PaymentService.cs
Развитие проекта: готовимся к неопределенности
Со временем проект растёт и накапливает знания о предметной области (домене). В модуль-ориентированной структуре достаточно просто превратить модуль в отдельный сервис. Если модуль является самодостаточным, то можно скопировать папку модуля в отдельный проект или новое приложение. В принципе, модуль можно рассматривать как плагин, который легко переместить.
Удалять и объединять модули тоже несложно: в первом случае достаточно удалить папку модуля, а во втором — переместить понятным образом организованные файлы или код.
Выводы
Такой подход к организации кода — это моя попытка продолжить философию Minimal APIs и свести код к необходимому минимуму. Я хочу, чтобы сервис стал простым и удобным в поддержке, сохранив возможности для расширения.
Цель состоит в том, чтобы уменьшить связность и запутанность разных частей приложения. Вместо этого приложение должно быть разделено на ядро и модули, которые являются самодостаточными и независимыми.
Не каждому модулю нужна сложная конфигурация. Разделив приложение на домены, становится проще отделять каждую часть от общего блока настройки. Внутри модуля у нас остается гибкость и возможность создавать разную структуру. Конечная цель такого подхода в том, чтобы было легче начать новый проект или присоединиться к существующему, и сделать поддержку более простой.
Когда я сравниваю текущую структуру проекта и структуру с разделением на модули, то становится очевидно, как много хлама можно выкинуть. С одной стороны, один файл с эндпоинтом, который легко найти, с другой стороны эндпоинт, логика которого проваливается на несколько слоев в нескольких папках. Зачастую между этими слоями ещё есть и некоторые дополнительные преобразования. Например, мой текущий поток обработки для любого запроса выглядит примерно так:
Controller |> MediatR |> Application |> Domain |> Repository/Service
Сравните это с подходом из этой статьи:
Endpoint (|> Domain) |> Service
Я понимаю, что эти слои появились не просто так, но времена изменились. Всего пару лет назад такие слои были крайне важны для тестирования приложения, но в последние несколько лет мы наблюдаем революцию функциональных тестов, о чем можно почитать в статье "Как тестировать C# Web API". Это еще один важный момент в пользу того, чтобы максимально упростить код и постараться сократить количество интерфейсов, создавая их только тогда, когда они действительно нужны (например, при взаимодействии со сторонним сервисом). С Minimal APIs стало проще конструировать объекты явно с помощью оператора new вместо того, чтобы полагаться на DI-контейнер.
Мы рассмотрели только структуру проекта Minimal APIs, и в примерах все файлы включены в один проект. Следуя этой архитектуре, вы по-прежнему можете выделить слой Core/Domain и слой Infrastructure в разные проекты. Будете вы это делать или нет? Зависит от размера проекта, и, как мне кажется, было бы неплохо поговорить об этом, чтобы быть одной волне. Лично у меня нет однозначного мнения на этот счет.
Главное не усложняйте, просто делайте проект простым.
Комментарии (19)
Boniface
06.04.2023 11:09Здравствуйте, отличная статья. Подскажите, как лучше реализовать работу с разделяемыми между модулями моделями, классами с функционалом и расширениями. А так же со слоем данных. Если у нас entity framework используется.
Vadimyan Автор
06.04.2023 11:09+1Мне не кажется, что тут есть однозначный ответ.
Можно смотреть на то, что говорит DDD по этому поводу. Между несколькими bounded context могут быть "одинаковые" сущности, которые отражают разные данные и разное представление об одном объекте реального мира, но могут иметь одинаковые свойства. В доменах они будут представлены разными типами и даже храниться в разных таблицах на уровне БД. В этом случае ответ понятный — разным доменам могут соответствовать разные контексты.
При хранении в одной таблице на самом деле тоже можно разделять контексты (EF Core умеет маппить в двух контекстах одну таблицу с разным набором свойств). Другое дело, что при последующем возможном разделении сервисов, когда один из модулей будет переноситься в отдельный сервис, мы получим не микросервисы, а распределенный монолит, который ковыряется в одной базе, ещё и в одной таблице разными сервисами.
Наверное, самый подходящий ответ — зависит. Потому что вопрос тут в сценарии развития. В самой статье есть фраза "Настройка общей инфраструктуры (например, логирование, аутентификация, мидлвары, swagger, …) приложения также остается в
Program.cs
, потому что она используется во всех модулях". В этом смысле DbContext может быть общим в рамках всего приложения (и это даже не будет противоречить DDD потому что контекст БД это не слой домена).И, собственно, тоже самое можно сказать и про другие разделяемые между модулями зависимостями - если это не протекание доменного слоя, то вполне нормально шарить такие вещи.
Evengard
06.04.2023 11:09+1Я честно говоря пришёл к почти такому же (ещё в эпоху .NET 5), только у меня один модуль - это одна Assembly/соотв. отдельный проект. И резолвит рефлексия у меня зависимости через зависимости между Assembly - вычитывает от каких Ассембли текущий модуль (или сам энтри-поинт) зависит, и в них уже ищет IModule-и (да, я даже назвал их точно так же). Соответственно IModule дальше рекурсивно ищет уже свои зависимости таким же способом (в дефолтной имплементации) и тоже их подвязывает. Только у меня дефолтная имплементация ещё и сами сервисы внутри модулей регистрирует через рефлексию через поиск классов с выставленными атрибутами, в которых прописывается время жизни сервисов.
Gromilo
06.04.2023 11:09+1Самая сложная часть - это не структура проекта, а разделение на модули.
Пройдёт совсем немного времени, прежде чем появится папка Common. А потом юзинги на типы из других модулей и мы получим всё тот же ком грязи.
Можно сказать "а вы следите за изоляций модулей, проектируйте домен и всё будет хорошо". Но на практике почему-то так не получается, т.к. требуется дисциплина и небольшие "срезания углов" будут постепенно накапливаться. Изоляция предполагает дублирование, которое сложно обосновать, когда всё рядом и решарпер сам подключает юзинги.
Кроме дисциплины, единственный известный мне путь - это физический запрет смешения кода модулей: тесты архитектуры, чтобы не выкладывалось в случае нарушения архитектуных правил, разделение модулей на сборки, чтобы физически нельзя было подключить лишнее или микросервисы.
Вообще, я за разделение на модули, но без фанатизма, не слишком мелко, т.к. изоляция тоже увеличивает сложность.
Vadimyan Автор
06.04.2023 11:09Эта же проблема всё равно была в случае контроллеров — доменный слой в разных bounded context не должен перемешиваться. Просто контроллеры не подсвечивали явно разные контексты и могли наоборот запутать. Роут
/users/{id}/orders/
же просится подумать, что User тут тот же самый, что и в/users/{id}
, хотя в первом случае у нас статус покупателя, размер скидки и прочие атрибуты клиента, а во втором учетные данные и всякие статусы рассылки.Но я согласен, что держать рамку DDD без дополнительных ограничений и/или проверок не всегда очень просто при развитии проекта.
LordDarklight
06.04.2023 11:09+1private static IEnumerable<IModule> DiscoverModules() { return typeof(IModule).Assembly .GetTypes() .Where(p => p.IsClass && p.IsAssignableTo(typeof(IModule))) .Select(Activator.CreateInstance) .Cast<IModule>(); }
Может я что-то не понимаю, но данный код подключает модули только из сборки, где объявлен интерфейс IModule, доступный по контексту в данной процедуре. Не глупо ли так ограничивать область выбора подключаемых модулей?
Vadimyan Автор
06.04.2023 11:09Мне кажется, что это вопрос организации кода и реализации. А код из статьи это просто пример.
Можно держать модули в проектах сервисов или внешних проектах, а сервисы в одном репозитории/солюшене.
В зависимости от способа публикации (будут ли попадать "лишние" сборки в папку приложения) тут может быть разный подход, но при реализации нужно будет помнить, что есть шанс выстрелить себе в ногу.
А при локальном дебаге поведение может отличаться, если в этом случае все сборки складываются в одну папку.Если делить модули на проекты и сервисы на репозитории, то искать по всем сборкам логичнее и этот код авторегистрации нужно переписать.
LordDarklight
06.04.2023 11:09Оно то верно. Ну мне показалось, что код текущая реализация совсем уж наивная - мне показалось автор хотел не показать какое-то красивое "авторское" решение для задачи модульности (а не просто описать Minimal APIs) - но это я так, к слову, скорее на заметку тем, кто будет читать и брать на вооружение не особо вникая в детали
AdAbsurdum
06.04.2023 11:09+1Не очень новый подход на самом деле. См. например https://habr.com/ru/companies/jugru/articles/447308 раздел "Организация по модулям, а не слоям" (2019). И это наверняка не самый ранний источник. Только меняем ненавистный контроллер на минимал апи
Vadimyan Автор
06.04.2023 11:09Согласен, концепция встречалась раньше много раз. Тут как будто сложно найти самый ранний источник. Потому что не совсем понятно, что именно считать началом.
Про feature folders в asp.net mvc писали ещё в 2013ом.
Статья "Vertical slices in ASP.NET MVC" из 2016. И тоже без изоляции фич/модулей.
Сложная настройка поиска вьюх по папкам в статье Feature Slices for ASP.NET Core MVC из MSDN Magazine. Тоже 2016 год.
В "Организации по модулям, а не слоям" на мой взгляд не хватает изоляции и самодостаточности модулей, которая бы включала отделении регистрации доменнозависимых зависимостей внутри модуля.
Мне кажется, что идея как раз в том, чтобы применить идею модулей к Mininal APIs и минималистичному подходу к конфигурации приложения, который пришёл на смену
Startup.cs
. Если раньше модули приходилось реализовывать, прикладывая дополнительные усилия, то в современном .net это делать легче, чем соблюдать "традиционный" подход.
dprotopopov
А статья точно не запоздала?
Vadimyan Автор
И .NET 7 на мой взгляд расширил эту концепцию с помощью тех же Route groups (и общего развития этого типа приложений). Но в разговорах о применимости minimal APIs я всё ещё довольно часто встречаю подход "Minimal APIs для экспериментов / микропроектов, для всего крупнее используйте контроллеры".
Идея изначальной статьи, кажется, в том, что стоит посмотреть на Minimal APIs как полноценную замену контроллеров. И это ещё и облегчит реализацию domain-driven подхода. В общем случае это касается в разной степени всех MVC-фреймворков.
unclejocker
Я прочитал и вспомнил, что есть у нас микро(нано)сервис, в котором один контролер с одним методом и подумал "и правда, а зачем там контроллер?". Переписал.
kurilovigor
Зачем контроллеры когда есть прекрасный FastEndpoints :)
Vadimyan Автор
На самом деле безконтроллерность же у нас была и раньше в разных проявлениях, а не только в "Minimal APIs"-like виде.
Уже, кажется, не поддерживаемый Nancy, который мне кажется довольно похожим на FastEndpoints.
Тот же Carter, на который есть ссылка в статье.
Довольно необычный ServiceStack, в котором
[Route]
вообще вешается на модели.FastEndpoints после Minimal APIs выглядит самым привычным из всех. При этом он всё ещё смешивает маршрутизацию и обратку в одном месте, а в каком-то идеализированном варианте я вижу их разделенными. Условно, это
app.MapGet<TRequest, TResponse>("myRoute")
, который умеет находить хендлер по переданным generic-параметрам. Но в таком случае мы опять возвращаемся к проблемам навигации, как с MediatR.dprotopopov
+
Evengard
.NET 6 - LTS версия.