Minimal API - это ASP.NET Core без церемоний контроллеров: вы сопоставляете URL и HTTP-метод с функцией, а фреймворк привязывает параметры, сериализует результат и запускает те же middleware, аутентификацию и внедрение зависимостей, что и в любом другом приложении ASP.NET Core. Для API любого размера, вплоть до нескольких сотен эндпоинтов, в современном .NET это выбор по умолчанию. То, что делает их готовыми к продакшену, - группы маршрутов для организации эндпоинтов, TypedResults, чтобы типы возвращаемых значений документировали сами себя, встроенная валидация (новинка .NET 10), problem details для ошибок и сгенерированный документ OpenAPI, - это строчка-другая на каждое, и в этой статье показано всё это.
Примеры нацелены на .NET 10, текущий релиз с долгосрочной поддержкой. Где возможность появилась позже .NET 8, в тексте об этом сказано.
Minimal API - это целое веб-приложение#
Самое маленькое полезное приложение умещается в четыре строки:
var builder = WebApplication.CreateBuilder(args);var app = builder.Build();app.MapGet("/healthz", () => Results.Ok(new { ok = true }));app.Run();Это Kestrel, полная система конфигурации, логирование, внедрение зависимостей и конвейер middleware - тот же хост, на котором работает приложение MVC, только без частей, которые находят контроллеры через reflection. Никакого штрафа в производительности или возможностях за такой старт нет: аутентификацию, ограничение частоты, EF Core, фоновые сервисы и даже контроллеры можно добавить позже в то же приложение.
Обработчики эндпоинтов - обычные делегаты. Лямбды достаточно для одной строки; для всего, что длиннее, направьте маршрут на статический метод, чтобы обработчик можно было прочитать, протестировать и найти в стеке вызовов:
app.MapGet("/orders/{id:int}", OrderEndpoints.GetById);static class OrderEndpoints{ public static async Task<Results<Ok<OrderDto>, NotFound>> GetById( int id, ShopDb db, CancellationToken ct) { var order = await db.Orders.AsNoTracking() .Where(o => o.Id == id) .Select(o => new OrderDto(o.Id, o.Total, o.Status)) .FirstOrDefaultAsync(ct); return order is null ? TypedResults.NotFound() : TypedResults.Ok(order); }}JSON обрабатывает System.Text.Json с веб-настройками по умолчанию: имена свойств пишутся в camelCase, сопоставление на входе нечувствительно к регистру, а числа можно читать из строк. Две настройки стоит решить в первый же день, а не после того, как клиенты начнут зависеть от вывода. Перечисления сериализуются как числа, если не добавить конвертер, а это делает ответы трудночитаемыми и хрупкими, когда кто-то переставит элементы перечисления. И свойства со значением null выводятся, если не сказать сериализатору их пропускать. Обе настройки живут в одном месте:
builder.Services.ConfigureHttpJsonOptions(o =>{ o.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); o.SerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;});Эти параметры применяются только к эндпоинтам minimal API. Если в том же приложении есть и контроллеры, они настраиваются отдельно через AddControllers().AddJsonOptions(...), и если об этом забыть, в одном приложении окажутся два стиля JSON.
Маршруты и привязка параметров#
Каждый параметр обработчика откуда-то заполняется, и фреймворк выводит, откуда именно, из типа и имени параметра. Явные атрибуты переопределяют этот вывод.
| Источник | Когда выводится | Явный атрибут |
|---|---|---|
| Значение маршрута | Имя совпадает с {segment} | [FromRoute] |
| Строка запроса | Простой тип, нет совпадения с маршрутом | [FromQuery] |
| Заголовок | Никогда не выводится | [FromHeader(Name = "X-Tenant")] |
| Тело JSON | Сложный тип в POST, PUT, PATCH | [FromBody] |
| Форма | Никогда не выводится | [FromForm] (.NET 8 и новее) |
| Сервисы | Тип зарегистрирован в DI | [FromServices] |
| Специальные типы | HttpContext, CancellationToken, ClaimsPrincipal... | не нужен |
Ограничения маршрутов вроде {id:int}, {slug:alpha} и {page:int:min(1)} отклоняют несовпадающие URL с кодом 404 ещё до выполнения вашего кода. Они нужны для маршрутизации, а не для валидации: несработавшее ограничение означает «такого URL не существует», а это другой ответ, чем «ваши входные данные неверны».
Всегда принимайте CancellationToken в обработчиках, выполняющих ввод-вывод, и передавайте его дальше. Когда клиент отключается, токен отменяется, и запрос к базе данных останавливается, а не доделывается ни для кого.
Nullability - часть контракта. Параметр, объявленный как int page, обязателен: запрос без ?page= получает 400 ещё до вызова обработчика. Объявите его как int? page или задайте значение по умолчанию, int page = 1, и он станет необязательным. То же касается тела: параметр тела, не допускающий null, отклоняет пустой запрос. В Development ответ 400 объясняет, какой параметр не удалось привязать; в Production он намеренно скуп, так что логируйте ошибки привязки, если вам нужно их видеть.
Когда обработчик принимает много параметров, сгруппируйте их в тип с [AsParameters]:
public record OrderQuery(int Page = 1, int PageSize = 20, string? Status = null);app.MapGet("/orders", ([AsParameters] OrderQuery q, ShopDb db) => /* ... */);Начиная с .NET 8, эндпоинты, которые привязывают формы или загрузки IFormFile, помечаются как требующие antiforgery-проверки. Для API, который вызывают другие программы, а не браузеры, вызовите .DisableAntiforgery() на этом эндпоинте; для формы в браузере зарегистрируйте antiforgery и отправляйте токен вместе с формой.
Возврат результатов: Results и TypedResults#
Обработчик может вернуть обычный объект (сериализуется в JSON со статусом 200), строку (пишется как текст) или IResult. Результаты создают два статических класса:
Results.Ok(x),Results.NotFound()и родственные возвращаютIResult. Гибко, но фреймворк не видит по сигнатуре, что возвращает эндпоинт.TypedResults.Ok(x)и родственные возвращают конкретные типы вродеOk<OrderDto>. В сочетании с объединённым типом возврата,Results<Ok<OrderDto>, NotFound, ValidationProblem>, сама сигнатура перечисляет все возможные ответы, и генератор OpenAPI документирует их без дополнительных атрибутов.
Предпочитайте TypedResults с объединённым типом возврата. Его проверяет компилятор - вернуть статус, которого нет в объединении, это ошибка компиляции, - и описание API бесплатно становится точным. Ещё он упрощает модульное тестирование обработчиков, потому что результат - типизированный объект, на который можно делать утверждения.
Группы маршрутов и организация реального проекта#
MapGroup даёт набору эндпоинтов общий префикс и общую конфигурацию:
var api = app.MapGroup("/api");api.MapGroup("/orders") .WithTags("Orders") .RequireAuthorization() .MapOrderEndpoints();api.MapGroup("/products") .WithTags("Products") .MapProductEndpoints();public static class OrderEndpointsExtensions{ public static RouteGroupBuilder MapOrderEndpoints(this RouteGroupBuilder group) { group.MapGet("/", OrderEndpoints.List); group.MapGet("/{id:int}", OrderEndpoints.GetById); group.MapPost("/", OrderEndpoints.Create); group.MapDelete("/{id:int}", OrderEndpoints.Delete) .RequireAuthorization("admin"); return group; }}Именно такая структура сохраняет minimal API читаемым при пятидесяти эндпоинтах: один файл на функциональность, метод расширения, который сопоставляет её маршруты, и Program.cs, сведённый к оглавлению. Всё, что применяется к группе, - авторизация, ограничение частоты, фильтры, теги OpenAPI - применяется к каждому эндпоинту в ней, а вложенные группы наследуют настройки родителей.
Тестируйте эндпоинты через HTTP, а не только вызывая обработчики напрямую, по крайней мере для важных путей. Пакет Microsoft.AspNetCore.Mvc.Testing запускает всё приложение в памяти через WebApplicationFactory<Program>, так что тест проходит через маршрутизацию, привязку, валидацию, фильтры и сериализацию ровно так, как это сделал бы клиент, не открывая порт. Подмените базу данных тестовым экземпляром в ConfigureWebHost фабрики, и несколько десятков таких тестов поймают большинство регрессий, которые вносит рефакторинг. Обработчики, написанные как статические методы, возвращающие TypedResults, можно также тестировать модульно напрямую, что быстрее для бизнес-правил.
Валидация#
Годами у minimal API не было встроенной валидации: data annotations на типе запроса просто игнорировались, и люди добавляли фильтр или библиотеку, чтобы их проверять. .NET 10 добавляет её во фреймворк:
builder.Services.AddValidation();public class CreateOrder{ [Required, EmailAddress] public string CustomerEmail { get; set; } = ""; [Range(1, 100)] public int Quantity { get; set; } [StringLength(200)] public string? Note { get; set; }}Когда это зарегистрировано, запрос, тело которого нарушает правило, получает ответ 400 с телом в формате problem details, где перечислено каждое неверное поле, а обработчик вообще не вызывается. Это работает для параметров, тел запросов и вложенных типов и управляется генератором исходного кода, так что не зависит от reflection во время выполнения. Сверьтесь с документацией для своей точной версии: детали менялись в ходе preview-цикла .NET 10.
На .NET 8 и 9 у вас три варианта. Фильтр эндпоинта, который выполняет Validator.TryValidateObject и возвращает TypedResults.ValidationProblem(...). Небольшая библиотека MiniValidation, которая делает то же самое с поддержкой вложенных объектов. Или FluentValidation, если вы хотите правила в коде, а не в атрибутах, - обычно её вызывают из фильтра. Все три держат валидацию вне самого обработчика.
Что бы вы ни выбрали, проверяйте на входе и дальше доверяйте типу. База данных всё равно должна обеспечивать то, что может, - столбцы not null, уникальные индексы, внешние ключи, - потому что валидация в API защищает одну точку входа, а база данных защищает их все.
Ошибки и problem details#
Необработанное исключение в minimal API в Production по умолчанию возвращает пустой 500. Включите стандартный формат ошибок:
builder.Services.AddProblemDetails();var app = builder.Build();app.UseExceptionHandler();app.UseStatusCodePages();Теперь необработанные исключения дают ответ application/problem+json со статусом и заголовком, коды статуса без тела (404 от маршрутизации, 401 от аутентификации) тоже его получают, а формат совпадает с ошибками валидации выше. В Development ответ включает подробности исключения; в Production - нет, и это то, что нужно. Записывайте исключение в лог на сервере, возвращайте клиенту идентификатор корреляции, если позже нужно будет его найти, и никогда не кладите в ответ сообщения исключений от драйвера базы данных.
Для ожидаемых сбоев - отсутствующей записи, конфликта, бизнес-правила - возвращайте результат явно (TypedResults.NotFound(), TypedResults.Conflict(), TypedResults.Problem(...)), а не выбрасывайте исключение. Исключения дороги, и из-за них трудно следить за потоком управления; объединённый тип возврата делает ожидаемые исходы видимыми.
Распространённая золотая середина - собственный обработчик исключений для нескольких известных типов исключений: например, конфликт параллельного доступа из EF Core превращается в 409. Начиная с .NET 8 можно реализовать IExceptionHandler, зарегистрировать его через AddExceptionHandler<T>() и позволить ему писать ответ problem details для исключений, которые он узнаёт, передавая остальные поведению по умолчанию.
Документация OpenAPI#
Начиная с .NET 9, ASP.NET Core сам генерирует документы OpenAPI через пакет Microsoft.AspNetCore.OpenApi, а шаблоны больше не включают Swashbuckle.
builder.Services.AddOpenApi();var app = builder.Build();app.MapOpenApi(); // serves /openapi/v1.json.NET 10 по умолчанию генерирует OpenAPI 3.1. Эндпоинты описываются по их маршруту, параметрам и типам возврата; добавьте .WithName("GetOrder"), .WithSummary(...) и .WithTags(...) для того, чего сигнатура сказать не может. TypedResults с объединёнными типами возврата документируют каждый ответ автоматически, и это самый сильный довод в их пользу.
Пакет производит документ, а не просмотрщик. Для интерактивной страницы добавьте его: Scalar (Scalar.AspNetCore, затем app.MapScalarApiReference()) или Swagger UI из пакета Swashbuckle.AspNetCore.SwaggerUI, направленный на /openapi/v1.json. Решите, должны ли документ и просмотрщик быть публичными. Для внутреннего API сопоставляйте их только в Development или прячьте за RequireAuthorization(); публичное описание каждого эндпоинта - подарок любому, кто прощупывает ваш API.
Фильтры, авторизация и ограничения частоты#
Фильтры эндпоинтов выполняются вокруг обработчика и имеют доступ к его аргументам и результату:
group.AddEndpointFilter(async (context, next) =>{ var started = Stopwatch.GetTimestamp(); var result = await next(context); var ms = Stopwatch.GetElapsedTime(started).TotalMilliseconds; context.HttpContext.Response.Headers["Server-Timing"] = $"app;dur={ms:F0}"; return result;});Авторизация и ограничение частоты подключаются так же. RequireAuthorization() без аргумента требует аутентифицированного пользователя; с именем политики применяет эту политику. RequireRateLimiting("per-ip") применяет политику, зарегистрированную через AddRateLimiter. Ограничению частоты по IP нужен настоящий адрес клиента, а за прокси это означает правильно настроенные проксированные заголовки - как это сделать, показано в статье ASP.NET Core за обратным прокси, а cookie и JWT на стороне аутентификации сравниваются в статье основы аутентификации в ASP.NET Core.
Если API вызывает браузерное приложение с другого origin, ему нужен CORS. Зарегистрируйте именованную политику с точным списком разрешённых origin (AddCors с WithOrigins("https://app.example.com")), добавьте UseCors() в конвейер перед авторизацией и примените политику к группе через RequireCors("app"). Не сочетайте AllowAnyOrigin() с учётными данными - браузеры отвергают такую пару, и ошибка видна только в консоли браузера, но никогда в логе сервера. На preflight-запросы OPTIONS отвечает само CORS middleware, так что они никогда не доходят до ваших обработчиков, и отдельный маршрут для них не нужен.
Когда лучше использовать контроллеры#
Minimal API и контроллеры работают на одном конвейере, и в одном приложении могут быть и те и другие (AddControllers() плюс MapControllers()). Причины выбрать контроллеры для части приложения или для всего:
- Большая существующая кодовая база или привычка команды. Переписывание работающих контроллеров мало что даёт.
- Согласование содержимого. Контроллеры могут возвращать XML или другие форматы через форматтеры; minimal API ориентированы прежде всего на JSON.
- Экосистемы фильтров MVC. Action-фильтры, result-фильтры и model binder-ы из библиотек, от которых вы уже зависите.
- OData, которая исторически строилась вокруг контроллеров.
- API, построенные на соглашениях, где маршрутизация атрибутами и наследование от базового контроллера убирают больше повторов, чем убрали бы группы маршрутов.
Производительность не является веским доводом ни в ту, ни в другую сторону. Minimal API делают меньше работы на запрос и меньше выделяют памяти, и это путь к Native AOT, но для API, который проводит время в базе данных, разницы видно не будет. Если вам важны время запуска и память, AOT описан в статье параметры dotnet publish.
Переход между ними не обязательно делать разом. Существующий API на контроллерах может добавлять новые функции как группы minimal API, пока старые контроллеры продолжают работать, и обратное столь же возможно. Таблица маршрутизации общая, так что правило только одно: два эндпоинта не должны претендовать на один и тот же маршрут и метод; если это случится, запрос упадёт с ошибкой неоднозначного совпадения, где названы оба, и это легко исправить. Команды, которые мигрируют так, обычно переводят по одной функциональности за раз, начиная с самой простой, и останавливаются, когда оставшиеся контроллеры не стоят усилий.
Деплой minimal API ничем не отличается от любого приложения ASP.NET Core: публикация, привязка Kestrel к выделенному порту на всех интерфейсах и домен на прокси. Полная последовательность - в статье деплой приложения ASP.NET Core с GitHub, а схема базы данных, на которой стоит большинство API, - в статье миграции EF Core в продакшене.
FAQ#
Minimal API - только для небольших проектов?
Нет. Название описывает количество церемоний, а не размер приложения. С группами маршрутов и одним файлом на функциональность minimal API остаётся организованным и при сотнях эндпоинтов.
Можно ли использовать внедрение зависимостей в обработчиках minimal API?
Да. Любой параметр, тип которого зарегистрирован в контейнере, разрешается на каждый запрос, включая scoped-сервисы. Конструктор не нужен.
Почему тело моего запроса всегда null?
JSON не соответствует типу параметра, заголовок Content-Type - не application/json, или метод - GET, для которого тело по умолчанию не привязывается. Посмотрите запрос в логе и сравните имена свойств; привязка нечувствительна к регистру, но написание всё равно важно.
Нужен ли мне ещё Swashbuckle?
Не для генерации документа - начиная с .NET 9 это делает Microsoft.AspNetCore.OpenApi. Пакет интерфейса нужен только для интерактивной страницы, и Scalar, и пакет Swagger UI работают со встроенным документом.
Как версионировать minimal API?
Проще всего - группа маршрутов на каждую версию, /api/v1 и /api/v2, каждая со своими эндпоинтами. Пакет Asp.Versioning.Http добавляет версионирование через заголовки и строку запроса, если это нужно.




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