В ноябре 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, что уравнивает инструментарий окончательно.

Практические критерии выбора между подходами под проект

Сухой остаток, который можно положить в решение по архитектуре:

  1. для нового сервиса из пары десятков конечных точек без сложной доменной модели минимальные API сокращают вход и количество файлов;
  2. для крупного доменного API с десятками сущностей, версионированием и большим числом участников берите контроллеры с их соглашениями;
  3. если нужен Native AOT, минимальный холодный старт или публикация в окружении с жёстким лимитом памяти, выбор только за минимальными API;
  4. существующая команда с опытом MVC и внутренними стандартами на контроллеры не должна переписываться ради тренда;
  5. прототипы, хакатоны и внутренние утилиты собираются на минимальных API быстрее и без накладных расходов;
  6. гибрид допустим: MapControllers и минимальные конечные точки живут в одном приложении, но правилами смешивания надо управлять явно;
  7. смешивать стили внутри одной фичи не стоит, читаемость выигрывает, когда один домен живёт в одном стиле.

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

Выбор между минимальными API и контроллерами в .NET 8 это выбор условий задачи, а не поколений. Минимальные API сильны там, где ценятся короткий вход, скорость старта и экономия памяти вплоть до Native AOT, а домен помещается в таблицу маршрутов. Контроллеры сильны там, где зрелость важнее новизны: валидация, фильтры и соглашения держат порядок в кодовой базе на сотни тысяч строк. Техническая разница между конвейерами в реальных приложениях почти всегда тонет во времени доступа к данным, зато разница в дисциплине команды видна в каждом пулреквесте. Измерьте свою ситуацию по критериям выше, зафиксируйте решение в архитектурном документе, и спор закончится, не начавшись.