Я, думаю, многие уже слышали о появившихся в .NET 6 Minimal API - легковесной замене контроллеров/MVC. Кто-то уже успел ознакомиться и задался вопросом: "Ваше API в 3 строчки, это, конечно, здорово, но как это будет работать в реальном проекте с сотнями эндпоинтов, кучей фильтров, аттрибутов, расширениями OpenAPI/Swagger и прочих радостях?"

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

А забегая чуть вперед: если думаете, стоит ли переводить проект на Mini API, вот вам сразу полезная информация: они могут жить в проекте вместе, причем даже без дублирования инфраструктуры: не обязательно переводить все разом - подробнее под катом.

Бонусом, заменим SwaggerGen на реализацию OpenAPI от Microsoft.

Зачем использовать Minimal API?

Мы с вами, (надеюсь) не гонимся за модными подходами "просто потому что", поэтому для начала разберемся, зачем переходить на Minimal API:

Из статьи https://piyushdoorwar.medium.com/how-to-use-minimal-apis-in-net-8-without-cluttering-program-cs-e6ea1d513137
  • Простота и гибкость.
    Контроллеры разработанны под MVC, Mini API разработаны специально под API / REPR паттерн. Это позволяет настроить эндпоинты под нужды и структуру вашего проекта, и минимизирует количество повторяющегося шаблонного кода

  • Поддержка Microsoft
    Референсный eShop уже перешел на Mini API, а по изменениям ASP.NET Core 9 и 10 можно заметить, что в Mini API стабильно добавляют новые возможности - куда активнее, чем для MVC. Во многом, конечно, это обусловлено добавлением функционала, который уже есть в контроллерах, но не всегда. Субъективно, контроллеры движутся в статус легаси. Очень медленно, конечно, вряд ли они станут deprecated в каком-нибудь .Net 11 (скорее примерно в .Net 15 - дам такой прогноз. И то, это для статуса легаси, без выпиливания из фреймворка). И это мое субъективное ощущение: официальных заявлений от Microsoft на эту тему не было, лишь косвенные о развитии Mini API.

  • Схожесть с другими фреймворками
    Нам не привыкать к контроллерам, но у большинства других крупных языков - JS (Fastify/Express), Python (Flask, FastAPI), Go (Gin), и так далее, уже давно есть эндпоинты по схожей схеме с MinimalAPI. Во-первых, переходить с языка на язык легче, а во-вторых, это говорит нам о том, что и без контроллеров можно прекрасно жить.

  • Отсутствие рефлексии
    Конфигурация эндпоинтов происходит через методы, явно вызывающиеся в вашем коде, а не поиске контроллеров по всему проекту и получению аттрибутов. Это позволяет настраивать проекты более гибко (например, вы не запихнете в аттрибут массив, или значение из конфига), а также из этого следуюет еще 2 плюса:

  • Поддержка NativeAOT
    NativeAOT - сейчас тренд в мире .Net, и вряд ли контроллеры получат ее поддержку, в отличии от Mini API, у которых она уже есть. Впрочем, бэкенд - это та область, где NativeAOT может ухудшить производительность. Вообще, это тема для отдельной статьи, но вот вам сравнение, и вот краткое объяснение почему. Но не забываем, что ситуация может измениться - .Net развивается быстро.

  • Улучшенная производительность
    Вот и вот вам бенчмарки. Спойлер: разница на несколько процентов по времени выполнения и на десятки процентов по выделенной памяти. В реальности, вероятнее всего бОльшую часть нагрузки будет создавать ваш код, поэтому ждать сильного увеличения RPS не стоит. Но если у вас есть какие-то эндпоинты с десятками и сотнями тысяч RPS, где вы отчаянно боретесь за производительность и каждый вызов GC, перевести их на Mini API может оказаться неплохой идеей, особенно учитывая тренд на улучшения производительности в последних .Net.

Недостатки:

  • Не весь функционал контроллеров пока реализован
    В статье Microsoft можно подробнее посмотреть чего пока нет. Основное - это валидация (решаемо, напишу про это ниже) и нет поддержки View из MVC (но Razor Pages есть)

  • Перенос уже существующих проектов - дни/недели работы
    Даже перенести первый эндпоинт для большого проекта может оказаться задачей на денек-другой: из-за необходимости исправлять все ваши фильтры, аттрибуты контроллеров и т.п. Дальше пойдет быстрее, но внимательной работы все равно хватит. Можете попробовать Cursor натравить на это дело - есть вероятность что прокатит.

Итого, мое личное мнение: если начинаете новый проект/микросервис, особенно с прицелом на .Net 10 (или работаете над пока небольшим проектом), переходить стоит. Если у вас большой энтерпрайз проект, все и так работает, а в бэклоге и так куча задач по рефакторингу, на которые менеджеры не дают времени, то можно пока и пожить с контроллерами. Если, конечно, производительность устраивает и NativeAOT не нужен.

Hello World!

Для тех кто не знаком, в этой главе вкратце расскажу, как написать простые эндпоинты с Mini API. Более подробно можно почитать например в примере от Microsoft или статье на хабре.

var app = WebApplication.CreateBuilder(args).Build();

app.MapGet("/", () => "Hello World!");

app.Run();

Красиво, правда? Ну ладно, реализовывать эндпоинты мы в Program.cs надеюсь не планируем, поэтому усложняем. Добавлю пример под архитектуру из своей статьи про VSA. Введем интерфейс IEndpoint для удобной регистрации эндпоинтов.

public interface IEndpoint
{
	void Register(IEndpointRouteBuilder endpointsBuilder);
}

Наши классы эндпоинтов будут реализовывать этот интерфейс. Хотите реализовывать несколько эндпоинтов в классе? Просто переименуйте интерфейс, например, в IEndpoitns(Provider). Minimal API - довольно гибкая штука, реализуйте как это больше подходит к вашему проекту.
Добавим extension для регистрации:

public static class EndpointsHelper
{
    public static IEndpointRouteBuilder Register<T>(this IEndpointRouteBuilder endpointsBuilder)
       where T : IEndpoint, new()
    {
       var endpointsProvider = new T();
       endpointsProvider.Register(endpointsBuilder);
       return endpointsBuilder;
    }
}

И, например, класс, который будет регистрировать все наши эндпоинты:

public static class EndpointsProvider
{
	public static void RegisterAppEndpoints(RouteGroupBuilder endpointsBuilder)
	{
		endpointsBuilder.Register<GetUserCart>();
	}
}

Хотя, при желании, никто не мешает пробежаться по Assembly и найти все IEndpoint - как это делается в контроллерах. Но я бы не стал.
И добавим его в Program.cs:

// Все эндпоинты будут начинаться с /api
var endpointsBuilder = app.MapGroup("/api");
EndpointsProvider.RegisterAppEndpoints(endpointsBuilder);

Теперь реализуем, сам эндпоинт:

public class GetUserCart : IEndpoint
{
    public void Register(IEndpointRouteBuilder endpointsBuilder)
    {
        endpointsBuilder.MapGet("/user/cart", HandleAsync)
            // Аналог аттрибута [Authorize]
            .RequireAuthorization()
            // Описание эндпоинта для OpenAPI. В контроллерах обычно для этого используют комментарии XML (/// <summary>). Теперь это в коде.
            .WithSummary("Search all available platforms.")
            // Тэг для OpenAPI. Обычно это имя контроллера.
            .WithTags("User");
    }

    // OpenAPI автоматически сгенерирует описание ответа на основе возвращаемого типа.
    private static async Task<QueryableList<UserCartItem>> HandleAsync(int skip, int take, UserContext userContext, ExampleDbContext db, CancellationToken ct)
    {
        var total = await db.CartItems.CountAsync(x => x.UserId == userContext.UserId, ct);
        if (total == 0)
            return QueryableList<UserCartItem>.Empty();
        
        var items = await db.CartItems.Where(x => x.UserId == userContext.UserId)
            .Select(x => new UserCartItem
            {
                Id = x.ItemId,
                Name = x.Item.Name,
            })
            .Skip(skip)
            .Take(take)
            .ToListAsync(ct);

        return new QueryableList<UserCartItem>(items, total);
    }
}

(Я упущу реализацию БД и прочих радостей чтобы не раздувать статью) skip и take будут получены из query string, остальные зависимости зарезолвит DI.
Много skip-take в проекте, хочется вынести в общую модель для удобства использования? Поможет аттрибут [AsParameters]:

private static async Task<QueryableList<UserCartItem>> HandleAsync([AsParameters]PagingQuery paging, UserContext userContext, ExampleDbContext db, CancellationToken ct)
...
public record PagingQuery
{
	[FromQuery(Name = "take")]
    public int Take { get; init; } = 10;
	[FromQuery(Name = "skip")]
    public int Skip { get; init; }
}

Подробнее о биндинге параметров можно почитать в статье Microsoft.
Движемся дальше.

Заменяем аттрибуты

В Minimal API используется важная концепция - метадата. Это то, что заменит нам атрибуты в контроллерах: добавляем эндпоинту метадату и обрабатываем ее в наших фильтрах.
Забегая вперед: метадата появилась еще до Minimal API, и аттрибуты контроллеров - это тоже метадата, а значит их можно получать одним и тем же кодом.
Например, хотим запретить какой-то эндпоинт в продакшне. Делаем класс:

public class ProhibitInProductionMetadata;

Добавляем его в эндпоинт (подсказка - можно даже написать красивые extension):

endpointsBuilder.MapGet("/user/cart", HandleAsync)
	.WithMetadata(new ProhibitInProductionMetadata()))

А, чтобы реализовать логику, сразу перейдем к фильтрам

Фильтры

Для примера выше реализуем фильтр:

public class ProhibitInProductionFilter(EnvironmentConfig envConfig) : IEndpointFilter
{
	public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
	{
		var endpoint = context.HttpContext.GetEndpoint();
		if (endpoint == null || envConfig.Environment != EnvironmentType.Production)
			return await next(context);
		
		// Проверяем наличие метадаты у эндпоинта
		var metadata = endpoint.Metadata.GetMetadata<ProhibitInProductionMetadata>();
		if (metadata == null)
			return await next(context);

		// Метадата есть, Production окружение, возвращаем 404
        return Results.NotFound();
	}
}

И сделаем так, чтобы он применялся ко всем эндпоинтам, модифицировав код из введения:

var endpointsBuilder = app.MapGroup("/api")
    .AddEndpointFilter<ProhibitInProductionFilter>();
EndpointsProvider.RegisterAppEndpoints(endpointsBuilder);

Готово. Вот и познакомились с фильтрами в Minimal API.
Правда, познакомились. Нам теперь не нужно вспоминать каждый раз вот эти все IAuthorizationFilter/IActionFilter/IExceptionFilter и так далее. Весь необходимый функционал можно реализовать наследуя от одного интерфейса.
Пара полезных нюансов:

  • Как можно заметить из примера выше, AddEndpointFilter() можно применять к одному, группе, или ко всем эндпоинтам - в зависимости от ваших нужд

  • Переходите с контроллеров? Этот же фильтр можно применить и к контроллерам:

app.MapControllers()
    .AddEndpointFilter(new ProhibitInProductionFilter());

А, заменив наш ProhibitInProductionMetadata на аттрибут, и добавив его в контроллер, он уже будет у нас в метадате:

var metadata = endpoint.Metadata.GetMetadata<ProhibitInProductionMetadataAttribute>();

Отсюда вывод: переделав ваши фильтры на IEndpointFilter, они смогут работать и с контроллерами, и с Mini API одновременно.
Больше деталей в документации.

Middleware

Тут все еще проще - ничего не меняется. Для наглядности пример - реализуем Middleware для логгирования запросов:

public class RequestLoggingMiddleware(RequestDelegate next, ILogger<RequestLoggingMiddleware> log)
{
    public async Task InvokeAsync(HttpContext context)
    {
		var url = context.Request.GetDisplayUrl();
        log.LogTrace("Started {RequestId} | Url: {Url}", context.TraceIdentifier, url);
		await next(context);
    }
}

...
// В Program.cs
app.UseMiddleware<RequestLoggingMiddleware>();

Варианты с app.Use() и IMiddleware доступны аналогично

Валидация

А вот тут посложнее: В Minimal API нет ModelState, соответсвенно коробочная валидация контроллеров не работает. Если вы полагаетесь на нее - придется пока переделывать. Если же у вас какой-то свой механизм через фильтры/middleware или просто в коде эндпоинтов - скорее всего получится его адаптировать.

Из коробки валидация появится в .Net 10, так что планируете реализовывать Mini API сразу на .Net 10, то написанное ниже вероятно будет не очень актуально. А пока я расскажу как это можно сделать сейчас:
Допустим, у нас есть такой эндпоинт:

endpointsBuilder.MapPut("/items/create", HandleAsync)
	.RequireAuthorization()
	.WithTags("Items");
...
// .Net поймет, что CreateItemRequest - это тело запроса, но при желании можно явно пометить аттрибутом
private async Task<IResult> HandleAsync(CreateItemRequest item)
...
public record CreateItemRequest
{
    public required string Name { get; init; }
    public required int Price { get; init; }
}

Можно, конечно, валидировать прямо в HandleAsync()

if (item.Name.Length < 3)
	return Results.BadRequest("Item name must be at least 3 characters long.");

Хотя тут даже если отправить null в "Name", получим NullReferenceException и 500 ошибку: включенные в проекте nullable reference types не уважаем, непорядок.
Но хорошая новость: FluentValidation, если пользуетесь, совместим с Minimal API. Реализуем для нашего эндпоинта:

public class CreateItemValidator : AbstractValidator<CreateItemRequest>
{
    public CreateItemValidator()
    {
        RuleFor(x => x.Name).NotEmpty().Length(3, 100);
        RuleFor(x => x.Price).GreaterThan(0).LessThanOrEqualTo(1000);
    }
}
...
// В Program.cs:
builder.Services.AddScoped<IValidator<CreateItemRequest>, CreateItemValidator>();
...
private async Task<IResult> HandleAsync(IValidator<CreateItemRequest> validator, CreateItemRequest item)
{
    var validationResult = await validator.ValidateAsync(item);
    if (!validationResult.IsValid)
        return
    Results.ValidationProblem(validationResult.ToDictionary());
...

Хотя ключевое слово required при отсутствии поля "name" в body все равно кинет свою 400 ошибку до того, как код дойдет до валидации. Ну да ладно, кейс специфичный и не всем нужен, оставлю так.
Другой подход: MiniValidation, работающий на System.ComponentModel.DataAnnotations, прямо как в контроллерах из коробки. Реализуем:

public record CreateItemRequest
{
    [Required, MinLength(3)]
    public required string Name { get; init; }
    [Required, Range(1, 1000)]
    public required int Price { get; init; }
}
...
private async Task<IResult> HandleAsync(CreateItemRequest item)
{
    var isValid = MiniValidator.TryValidate(item, out var errors);
    if (!isValid)
        return Results.ValidationProblem(errors);

Главный плюс MiniValidation - он реализует DataAnnotations, а это значит:

  • Совместимость с текущими контроллерами

  • В .Net 10 его можно будет просто убрать и заменить коробочным решением.

OpenAPI/Swagger и фильтры

Чуть выше я уже затронул некоторые моменты для OpenAPI, разберем поподробнее

Вообще с .Net 9 Microsoft перешла со SwaggerGen на свой пакет Microsoft.AspNetCore.OpenApi Если вы путаете Swagger(UI/Gen) и OpenAPI (эти понятия были тесно связаны между собой, и нейминг не помогал), то можно глянуть что произошло в этой issue и этой статье.

Вкратце, простыми словами:

  • SwaggerGen, как и OpenApi от Microsoft, генерируют нам тот самый .json с API спецификацией нашего проекта. Swagger UI - визуализирует этот JSON чтобы можно было удобно тыкаться ручками.

  • Вместо SwaggerGen теперь рекоммендуется использовать Microsoft.AspNetCore.OpenApi. Но никто не запрещает остаться на SwaggerGen: поддерка MinimalAPI есть. Зачем тогда переходить? В перспективе можно ожидать более качественную реализацию новых функций .Net и протокола OpenAPI. Как показывает опыт, хоть скорее всего так и будет, но это не точно.

  • Swagger UI все еще можно использовать даже без SwaggerGen

  • Активно развиваются альтернативы Swagger UI, например Scalar. На хабре уже была статья о нем.

Scalar интригует, но остановимся на пол пути: в примере буду приводить SwaggerGen и переводить его на Microsoft OpenAPI + Swagger UI.
Возьмем пример из головы: допустим, мы хотим отображать OpenAPI определенные хэдеры у конкретных эндпоинтов. Где-то, конечно, подойдет строка с аттрибутом [FromHeaders], но не всегда удобно.

Для привычного "SwaggerGen + Контроллеры" реализация будет выглядеть так:
Добавим аттрибут:

[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)]
public class ApiShowHeadersAttribute(params string[] headers) : Attribute
{
    public string[] Headers => headers;
}

Реализуем фильтр:

public class ShowHeadersFilter : IOperationFilter
{
    public void Apply(OpenApiOperation operation, OperationFilterContext context)
    {
        // Получаем аттрибут ShowHeadersAttribute из метода контроллера
        var headersData = context.MethodInfo.GetCustomAttribute<ApiShowHeadersAttribute>();
        if (headersData == null)
            return;

		operation.Parameters ??= new List<OpenApiParameter>();
        foreach (var header in headersData.Headers)
        {
            // Добавляем описание хэдера в OpenAPI спецификацию
            operation.Parameters.Add(new OpenApiParameter
            {
                Name = header,
                In = ParameterLocation.Header,
                Required = false,
                Schema = new OpenApiSchema() // Можно еще красивую схему придумать
            });
        }
    }
}

И добавляем этот фильтр в наш services.AddSwaggerGen(). В OpenAPI спецификации у нас появятся нужные хэдеры.

Теперь реализуем то же самое для Mini API, причем мы переиспользуем фильтр: он будет работать и для контроллеров, и для Mini API
Добавим такую строчку к нашему энпоинту:

endpointsBuilder.MapGet("/user/cart", HandleAsync)
	.WithMetadata(new ApiShowHeadersAttribute("X-Custom-Header"))

И теперь просто заменяем получение аттрибута в фильтре на получение метадаты:

var headersData = context.ApiDescription.ActionDescriptor.EndpointMetadata
    .OfType<ApiShowHeadersAttribute>()
    .FirstOrDefault();

Готово! Теперь данный фильтр будет работать с контроллерами, и с Minimal API.
Тут можно обнаружить еще один небольшой плюс Minimal API: в отличие от аттрибутов, в метод можно передать наш массив хэдеров из какого-нибудь статического списка или даже конфига при желании: мы больше не ограничены этапом компиляции.

Теперь переделаем фильтр со SwaggerGen на Microsoft OpenApi:
Наш фильтр будет реализовывать не IOperationFilter, а IOpenApiOperationTransformer, при этом изменения в коде минимальны:

// Было:
public class ShowHeadersFilter : IOperationFilter
...
public void Apply(OpenApiOperation operation, OperationFilterContext context)
...
var headersData = context.ApiDescription.ActionDescriptor.EndpointMetadata.OfType<ApiShowHeadersAttribute>().FirstOrDefault();

// Стало:
public class OpenApiShowHeadersFilter : IOpenApiOperationTransformer
...
public async Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken ct)
...
var headersData = context.Description.ActionDescriptor.EndpointMetadata.OfType<ApiShowHeadersAttribute>().FirstOrDefault();

Подробнее о трансформерах можно почитать в доке. И поменяем в Program.cs:

// Было:
builder.Services.AddSwaggerGen(c => c.OperationFilter<ShowHeadersFilter>());
...
app.UseSwagger();
app.UseSwaggerUI();

// Стало:
builder.Services.AddOpenApi(c => c.AddOperationTransformer<ShowHeadersFilter>());
...
app.UseSwaggerUI(options => options.SwaggerEndpoint("/openapi/v1.json", "Swagger"));
app.MapOpenApi();

Microsoft OpenApi делается с прицелом на Minimal API, однако с контроллерами тоже работает: убедитесь, что на ваших контроллерах есть аттрибут [ApiController]
А описание кодов ответа в Min API можно заменить так:

// Было:
[HttpPut("items/create")
[SwaggerResponse(StatusCodes.Status400BadRequest, Type = typeof(ErrorModel))]

// Стало:
endpointsBuilder.MapPut("/items/create", HandleAsync)
	.Produces<ErrorModel>(400);

Подробнее, что на что поменялось, можно посмотреть в документации.
Почему-то .Produces недоступен, если хотите навесить его сразу на группу эндпоинтов. Но не беда: это просто метадата, и мы можем сделать так:

var endpointsBuilder = app.MapGroup("/api").WithMetadata(new ProducesResponseTypeMetadata(400, typeof(ErrorModel)))
...

Полезно, если у вас стандартизированный ответ с ошибкой (а он, надеюсь, стандартизирован)

Обработка ошибок

В Minimal API есть встроенная обработка ошибок (документация). Можно использовать так:

app.UseExceptionHandler(exceptionHandlerApp =>
{
    exceptionHandlerApp.Run(async context =>
    {
        var exceptionFeature = context.Features.Get<IExceptionHandlerFeature>();
        if (exceptionFeature?.Error is ValidationException)
            await Results.ValidationProblem([]).ExecuteAsync(context);
        else
            await Results.Problem().ExecuteAsync(context);
    });
});

Впрочем, лично мне это коробочное решение не очень понравилось: оно мне показалось не очень гибким, и оно безконтрольно залоггирует ошибку еще до того, как дойдет до хэндлера - даже если вы ее как-то обработаете.
А под капотом там - обычный Middleware с try catch, поэтому можно так и самим сделать:

public class ExceptionHandlingMiddleware(RequestDelegate next, ILogger<ExceptionHandlingMiddleware> log)
{
	public async Task InvokeAsync(HttpContext context)
	{
		try
		{
			await next(context);
		}
		catch (Exception exception)
		{
			// Ваша обработка тут
		}
	}
}

Но, вне зависимости от вашего выбора, оба варианта (т.к. и там и там Middleware), будут работать и с контроллерами

Кэширование

ResponseCacheAttribute для Minimal API не работает (хотя, подсказка: ничто не мешает добавить его в метадату и написать фильтр для добавления HTTP хэдеров Cache-Control вручную)
Но у нас теперь есть OutputCache: без HTTP, только на уровне приложения. Реализуем:

// В Program.cs:
builder.Services.AddOutputCache();
...
app.UseOutputCache();

// Эндпоинт:
endpointsBuilder.MapGet("/items", HandleAsync)
	.CacheOutput(builder =>
	{
		builder.Expire(TimeSpan.FromSeconds(60));
		builder.SetVaryByQuery("*");
	});

Теперь, после 1 выполнения, ответ будет закэширован у нас на сервере на 60 секунд.

Эндпоинт с таким кэшом умеет удалять кэш раньше времени при необходимости, кэшировать в Redis и даже имеет resource locking (1ый реквест будет выполняться, а остальные параллельные ждать, пока тот выполнится, чтобы не перегружать сервер) - в общем, штука весьма полезная.

Results/TypedResults

Как и с методами контроллеров Ok(), BadRequest() и т.п., у нас теперь есть (Typed)Results.Ok()/BadRequest().
И, как и с контроллерами, можно их и использовать, а можно и не использовать:

endpointsBuilder.MapGet("/cart/item-plain", HandlePlain);
endpointsBuilder.MapGet("/cart/item-results", HandleResults);
...
private static UserCartItem HandlePlain() => new UserCartItem();
private static Ok<UserCartItem> HandleResults() => TypedResults.Ok(new UserCartItem());

Данные варианты практически идентичны, и OpenAPI/SwaggerGen сам подхватит описание модели ответа.
Но для Results можно еще, в стиле функционального программирования, показать, что у нас возможны разные ответы сервера:

private static Results<Ok<UserCartItem>, NotFound> HandleResults() => TypedResults.Ok(new UserCartItem());

Но, как я писал выше про OpenApi, у эндпоинтов есть расширение .Produces(), позволяющее добиться того же результата, причем, при желании глобально
Итого, что же выбрать, Results.Ok() или "сырые" данные? Если у вас уже есть консенсус в контроллерах на эту тему - можно оставить как есть.

Если ответа для вас пока нет, лично мне нравится больше использовать сырые данные + обрабатывать ошибки через Exceptions: зачем привязывать логику обработки событий юзеров к имплементации API, если это особо не дает преимуществ.
Хотя, это больше вкусовщина, ООП против функционального стиля.

Впрочем, если ваш эндпоинт возвращает не просто json-ответ, Results будут полезны в любом случае:

return TypedResults.File(result, "application/x-gzip", $"sitemap-{page}.xml.gz");

Итоги

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

Отдельного упоминания заслуживает FastEndpoints - сторонняя альтернатива MVC и Minimal API. Однако, как мне кажется, с развитием Minimal API смысла в ее использовании все меньше

Upd:
Дополнение от @mvv-rus: если нужно погрузиться еще глубже, цикл статей от Andrew Lock про устройство Minimal API под капотом

Комментарии (16)


  1. RouR
    11.06.2025 06:45

    Конфигурация эндпоинтов происходит через методы, явно вызывающиеся в вашем коде, а не поиске контроллеров по всему проекту и получению аттрибутов

    Как раз таки контроллеры искать и не надо. И атрибуты сразу все видны.

    Я работал с большими проектами и с обоими подходами. MinimalAPI это больше кода и и больше файлов с конфигурацией эндпоинтов. Мне не понравилось.


    1. Espleth Автор
      11.06.2025 06:45

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

      var endpoints = Assembly.GetExecutingAssembly().DefinedTypes
          .Where(type => type is { IsClass: true, IsAbstract: false } && typeof(IEndpoint).IsAssignableFrom(type));

      Заглянул под капот контроллеров, там примерно тот же принцип в чуть более красивой обертке.
      И, ничто не мешает так сделать для эндпоинтов. И разные сервисы для DI подхватывать например, или что еще вам нужно. Красиво - просто написал условный Handler для MediatR, а он автоматом уже зарегистрирован, никаких "опять забыл в DI прокинуть".

      Вот только, злоупотребление такой черной магией несет за собой негативные последствия: меньшую гибкость, усложнение восприятия для новых разработчиков (в т.ч. разрыв цепочки usage, не проследишь что откуда вызывается). И старых, когда все нюансы работы проекта начнут забываться, и рефлексию, от которой стараемся избавиться
      Стоит ли того 1 сэкономленная строчка кода на эндпоинт/сервис? Microsoft вот раньше, с контроллерами, считали, что можно сделать исключение. Сейчас, не безосновательно, они так не делают


      1. mvv-rus
        11.06.2025 06:45

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

        Можно подумать, что у вас тут, в Minimal API, не магия (ну, или та самая достаточно развитая технология, которая от магии неотличима). Или она - белая, а потому негативных последствий не несет?

        В MVC используется маршрутизация основанная на соглашениях - хорошо документированных и известных. Многие этим пользуются, давно, и им все нормально, без негативных последствий.

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

        А такие соглашения создают немалые удобства: не надо каждую точку назначения регистрировать отдельно - а в большом проекте их ох как много быть может...


        1. blaka
          11.06.2025 06:45

          что у вас тут, в Minimal API, не магия

          Не магия. В статье простая реализация REPR паттерна с явной регистрацией (которую, имхо, можно сделать изящнее). Этот паттерн можно сделать и контроллерами (правда это не будет полный эквивалент, ведь там используются атрибуты)

          немалые удобства

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

          А ведь можно сделать просто и красиво сведя регистрацию в единое место. Если очень хочется, то можно и этот класс разбить (и разложить по тем же папкам) - все равно это будет явная схема, где видно "кто куда и с чем" и возможностью реконфигурации не трогая сами точки.

          Тут с автором еще можно поспорить с его "делаем в HandleAsync всю работу", но это уже оффтоп.


          1. Espleth Автор
            11.06.2025 06:45

            Тут с автором еще можно поспорить с его "делаем в HandleAsync всю работу", но это уже оффтоп.

            Ну это, как я написал, с примером из моей статьи про VSA. Тут просто для наглядности, каким-нибудь MediatR, валидаторами или репозиториями, как раньше, никто пользоваться не запрещает.

            Как можно посмотреть в eShop, похожий подход теперь самими Microsoft одобрен =)


          1. mvv-rus
            11.06.2025 06:45

            Не магия. В статье простая реализация REPR паттерна с явной регистрацией (которую, имхо, можно сделать изящнее).

            Я не про статью, а про сам Minimal API. Сама по себе, без этой магии, подсистема маршрутизации в качестве точки назначения в MapXXX может использовать только RequestDelegate: delegate Task RequestDelegate(HttpContext context), а для MinimalApi можно передать любой делегат, так что прелбразование его в RequestDelegate - самая что ни на есть магия.

            Но что делать, если мы хотим навесить что-то дополнительно на пару точек из группы?

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

            А в едином месте для регистрации в проекте любит образовываться куча всякой всячины. И в реально большом проекте это будет реально большая куча. Не, для настоящих программистов (тех, кто не использует Паскаль(см. одноименную статью от 1983 года), и кого не напрягает написать, а потом - читать и перечитывать - цикл из трех сотен строк кода) эта куча - не преграда. Но ведь менеджеры так и норовят вместо настоящих программистов нанять неженок, которым такие подвиги не по силам, чисто потому, что им платить можно меньше. ;-)


        1. Espleth Автор
          11.06.2025 06:45

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

          С моей точки зрения, в контроллерах это - задокументированная фича фреймворка, о которой разработчик узнает на этапе "пишем hello world", поэтому там такой подход логичен. В эндпоинтах же - это перемещается на уровень соглашений в проекте.

          нормально продумаете и документируете для своего проекта

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

          И если у нас в проекте одно такое исключение, для эндпоинтов - ничего страшного, правда. Но, идут годы, проект растет, таких исключений уже куча. Вы так не планировали? Ну так другой разработчик подумал, раз есть одно, то почему бы и не два. А вы были в отпуске, или PR прошел мимо вас. А теперь это задача на рефакторинг, на которую менеджер не дает времени, потому что "что нам это даст?", и т.п.
          И вот уже незаметненько, новые разработчики сидят и по несколько месяцев онбордятся, пытаясь разобраться что тут происходит. Еще и без возможности пройтись по всей цепочке Usages и отследить, потому что цепочки разорваны рефлексией.

          А ведь есть неплохое правило "хороший код должен сам себя документировать". Конечно, его не везде получится соблюсти, но зачем просто так его нарушать.


  1. JackHarckness
    11.06.2025 06:45

    Спасибо за обзор! Приходилось раньше несколько раз делать легковесные веб-серверы как раз с использованием NativeAOT и Reflection-free mode (ныне <IlcDisableReflection> вырезан начиная с .NET 9), используя только Kestrel. Для этого просто ссылаемся на ASP.NET в нашем .NET проекте примерно таким образом:

    <Project Sdk="Microsoft.NET.Sdk">
      <ItemGroup>
        <FrameworkReference Include="Microsoft.AspNetCore.App"/>
      </ItemGroup>
    </Project>
    

    И пишем свою собственную логику используя неймспейс Microsoft.AspNetCore.Server.Kestrel - там все достаточно просто.
    Похоже, что когда в следующий раз придется это делать, можно будет остановиться на Minimal API, что несомненно удобнее, ведь в нем из коробки уже реализован роутинг и аутентификация. Один момент в вашей статье хотел уточнить - если уж мы используем такой подход, наверное следует отказаться от Reflection целиком, в т.ч. от сериализации моделей средствами ASP.NET (если они еще не перешли на source generators) и получение MethodInfo в вашем примере с OpenAPI. Наконец, вместо ORM и Queriable использовать ADO - DbConnection, DbCommand. Не так уж много кода потребует это все, тем более что достаточно выработать паттерн один раз и переиспользовать его в дальнейшей разработке. Поэтому подойдет, наверное, даже для средне-крупных проектов.
    К слову, на этапе компоновки бинаря (таргет LinkNative) весь лишний код вырезался и получался бинарь размером не более 5 Мб. Minimal API через <Project Sdk="..."/> давал минимум 12 Мб. Надо проверить, как сейчас с этим дело обстоит у Minimal API.


    1. Espleth Автор
      11.06.2025 06:45

      Избавление от рефлексии целиком и переход на NativeAOT в большом проекте - это тема для отдельной статьи, и переход на Minimal API тут был бы лишь один из шагов. У меня тут меньше опыта, поэтому сразу и то и то я делать не пытался.
      Да и, многим сейчас NuGet-зависимости не позволят, которые еще не стать совместимыми.
      Но, пара моментов:

      1. Json source generators опционально есть, вот пример для Minimal API. В идеале конечно, да, надо бы их использовать. MethodInfo я там, если посмотрите, парой абзацев ниже как раз показал на что заменить.

      2. EF тоже может работать c Native AOT. Правда, с заметными ограничениями, и в целом, выстрелить себе в колено стало еще проще. Зато с такими ограничениями разница в производительности "в среднем" между EF и raw sql, которая и так стремительно уменьшается последние годы, еще меньше. В общем, вкусовщина


  1. lxvkw
    11.06.2025 06:45

    Minimal API, имхо, отлично подходит для микросервисов с совсем небольшии количеством эндпоинтов.

    Но вот для развеститого api будет скорее больший проигрыш в поддержке и прозрачности.


  1. mvv-rus
    11.06.2025 06:45

    В Minimal API есть важная концепция - метадата.

    Эта важная концепция - она не из Minimal API, а из базовой для .NET подсистемы маршрутизации (endpoint routing). MVC тоже использует этот же механизм маршрутизации, а потому и метаданные тоже использует. И если написать маршрут к контроллеру API на MVC через MapControllerRoute, а не через атрибуты (это вполне возможно, если не использвать [ApiController], правда при этом надо и другие функции, реализуемые [ApiController] самому реализовать), то на полученный от него IEndpointConventionBuilder можно навесить любые метаданные. Более того, например, навесить именованную политику ограничения запросов (Rate Limiting) можно как и атрибутом [EnableRateLimiting] для контроллера/действия, так и методом расширения RequireRateLimiting для IEndpointConventionBuilder - в обоих случаях в метаданных будет один и тот же класс EnableRateLimitingAttribute с именем политики, который Rate Limiting Middleware увидит и применит.

    PS Если кому надо узнать, как работает Minimal API - есть хороший цикл статей от Andrew Lock, в котором объясняется вся эта белая магия. упомянутая в данной статье. Но букв там много, да.


    1. Espleth Автор
      11.06.2025 06:45

      Спасибо, поправил про метадату. Что-то в моей голове заставило меня думать (видимо потому что везде видел MethodInfo), что она появилась только с Minimal API.
      Ссылку на цикл статей тоже добавил.


  1. LbISS
    11.06.2025 06:45

    Я, к сожалению, так и не уловил профита. Сперва рассказывается о тяжеловесности контроллеров - фильтрах, атрибутах и т.п. А потом берётся minimal API и... И из него делаются те же самые контроллеры, только всю обвязку надо повторно написать руками вместо готовой предоставленной Майкрософтом. А профит-то где? Пара процентов перформанса? Я лучше буду иметь меньше самописного кода и большую структурированность, это в костах больше сэкономит.


    1. Espleth Автор
      11.06.2025 06:45

      Обвязка для регистрации? Ну, это десяток строк кода, плюс по 1 строчке на эндпоинт/группу эндпоинтов.

      А если остальное так у нас как были, так и есть много коробочных фильтров/middleware/поддержка OpenAPI от Microsoft. Просто, в большом проекте (а я в статье делаю акцент на такой), всегда появятся свои приколы, и тут уж, не важно какой язык, фреймворк или библиотека - за вас это никто не напишет.


      1. EgorovDenis
        11.06.2025 06:45

        Как пример, в крупном контроллере может быть одна зависимость, которая сама тянет конфиги через сеть. Сломается одна зависимость и сломаются все endpoint, которые находятся в контроллере.

        Или, нужно вывести enum на фронт, а для этого вы тянете 10 дополнительных зависимостей при каждом запросе.

        Каждый решает сам, стоит оно того или нет, но мне подход с контроллерами напоминает подход с тяжеловесными сервисами по 20 методов, которые врятли когда-либо будут отрефакторены и навсегда останутся подвержены росту. И поэтому я использую Minimal Api на крупном проекте и доволен удобством


        1. Espleth Автор
          11.06.2025 06:45

          Ну, справедливости ради

          Сломается одна зависимость и сломаются все endpoint, которые находятся в контроллере.

          Обычно зависимости идут не в контроллеры, а хэндлеры/сервисы (впрочем, в случае сервисов, проблема остается но просто в другом слое).
          Но, если хочется в контроллер - DI там тоже работает не только в конструкторе контроллера, но и в его методах (эндпоинтах), и можно прокинуть сервис туда.
          Я, поэтому, не упомянул в плюсах Minimal API то что мы тащим только нужные для конкретного эндпоинта сервисы: в контроллерах так тоже можно.