.NETMinimal APIBackendPerformance

Чому ми побудували бекенд на .NET Minimal API для високонавантаженої платформи

13 квітня 2026 р.

Codevia Engineering

Ми мігрували платформу сертифікації з традиційних MVC-контролерів на .NET 10 Minimal API. Ось що ми виміряли, що нас здивувало і де традиційні контролери досі є правильним вибором.

Контекст

Платформа обробляє запити на сертифікацію від польових аудиторів, які працюють у зонах зі слабким інтернетом. Це означає офлайн-перші мобільні клієнти, що накопичують операції та відправляють їх пачками, коли з'являється зв'язок. Бекенд повинен справлятися з нерівномірними піками навантаження, а не зі стабільним потоком запитів.

Початкова архітектура використовувала MVC-контролери ASP.NET Core. Це працювало. Але з ростом функціональності ми почали відчувати накладні витрати у трьох сферах: час холодного старту на serverless-функціях, затримка конвеєра запитів на малонавантажених ендпоінтах і надмірне scaffolding-заготовки для простих CRUD-операцій.

Що насправді змінює .NET Minimal API

Minimal API (представлено у .NET 6, суттєво покращено у .NET 10) — це не просто синтаксичний цукор. Він скорочує кількість етапів middleware-конвеєра, через які проходить кожен запит, коли вони не потрібні.

У традиційному застосунку на контролерах кожен запит проходить повний конвеєр: прив'язка моделі, активація контролера, фільтри дій і виконання результату. Більшість цих етапів нічого не роблять для простих ендпоінтів, але все одно виконуються.

Minimal API дозволяє визначати ендпоінти як делегати. Конвеєр коротший за замовчуванням.

// Традиційний контролер
[ApiController]
[Route("api/certifications")]
public class CertificationController : ControllerBase
{
    private readonly ICertificationService _service;

    public CertificationController(ICertificationService service)
    {
        _service = service;
    }

    [HttpGet("{id}")]
    public async Task<IActionResult> GetById(string id, CancellationToken ct)
    {
        var result = await _service.GetByIdAsync(id, ct);
        if (result is null) return NotFound();
        return Ok(result);
    }
}

// Еквівалент на .NET 10 Minimal API
app.MapGet("/api/certifications/{id}", async (
    string id,
    ICertificationService service,
    CancellationToken ct) =>
{
    var result = await service.GetByIdAsync(id, ct);
    return result is null ? Results.NotFound() : Results.Ok(result);
})
.RequireAuthorization("Auditor")
.WithName("GetCertificationById")
.Produces<CertificationDto>()
.ProducesProblem(404);

Визначення ендпоінту компактніше. Авторизація, документація типів відповіді та іменування — все в одному місці.

Що ми виміряли

Ми протестували обидва підходи на нашому staging-середовищі з реалістичними даними запитів.

Холодний старт (Azure Functions, план Consumption):

  • MVC-контролери: 820 мс у середньому
  • Minimal API: 510 мс у середньому
  • Покращення: ~38%

Запитів за секунду (8-ядерна VM, простий GET-ендпоінт):

  • MVC-контролери: 42 000 RPS
  • Minimal API: 58 000 RPS
  • Покращення: ~38%

Виділення пам'яті на запит (простий ендпоінт серіалізації):

  • MVC-контролери: 2,1 КБ
  • Minimal API: 1,4 КБ

Ці числа збігаються з власними бенчмарками Microsoft. Покращення досягається за рахунок меншої кількості етапів middleware, прямої генерації коду для серіалізації (source generators System.Text.Json) і меншого overhead від reflection.

Для платформи, що обробляє пакетну синхронізацію від сотень офлайн-пристроїв, покращення холодного старту мало значення. Польові аудитори синхронізуються, коли з'являється зв'язок — теплого baseline не існує.

Архітектура, на якій ми зупинились

Ми організовуємо ендпоінти Minimal API за допомогою extension-методів на IEndpointRouteBuilder. Це замінює патерн «один контролер на фічу» на патерн «один модуль на фічу».

// CertificationModule.cs
public static class CertificationModule
{
    public static IEndpointRouteBuilder MapCertificationEndpoints(
        this IEndpointRouteBuilder routes)
    {
        var group = routes.MapGroup("/api/certifications")
            .RequireAuthorization();

        group.MapGet("/{id}", GetById);
        group.MapPost("/", Submit);
        group.MapPost("/batch", SubmitBatch);
        group.MapPatch("/{id}/status", UpdateStatus);

        return routes;
    }

    private static async Task<IResult> GetById(
        string id,
        ICertificationService service,
        CancellationToken ct)
    {
        var result = await service.GetByIdAsync(id, ct);
        return result is null ? Results.NotFound() : Results.Ok(result);
    }

    private static async Task<IResult> Submit(
        CertificationSubmitRequest request,
        ICertificationService service,
        IValidator<CertificationSubmitRequest> validator,
        CancellationToken ct)
    {
        var validation = await validator.ValidateAsync(request, ct);
        if (!validation.IsValid)
            return Results.ValidationProblem(validation.ToDictionary());

        var id = await service.SubmitAsync(request, ct);
        return Results.Created($"/api/certifications/{id}", new { id });
    }
}

Кожен модуль реєструється у Program.cs:

app.MapCertificationEndpoints();
app.MapAuditorEndpoints();
app.MapAdminEndpoints();

Це тримає Program.cs чистим і робить структуру маршрутизації очевидною.

Файл проєкту таргетує net10.0:

<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
</Project>

Валідація без фільтрів

Одне з того, що ви втрачаєте з Minimal API, — це автоматична валідація моделі через [ApiController] та атрибути валідації. Ми використовуємо FluentValidation, впроваджений безпосередньо в обробники ендпоінтів.

Для консистентності ми написали мінімальний extension:

public static class ValidationExtensions
{
    public static async Task<IResult?> ValidateAsync<T>(
        this IValidator<T> validator,
        T request,
        CancellationToken ct = default)
    {
        var result = await validator.ValidateAsync(request, ct);
        return result.IsValid
            ? null
            : Results.ValidationProblem(result.ToDictionary());
    }
}

// Використання в ендпоінті
private static async Task<IResult> Submit(
    CertificationSubmitRequest request,
    IValidator<CertificationSubmitRequest> validator,
    ICertificationService service,
    CancellationToken ct)
{
    var validationResult = await validator.ValidateAsync(request, ct);
    if (validationResult is not null) return validationResult;

    var id = await service.SubmitAsync(request, ct);
    return Results.Created($"/api/certifications/{id}", new { id });
}

Де ми досі використовуємо контролери

Ми не використовуємо Minimal API скрізь.

Складні ланцюги фільтрів. Якщо ендпоінт використовує кілька action-фільтрів, result-фільтрів та exception-фільтрів, що повторюються на багатьох ендпоінтах, — модель контролера чистіша. Фільтри на ендпоінтах Minimal API потребують endpoint filters, які менш ергономічні для складних ланцюгів.

Адмін-панелі з важким scaffolding. Якщо потрібні RazorPages або серверно-рендерені представлення, контролери досі є природним вибором.

Сторонні бібліотеки, що передбачають MVC. Деякі бібліотеки для звітності або експорту будуються на IActionResult і ControllerBase. Обгортати їх для Minimal API не варто.

Правило, яке ми застосовуємо: нові ендпоінти функцій — Minimal API за замовчуванням. Легасі-код залишається на контролерах. Ми мігруємо контролери, коли торкаємося їх з інших причин — не як окреме завдання рефакторингу.

Виграш від серіалізації

Одна з недооцінених переваг .NET Minimal API — Results.Ok(value) з source-генерованою серіалізацією вимірно швидший, ніж Ok(value) у контролері з runtime reflection.

Увімкніть source generation у вашому JsonSerializerContext:

[JsonSerializable(typeof(CertificationDto))]
[JsonSerializable(typeof(List<CertificationDto>))]
[JsonSerializable(typeof(ProblemDetails))]
internal partial class AppJsonContext : JsonSerializerContext { }

Зареєструйте його:

builder.Services.ConfigureHttpJsonOptions(options =>
{
    options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonContext.Default);
});

У наших бенчмарках source-генерована серіалізація скоротила overhead JSON-серіалізації приблизно на 40% для складних об'єктів відповіді. На платформі, що обробляє тисячі пакетних запитів синхронізації, — це не мікрооптимізація.

Висновок

.NET Minimal API готовий до продакшну і забезпечує вимірні покращення продуктивності порівняно з MVC-контролерами для ендпоінтів з великою кількістю запитів. Покращення пропускної здатності на 38% і нижчий час холодного старту виправдали витрати на міграцію нашої платформи.

Компроміси реальні: ви втрачаєте деяку ергономіку навколо фільтрів, анотацій прив'язки моделей і scaffolding. Для greenfield API — особливо бекендів SaaS-платформ, де важлива затримка холодного старту — ці компроміси майже завжди варті прийняття. Для наявних MVC кодобаз мігруйте поступово на рівні ендпоінтів. Не сприймайте це як переписування «все або нічого».

Якщо ви будуєте новий .NET-бекенд з нуля і хочете почати правильно, наша команда розробки .NET-бекенду за замовчуванням обирає Minimal API на кожному новому проєкті. Найкращий час починати — на нових ендпоінтах, що додаються до наявної системи — ви отримуєте переваги одразу, не порушуючи робочий код.

Якщо ви вже використовуєте Minimal API і хочете додати AI-можливості, подивіться наш гайд з інтеграції OpenAI API у .NET 10 вебзастосунок.