В ноябре 2021 года .NET 6 привёз в ASP.NET Core минимальные API, и спор о том, вытеснят ли они контроллеры, не утихает до сих пор. Шаблон dotnet new webapi с тех пор генерирует именно минимальный вариант, а контроллеры в .NET 8 возвращаются флагом --useControllers, то есть платформа честно держит оба лагеря. Вопрос на практике не в том, что новее, а в том, что дешевле для конкретного проекта: минимальные API дают короткий вход, меньше церемоний и поддержку Native AOT, контроллеры приносят зрелую связку модели, валидации, фильтров и соглашений, на которой держатся большие команды. Разбираем, что у подходов общего, чем они отличаются под капотом и по каким признакам делать выбор без вечных споров.
Общая платформа под капотом от маршрутизации до конвейера MVC
Первое, что снимает половину страстей: оба стиля живут на одном фундаменте. Запрос принимает Kestrel, проходит общие middleware, попадает в общий механизм маршрутизации конечных точек, и только дальше пути расходятся. Минимальный обработчик это скомпилированный делегат RequestDelegate, в параметры которого среда подставляет службы из контейнера и значения из маршрута. Контроллер идёт дальше по конвейеру MVC: связыватели модели собирают аргументы действия, фильтры четырёх уровней от авторизации до исполнения результата, форматтеры договариваются о формате ответа. Это зрелая машина с большим наследием, но каждая её ступень стоит процессорного времени.
Минимальный вариант одной и той же операции выглядит так:
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/orders/{id:int}", async (int id, OrderStore store) =>
{
Order? order = await store.FindAsync(id);
return order is null ? Results.NotFound() : Results.Ok(order);
});
app.Run();
Классический эквивалент тянет за собой регистрацию служб, атрибуты и класс:
[ApiController]
[Route("orders")]
public class OrdersController : ControllerBase
{
private readonly OrderStore _store;
public OrdersController(OrderStore store)
{
_store = store;
}
[HttpGet("{id:int}")]
public async Task<ActionResult<Order>> Get(int id)
{
Order? order = await _store.FindAsync(id);
return order is null ? NotFound() : Ok(order);
}
}
Накладные расходы конвейера MVC реальны, но честность требует контекста: в типичном API основное время запроса уходит в базу данных или внешний сервис, и разница между конвейерами измеряется микросекундами на фоне миллисекунд ожидания. Заметной она становится при тысячах коротких запросов в секунду, в сценариях с холодным стартом и в публикациях Native AOT.
Производительность и холодный старт где разница становится заметной
Три места, где минимальные API реально выигрывают: высокая частота коротких запросов, старт после простоя и память на каждом экземпляре. Для Native AOT в .NET 8 в файле проекта включается свойство:
<PropertyGroup>
<PublishAot>true</PublishAot>
</PropertyGroup>
Команда dotnet publish -r linux-x64 выдаёт самодостаточный бинарник без установленного рантайма. Генератор RequestDelegateGenerator убирает из минимальных API рефлексию на этапе сборки, а сериализация JSON в AOT требует контекста, сгенерированного исходным кодом:
[JsonSerializable(typeof(Order))]
internal partial class AppJsonContext : JsonSerializerContext { }
Прирост зависит от службы, но направление устойчивое: меньше памяти на экземпляр, быстрее старт, дешевле масштабирование в ноль. Обычный прод без AOT разницы почти не заметит: время запроса доминирует в базе данных и сети, а микросекунды конвейера прячутся в шуме измерений. Профилируйте счётчиками dotnet-counters, прежде чем переносить всё на минимальные API ради скорости, и только потом принимайте решение по цифрам, а не по настроению.
Возможности минимальных API в .NET 8 от групп маршрутов до фильтров
Критика первых минимальных API в .NET 6 была справедлива: не хватало фильтров, группировки маршрутов и аккуратных контрактов ответов. В .NET 7 платформа закрыла главные дыры, в .NET 8 добавились привязка форм с защитой от подделки запроса и генератор делегатов. Фильтры конечных точек работают как короткая цепочка вокруг обработчика:
app.MapGet("/reports/{id:int}", async (int id, ReportService service) =>
Results.Ok(await service.BuildAsync(id)))
.AddEndpointFilter(async (context, next) =>
{
// код до обработчика
var result = await next(context);
// код после обработчика
return result;
})
.RequireAuthorization();
Группы маршрутов собирают однотипные конечные точки в один узел с общими тегами OpenAPI и политикой доступа:
var group = app.MapGroup("/orders")
.WithTags("Заказы")
.RequireAuthorization();
group.MapGet("/{id:int}", async (int id, OrderStore store) =>
{
Order? order = await store.FindAsync(id);
return order is null
? TypedResults.NotFound()
: TypedResults.Ok(order);
});
Типизированные результаты TypedResults и объединения вида Results<Ok<Order>, NotFound> из .NET 7 дают генератору OpenAPI точную схему без ручных атрибутов. Привязка форм из .NET 8 принимает IFormFile, а защита от подделки запроса включена по умолчанию и отключается явно, через DisableAntiforgery, только когда endpoint этого действительно не требует. Загрузка файла после .NET 8 выглядит лаконично:
app.MapPost("/uploads", async (IFormFile file, UploadService service) =>
Results.Ok(await service.SaveAsync(file)));
Проверки данных остаются на совести разработчика: встроенного автомата, как у [ApiController], нет, приходится валидировать вручную, выносить проверку в фильтр или подключать библиотеку вроде FluentValidation.
Контроллеры и их сильные стороны в крупном проекте
Сила контроллеров в том, что огромная часть рутины уже написана. Атрибут [ApiController] включает автоматическую проверку модели: запрос с незаполненным полем получает ответ 400 с ProblemDetails, не потребовав ни строчки кода. Связывание значений из маршрута, строки запроса, заголовков и тела работает по соглашениям, а для нестандартных случаев есть атрибуты источников. Валидация описывается декларативно:
public class CreateOrderRequest
{
[Required]
public string Customer { get; set; } = "";
[Range(1, 1_000_000)]
public decimal Amount { get; set; }
}
Конвейер фильтров покрывает сквозные задачи: авторизация, кэширование, обработка исключений, логирование и преобразование результата, у каждого типа свой момент в жизненном цикле запроса. Согласование формата ответа встроено в форматтеры, атрибуты ProducesResponseType описывают коды ответов для OpenAPI, соглашения позволяют настроить поведение сразу для всех контроллеров. Ответы об ошибках единообразны: ProblemDetails с полями title и detail позволяет фронтенду разбирать сбои одним парсером, а минимальные API могут вернуть такой же объект вручную через Results.Problem. В проекте, где полтора десятка команд правят один бэкенд, эта предсказуемость экономит часы ревью: любой разработчик открывает незнакомый контроллер и мгновенно знает, где модель, где проверка и где обработка ошибок.
Организация кода когда Program разрастается за сотни строк
Ахиллесова пята минимальных API это накопление конечных точек в одном Program.cs. На десяти маршрутах файл читается прекрасно, на пятидесяти превращается в кашу. Лечение известно: конечные точки раскладываются по классам-картографам, по одному на фичу, а Program.cs сводится к списку вызовов:
public static class OrderEndpoints
{
public static void MapOrderEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/orders");
group.MapGet("/{id:int}", GetAsync);
group.MapPost("/", CreateAsync);
}
private static async Task<IResult> GetAsync(int id, OrderStore store)
{
Order? order = await store.FindAsync(id);
return order is null ? Results.NotFound() : Results.Ok(order);
}
}
var app = builder.Build();
app.MapOrderEndpoints();
app.MapCustomerEndpoints();
app.Run();
Такой стиль близок к вертикальным срезам: всё, что относится к заказам, лежит в одном файле от маршрута до обработчика. У контроллеров организация обратная, по ресурсам: один класс собирает все действия над заказом, включая версии и вспомогательные конечные точки. Оба способа масштабируются, но требуют командной дисциплины: без договорённостей минимальные API расползаются по случайным файлам, а контроллеры обрастают ветвлениями внутри действий.
Тестирование и командная работа при двух стилях API
Контроллер тестируется напрямую, как обычный класс: подставил зависимости в конструктор, вызвал метод, проверил результат. Быстро, без хоста и сети. Обработчик минимального API это лямбда, к которой так просто не подобраться, поэтому логику выносят в службы, а конечную точку держат тонкой обёрткой над ними. Интеграционные тесты через WebApplicationFactory работают одинаково для обоих стилей: хост поднимается в памяти, запросы идут через HttpClient, проверяются и маршруты, и сериализация, и политики доступа:
public class OrdersApiTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;
public OrdersApiTests(WebApplicationFactory<Program> factory)
{
_client = factory.CreateClient();
}
[Fact]
public async Task UnknownOrderReturnsNotFound()
{
var response = await _client.GetAsync("/orders/999999");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
}
Командный аспект часто перевешивает технический. Контроллеры окружены двадцатью годами документации, книг и готовых ответов, часть команды приходит с опытом MVC. Минимальные API проще для новичка с нуля: без скрытой работы связывателей понятно, откуда берётся каждый параметр. Зато в разнородной команде конвейер MVC дисциплинирует сам: одинаковые ответы на ошибки задаёт инфраструктура, а не вкус конкретного автора конечной точки. Для OpenAPI оба подхода описываются генераторами, а в .NET 9 платформа получила встроенный генератор документа OpenAPI, что уравнивает инструментарий окончательно.
Практические критерии выбора между подходами под проект
Сухой остаток, который можно положить в решение по архитектуре:
- для нового сервиса из пары десятков конечных точек без сложной доменной модели минимальные API сокращают вход и количество файлов;
- для крупного доменного API с десятками сущностей, версионированием и большим числом участников берите контроллеры с их соглашениями;
- если нужен Native AOT, минимальный холодный старт или публикация в окружении с жёстким лимитом памяти, выбор только за минимальными API;
- существующая команда с опытом MVC и внутренними стандартами на контроллеры не должна переписываться ради тренда;
- прототипы, хакатоны и внутренние утилиты собираются на минимальных API быстрее и без накладных расходов;
- гибрид допустим: MapControllers и минимальные конечные точки живут в одном приложении, но правилами смешивания надо управлять явно;
- смешивать стили внутри одной фичи не стоит, читаемость выигрывает, когда один домен живёт в одном стиле.
Гибрид при этом даёт честный путь миграции: новый домен пишется на минимальных API, старый спокойно живёт на контроллерах, а перенос идёт малыми шагами по одному ресурсу без долгой живой ветки.
Выбор между минимальными API и контроллерами в .NET 8 это выбор условий задачи, а не поколений. Минимальные API сильны там, где ценятся короткий вход, скорость старта и экономия памяти вплоть до Native AOT, а домен помещается в таблицу маршрутов. Контроллеры сильны там, где зрелость важнее новизны: валидация, фильтры и соглашения держат порядок в кодовой базе на сотни тысяч строк. Техническая разница между конвейерами в реальных приложениях почти всегда тонет во времени доступа к данным, зато разница в дисциплине команды видна в каждом пулреквесте. Измерьте свою ситуацию по критериям выше, зафиксируйте решение в архитектурном документе, и спор закончится, не начавшись.