Минимальные API в .NET: создание облегченных HTTP API

· 11 мин чтения

Если вы какое-то время создавали API с помощью ASP.NET Core, вы, вероятно, хорошо знакомы с подходом на основе контроллера: создайте класс контроллера, украсьте его атрибутами, внедрите свои сервисы через конструктор и соедините свои маршруты. Это работает, и работает хорошо, но иногда кажется, что вы пишете много церемоний, что означает «принять этот запрос, сделать что-нибудь, вернуть ответ».

Это именно та проблема, которую призваны решить минимальные API. Минимальные API, представленные в .NET 6, позволяют определять конечные точки HTTP с помощью минимального шаблонного кода. Никаких контроллеров, никаких атрибутов, никакой манипуляции с классами запуска — только прямое сопоставление маршрутов с обработчиками в чистом, функциональном стиле.

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

Почему минимальные API?

Прежде чем мы начнем, давайте поговорим о том, почему вы предпочитаете минимальные API традиционному подходу на основе контроллера.

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

С другой стороны, минимальные API сияют, когда вам нужно:

  • Меньше шаблонов — нет классов контроллера, нет атрибутов [ApiController], нет отдельной конфигурации запуска.
  • Более быстрый запуск — меньше операций, связанных с отражением, во время загрузки.
  • Упрощенная ментальная модель — маршрут сопоставляется непосредственно с обработчиком. Вот и все.
  • Удобство для микросервисов — если ваш API имеет 5–10 конечных точек, полная настройка контроллера может показаться излишним.

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

Начало работы

Давайте создадим минимальный API с нуля. Если у вас установлен .NET SDK, это так же просто, как:

[[[ТОК_1]]]

Это дает вам Program.cs, который выглядит примерно так:

[[[ТОК_3]]]

Вот и все. Это рабочий API. Нет Startup.cs, нет класса контроллера, нет конфигурации маршрутизации. Вы запускаете dotnet run, нажимаете http://localhost:5000 и получаете «Hello World!» назад. Вся суть здесь в простоте.

Давайте сделаем его немного более полезным с помощью классического API todo:

[[[ТОК_7]]]

У нас есть полный CRUD менее чем в 30 строках. В этом сила минимального подхода.

Обработчики маршрутов

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

Лямбда-выражения

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

[[[ТОК_8]]]

Группы методов

Для более сложной логики вы можете указать именованный метод:

[[[ТОК_9]]]Это мой предпочтительный подход ко всему, что выходит за рамки однострочника. Он сохраняет раздел сопоставления маршрутов чистым и читабельным — вы можете сразу увидеть, какие конечные точки существуют, не вдаваясь в подробности реализации.

Локальные функции

Вы также можете использовать локальные функции, что полезно, если вы хотите, чтобы обработчики были близки к определениям их маршрутов:

[[[ТОК_10]]]

Привязка параметра

Одна из вещей, которые мне очень нравятся в минимальных API, — это интуитивно понятное связывание параметров. Платформа определяет, откуда брать значения, в зависимости от контекста и типа.

Параметры маршрута

[[[ТОК_11]]]

Параметры строки запроса

[[[ТОК_12]]]

Типы, допускающие значение NULL, становятся необязательными параметрами. Значения по умолчанию работают именно так, как вы ожидаете.

Тело запроса

app.MapPost("/orders", (Order order) =>
{
    // 'order' is automatically deserialized from the JSON body
    return Results.Created($"/orders/{order.Id}", order);
});

Заголовок и привязка службы

app.MapGet("/protected", (
    [FromHeader(Name = "X-Api-Key")] string apiKey,
    [FromServices] ILogger<Program> logger) =>
{
    logger.LogInformation("Request with API key: {Key}", apiKey[..4] + "****");
    return Results.Ok("Authorized");
});

HttpContext и HttpRequest

Когда вам нужен доступ более низкого уровня:

app.MapGet("/info", (HttpContext context) =>
{
    var userAgent = context.Request.Headers.UserAgent.ToString();
    var ip = context.Connection.RemoteIpAddress?.ToString();
    return Results.Ok(new { userAgent, ip });
});

Пользовательская привязка с помощью BindAsync

Для сложных типов вы можете реализовать статический метод BindAsync:

public record PaginationParams(int Page, int PageSize)
{
    public static ValueTask<PaginationParams?> BindAsync(HttpContext context)
    {
        int.TryParse(context.Request.Query["page"], out var page);
        int.TryParse(context.Request.Query["pageSize"], out var pageSize);

        var result = new PaginationParams(
            Page: page > 0 ? page : 1,
            PageSize: pageSize > 0 ? Math.Min(pageSize, 100) : 20
        );

        return ValueTask.FromResult<PaginationParams?>(result);
    }
}

app.MapGet("/items", (PaginationParams pagination, AppDbContext db) =>
{
    var items = db.Items
        .Skip((pagination.Page - 1) * pagination.PageSize)
        .Take(pagination.PageSize)
        .ToList();

    return Results.Ok(items);
});

Это невероятно мощно. Вы определяете логику привязки один раз и повторно используете ее на всех своих конечных точках.

Проверка

Одна из областей, в которой минимальные API не дают вам столько возможностей по сравнению с контроллерами, — это проверка модели. Автоматического применения [Required] или [StringLength] не предусмотрено. Но есть четкие шаблоны для решения этой проблемы.

Ручная проверка

Самый простой подход — просто валидируем в обработчике:

app.MapPost("/users", (CreateUserRequest request) =>
{
    if (string.IsNullOrWhiteSpace(request.Email))
        return Results.BadRequest(new { Error = "Email is required" });

    if (request.Name?.Length > 100)
        return Results.BadRequest(new { Error = "Name must be 100 characters or less" });

    // Create user...
    return Results.Created($"/users/{request.Email}", request);
});

Использование библиотеки проверки

Для чего-то нетривиального я бы рекомендовал использовать FluentValidation или аналогичную библиотеку:

public class CreateUserValidator : AbstractValidator<CreateUserRequest>
{
    public CreateUserValidator()
    {
        RuleFor(x => x.Email).NotEmpty().EmailAddress();
        RuleFor(x => x.Name).NotEmpty().MaximumLength(100);
        RuleFor(x => x.Age).InclusiveBetween(0, 150);
    }
}

Вы можете подключить это к своим конечным точкам через фильтры конечных точек, что подводит нас к следующему разделу.

Фильтры конечных точек

Фильтры конечных точек — одна из лучших функций минимальных API. Думайте о них как о промежуточном программном обеспечении, но ограниченном конкретными конечными точками, а не всем конвейером. Они были представлены в .NET 7 и отлично подходят для решения сквозных задач.

Базовый фильтр

app.MapPost("/todos", (Todo todo) =>
{
    // Handle the request
    return Results.Created($"/todos/{todo.Id}", todo);
})
.AddEndpointFilter(async (context, next) =>
{
    var todo = context.GetArgument<Todo>(0);

    if (string.IsNullOrEmpty(todo.Title))
    {
        return Results.BadRequest(new { Error = "Title is required" });
    }

    return await next(context);
});

Фильтр проверки с FluentValidation

Вот где он становится действительно мощным — многоразовый фильтр проверки:

public class ValidationFilter<T> : IEndpointFilter where T : class
{
    private readonly IValidator<T> _validator;

    public ValidationFilter(IValidator<T> validator)
    {
        _validator = validator;
    }

    public async ValueTask<object?> InvokeAsync(
        EndpointFilterInvocationContext context,
        EndpointFilterDelegate next)
    {
        var model = context.Arguments
            .OfType<T>()
            .FirstOrDefault();

        if (model is null)
            return Results.BadRequest(new { Error = "Request body is required" });

        var result = await _validator.ValidateAsync(model);

        if (!result.IsValid)
        {
            var errors = result.Errors
                .GroupBy(e => e.PropertyName)
                .ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());

            return Results.ValidationProblem(errors);
        }

        return await next(context);
    }
}

// Usage
app.MapPost("/users", (CreateUserRequest request) =>
{
    // Only reached if validation passes
    return Results.Created($"/users/{request.Email}", request);
})
.AddEndpointFilter<ValidationFilter<CreateUserRequest>>();

Фильтр журналов

app.MapGet("/products", (AppDbContext db) => db.Products.ToList())
    .AddEndpointFilter(async (context, next) =>
    {
        var logger = context.HttpContext
            .RequestServices.GetRequiredService<ILogger<Program>>();

        var sw = Stopwatch.StartNew();
        var result = await next(context);
        sw.Stop();

        logger.LogInformation("Endpoint executed in {Elapsed}ms", sw.ElapsedMilliseconds);

        return result;
    });

Вы можете объединить несколько фильтров, и они будут выполняться в том порядке, в котором они добавлены — точно так же, как промежуточное программное обеспечение.

Интеграция OpenAPI/Swagger

Хорошая документация по API больше не является обязательной. К счастью, минимальные API имеют первоклассную поддержку OpenAPI через пакет Microsoft.AspNetCore.OpenApi.

Базовая настройка

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();

var app = builder.Build();

app.MapOpenApi();

app.MapGet("/todos", () => new List<Todo>())
    .WithName("GetTodos")
    .WithDescription("Retrieves all todo items")
    .WithTags("Todos");

app.Run();

Расширенные метаданные конечной точки

Вы можете предоставить подробную информацию о ваших конечных точках:

app.MapPost("/todos", (Todo todo) =>
{
    return Results.Created($"/todos/{todo.Id}", todo);
})
.WithName("CreateTodo")
.WithDescription("Creates a new todo item")
.WithTags("Todos")
.Accepts<Todo>("application/json")
.Produces<Todo>(StatusCodes.Status201Created)
.Produces(StatusCodes.Status400BadRequest)
.ProducesValidationProblem();

Использование WithOpenApi для настройки

Для детального контроля над созданным документом OpenAPI:

app.MapGet("/todos/{id:int}", (int id) => Results.Ok(new Todo(id, "Sample", false)))
    .WithOpenApi(operation =>
    {
        operation.Summary = "Get a specific todo";
        operation.Description = "Retrieves a single todo item by its unique identifier.";
        operation.Parameters[0].Description = "The unique identifier of the todo item";
        return operation;
    });

Это дает вам тот же уровень управления документацией, который вы получаете с XML-комментариями Swashbuckle к контроллерам, но более явным способом, ориентированным на код.

Аутентификация и авторизация

Защита конечных точек минимального API следует тем же шаблонам, что и остальная часть ASP.NET Core — вы просто применяете их по-другому.

Базовая настройка```csharp

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { options.Authority = “https://your-identity-provider.com”; options.Audience = “your-api”; });

builder.Services.AddAuthorizationBuilder() .AddPolicy(“AdminOnly”, policy => policy.RequireRole(“Admin”)) .AddPolicy(“PremiumUser”, policy => policy.RequireClaim(“subscription”, “premium”));

var app = builder.Build();

app.UseAuthentication(); app.UseAuthorization();


### Применение авторизации к конечным точкам

```csharp
// Require any authenticated user
app.MapGet("/profile", (ClaimsPrincipal user) =>
{
    return Results.Ok(new
    {
        Name = user.FindFirstValue(ClaimTypes.Name),
        Email = user.FindFirstValue(ClaimTypes.Email)
    });
}).RequireAuthorization();

// Require a specific policy
app.MapDelete("/admin/users/{id}", (int id) =>
{
    // Delete user logic
    return Results.NoContent();
}).RequireAuthorization("AdminOnly");

// Allow anonymous access
app.MapGet("/public/health", () => Results.Ok(new { Status = "Healthy" }))
    .AllowAnonymous();

Обратите внимание, как вы можете внедрить ClaimsPrincipal непосредственно в параметры вашего обработчика — обо всем остальном позаботится инфраструктура. Это одна из тех мелочей, благодаря которым минимальные API выглядят по-настоящему элегантно.

Организация больших API

Слон в комнате с минимальными API — это организация. Когда ваш Program.cs имеет 50 конечных точек, это становится беспорядком. Вот шаблоны, которые я использую, чтобы все было управляемо.

Группы маршрутов

Группы маршрутов (представленные в .NET 7) позволяют совместно использовать конфигурацию между связанными конечными точками:

var todos = app.MapGroup("/todos")
    .WithTags("Todos")
    .RequireAuthorization();

todos.MapGet("/", (AppDbContext db) => db.Todos.ToListAsync());
todos.MapGet("/{id:int}", (int id, AppDbContext db) => db.Todos.FindAsync(id));
todos.MapPost("/", (Todo todo, AppDbContext db) =>
{
    db.Todos.Add(todo);
    db.SaveChangesAsync();
    return Results.Created($"/todos/{todo.Id}", todo);
});

Все конечные точки в группе имеют общий префикс /todos, тег Todos и требование авторизации. Чистый.

Методы расширения

Это модель, которая действительно масштабируется. Переместите каждую группу конечных точек в отдельный статический класс:

// Endpoints/TodoEndpoints.cs
public static class TodoEndpoints
{
    public static RouteGroupBuilder MapTodoEndpoints(this WebApplication app)
    {
        var group = app.MapGroup("/todos").WithTags("Todos");

        group.MapGet("/", GetAll);
        group.MapGet("/{id:int}", GetById);
        group.MapPost("/", Create);
        group.MapPut("/{id:int}", Update);
        group.MapDelete("/{id:int}", Delete);

        return group;
    }

    private static async Task<IResult> GetAll(AppDbContext db)
        => Results.Ok(await db.Todos.ToListAsync());

    private static async Task<IResult> GetById(int id, AppDbContext db)
        => await db.Todos.FindAsync(id) is { } todo
            ? Results.Ok(todo)
            : Results.NotFound();

    private static async Task<IResult> Create(Todo todo, AppDbContext db)
    {
        db.Todos.Add(todo);
        await db.SaveChangesAsync();
        return Results.Created($"/todos/{todo.Id}", todo);
    }

    private static async Task<IResult> Update(int id, Todo updated, AppDbContext db)
    {
        var todo = await db.Todos.FindAsync(id);
        if (todo is null) return Results.NotFound();

        todo.Title = updated.Title;
        todo.IsComplete = updated.IsComplete;
        await db.SaveChangesAsync();

        return Results.Ok(todo);
    }

    private static async Task<IResult> Delete(int id, AppDbContext db)
    {
        var todo = await db.Todos.FindAsync(id);
        if (todo is null) return Results.NotFound();

        db.Todos.Remove(todo);
        await db.SaveChangesAsync();

        return Results.NoContent();
    }
}

// Program.cs — stays clean
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapTodoEndpoints();
app.MapUserEndpoints();
app.MapOrderEndpoints();

app.Run();

Ваш Program.cs становится оглавлением вашего API. Каждая группа конечных точек находится в своем собственном файле. Именно такой подход я рекомендую для производственных приложений.

Библиотека Картера

Если вам нужно еще больше структуры, библиотека Carter предлагает подход на основе модулей:

public class TodoModule : ICarterModule
{
    public void AddRoutes(IEndpointRouteBuilder app)
    {
        app.MapGet("/todos", async (AppDbContext db) =>
            await db.Todos.ToListAsync());

        app.MapPost("/todos", async (Todo todo, AppDbContext db) =>
        {
            db.Todos.Add(todo);
            await db.SaveChangesAsync();
            return Results.Created($"/todos/{todo.Id}", todo);
        });
    }
}

Картер автоматически обнаруживает и регистрирует все модули. Это хорошая золотая середина между необработанным подходом минимального API и полноценными контроллерами.

Типизированные результаты и типы ответов

Начиная с .NET 7, вы можете использовать TypedResults вместо Results для типобезопасных ответов. Это может показаться небольшим изменением, но оно имеет реальные преимущества для документирования и тестируемости OpenAPI.

app.MapGet("/todos/{id:int}", async Task<Results<Ok<Todo>, NotFound>> (int id, AppDbContext db) =>
{
    var todo = await db.Todos.FindAsync(id);
    return todo is not null
        ? TypedResults.Ok(todo)
        : TypedResults.NotFound();
});

Тип возвращаемого значения Results<Ok<Todo>, NotFound> явно сообщает платформе (и вашей документации OpenAPI), какие именно типы ответов может генерировать эта конечная точка. Больше никаких догадок, никаких ручных вызовов Produces<>() для основных случаев.

Для нескольких возможных результатов:

app.MapPost("/todos",
    async Task<Results<Created<Todo>, ValidationProblem, Conflict>>
    (Todo todo, AppDbContext db) =>
{
    if (string.IsNullOrEmpty(todo.Title))
        return TypedResults.ValidationProblem(
            new Dictionary<string, string[]>
            {
                { "Title", new[] { "Title is required" } }
            });

    if (await db.Todos.AnyAsync(t => t.Title == todo.Title))
        return TypedResults.Conflict();

    db.Todos.Add(todo);
    await db.SaveChangesAsync();

    return TypedResults.Created($"/todos/{todo.Id}", todo);
});

Я начал использовать TypedResults во всех новых проектах. Компилятор выявляет несоответствия между объявленными типами возвращаемых данных и тем, что вы на самом деле возвращаете, что устраняет целый класс неожиданностей во время выполнения.

Вопросы производительности

Одним из преимуществ минимальных API является производительность, и стоит понять, почему они быстрее.

Сокращение затрат при запуске. Контроллеры в значительной степени полагаются на отражение при обнаружении конечных точек, привязке моделей и применении фильтров. Минимальные API используют генераторы исходного кода (начиная с .NET 7) для создания кода привязки во время компиляции. Это означает меньше работы при запуске и меньшее выделение памяти на запрос.

Нет конвейера MVC. API на основе контроллера проходят через полный конвейер MVC: выбор действия, привязка модели, фильтры действий, выполнение результатов. Минимальные API пропускают все это и сразу переходят от маршрутизации к вашему обработчику.

Компиляция RequestDelegate. Платформа компилирует ваши лямбда-выражения в оптимизированные экземпляры RequestDelegate . Полученный код очень близок к тому, что вы написали бы вручную, если бы работали напрямую с HttpContext.

Вот несколько практических советов по повышению производительности:

// Use AsNoTracking for read-only queries
app.MapGet("/products", async (AppDbContext db) =>
    await db.Products.AsNoTracking().ToListAsync());

// Return results directly — avoid unnecessary allocations
app.MapGet("/count", async (AppDbContext db) =>
    await db.Products.CountAsync());

// Use cancellation tokens for long-running operations
app.MapGet("/report", async (AppDbContext db, CancellationToken ct) =>
    await db.Orders
        .AsNoTracking()
        .GroupBy(o => o.Status)
        .Select(g => new { Status = g.Key, Count = g.Count() })
        .ToListAsync(ct));
```Также стоит отметить, что разрыв в производительности между контроллерами и минимальными API продолжает сокращаться с каждым выпуском .NET. Для большинства приложений разница будет не вашим узким местом  вашими запросами к базе данных и вызовами внешних служб. Выбирайте, основываясь на опыте разработчиков и потребностях проекта, а не на тестах.

## Заключение

Минимальные API прошли долгий путь с момента их появления в .NET 6. То, что начиналось как демонстрационная функция «привет, мир», превратилось в законный выбор для рабочих API. Благодаря фильтрам конечных точек, группам маршрутов, типизированным результатам и надежной поддержке OpenAPI у вас есть все необходимое для создания хорошо структурированных и удобных в обслуживании сервисов.

Моя рекомендация? Если вы начинаете новый проект API  особенно микросервис или специализированный внутренний API  серьезно попробуйте Minimal API. Используйте шаблон метода расширения для организации, используйте фильтры конечных точек для решения сквозных задач и используйте `TypedResults` для обеспечения безопасности типов.

Для существующих проектов на базе контроллеров мигрировать не нужно. Оба подхода работают хорошо, и вы даже можете использовать их одновременно. Но в следующий раз, когда вам понадобится добавить небольшой сервис или быстрый внутренний API, пропустите контроллеры и используйте минимум. Ты можешь не вернуться.

Приятного кодирования!