Чому ми побудували бекенд на .NET Minimal API для високонавантаженої платформи
Ми мігрували платформу сертифікації з традиційних 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 вебзастосунок.