Революция без контроллеров (и почему она вам может понравиться)

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

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

Прелесть этого подхода в том, что вы можете запустить работающий API, используя всего несколько строк кода. Без массивной структуры проекта. Без ненужных слоёв. Только вы, ваша бизнес-логика и нужные вам HTTP-точки доступа.

Что отличает минимальные API?

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

Вот что делает их действительно привлекательными:

  • Снижение когнитивной нагрузки: логика вашей конечной точки находится там, где вы её определяете, а не разбросана по классам контроллеров и методам действий.
  • Молниеносное прототипирование: вы не боретесь с шаблонами; вы создаёте функции.
  • Идеально для микросервисов: когда вам нужно что-то компактное и сфокусированное, минимальные API предоставляют именно это.
  • Современные функции .NET: .NET 10 предлагает встроенную проверку, группировку маршрутов и интеграцию OpenAPI прямо из коробки.

Главное? Вы не жертвуете надёжностью ради простоты. Обработка ошибок на уровне производства, правильные HTTP-коды состояния и всеобъемлющая документация API — всё это в пределах досягаемости.

Настройка вашего первого минимального API: версия за 30 секунд

Давайте избавимся от лишних слов и что-нибудь запустим. Использование dotnet CLI — это действительно самый быстрый путь вперёд — шаблон по умолчанию уже предоставляет вам структуру минимального API.

dotnet new webapi -n RobustApiService
cd RobustApiService

Вот и всё. У вас есть проект. Если вы используете Visual Studio, процесс аналогичен; вам просто нужно удалить любой шаблон контроллера, который генерируется по умолчанию.

Теперь давайте создадим что-то, что действительно работает. Вот основа настройки API на уровне производства:

using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
// Добавить контекст базы данных
builder.Services.AddDbContext<AppDbContext>(opt =>
    opt.UseInMemoryDatabase("AppDatabase"));
// Включить исследование API для документации
builder.Services.AddEndpointsApiExplorer();
// Добавить документацию OpenAPI (Swagger)
builder.Services.AddOpenApiDocument(config =>
{
    config.DocumentName = "RobustAPI";
    config.Title = "Робоust Service API v1";
    config.Version = "v1";
});
var app = builder.Build();
// Подавать документацию OpenAPI
if (app.Environment.IsDevelopment())
{
    app.MapOpenApi();
}
app.UseHttpsRedirection();
// Ваши конечные точки идут здесь
var api = app.MapGroup("/api/items")
    .WithName("Items")
    .WithOpenApi();
app.Run();

Заметили что-то? Нет контроллеров. Нет методов действий. Только чистая, сфокусированная конфигурация. Мы используем MapGroup для организации наших конечных точек — подробнее об этом изменении правила игры чуть позже.

Освоение групп маршрутов: организация без беспорядка

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

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

var todoItems = app.MapGroup("/api/todos")
    .WithName("Todo Items")
    .WithOpenApi()
    .RequireAuthorization(); // Применить ко всей группе
// ПОЛУЧИТЬ все задачи
todoItems.MapGet("/", GetAllTodos)
    .WithName("GetAllTodos")
    .WithOpenApi();
// ПОЛУЧИТЬ задачу по ID
todoItems.MapGet("/{id}", GetTodoById)
    .WithName("GetTodoById")
    .WithOpenApi();
// ОТПРАВИТЬ новую задачу
todoItems.MapPost("/", CreateTodo)
    .WithName("CreateTodo")
    .WithOpenApi()
    .Accepts<CreateTodoRequest>("application/json")
    .Produces<TodoItemResponse>(StatusCodes.Status201Created);
// ПОМЕСТИТЬ обновление задачи
todoItems.MapPut("/{id}", UpdateTodo)
    .WithName("UpdateTodo")
    .WithOpenApi();
// УДАЛИТЬ задачу
todoItems.MapDelete("/{id}", DeleteTodo)
    .WithName("DeleteTodo")
    .WithOpenApi()
    .Produces(StatusCodes.Status204NoContent);

Видите, что происходит? Все ваши конечные точки, связанные с задачами, сгруппированы вместе. Авторизация применяется ко всей группе. Метаданные документации согласованы. Это организация с целью, а не церемония.

Создание операций CRUD: настоящее дело

Давайте создадим полную реализацию CRUD для чего-то конкретного. Мы создадим конечные точки для каталога продуктов:

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public string Description { get; set; } = string.Empty;
    public decimal Price { get; set; }
    public int Stock { get; set; }
    public DateTime CreatedAt { get; set; }
}
public class ProductDbContext : DbContext
{
    public DbSet<Product> Products => Set<Product>();
    public ProductDbContext(DbContextOptions<ProductDbContext> options)
        : base(options) { }
}
public class CreateProductRequest
{
    public required string Name { get; set; }
    public string Description { get; set; } = string.Empty;
    public decimal Price { get; set; }
    public int Stock { get; set; }
}
public class ProductResponse
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public string Description { get; set; } = string.Empty;
    public decimal Price { get; set; }
    public int Stock { get; set; }
    public DateTime CreatedAt { get; set; }
}
// Обработчики
async Task<IResult> GetAllProducts(ProductDbContext db)
{
    var products = await db.Products
        .AsNoTracking()
        .Select(p => new ProductResponse
        {
            Id = p.Id,
            Name = p.Name,
            Description = p.Description,
            Price = p.Price,
            Stock = p.Stock,
            CreatedAt = p.CreatedAt
        })
        .ToListAsync();
    return Results.Ok(products);
}
async Task<IResult> GetProductById(int id, ProductDbContext db)
{
    var product = await db.Products.FindAsync(id);
    if (product is null)
        return Results.NotFound(new { message = $"Product with ID {id} not found" });
    return Results.Ok(new ProductResponse
    {
        Id = product.Id,
        Name = product.Name,
        Description = product.Description,
        Price = product.Price,
        Stock = product.Stock,
        CreatedAt = product.CreatedAt
    });
}
async Task<IResult> CreateProduct(CreateProductRequest request, ProductDbContext db)
{
    // Встроенная проверка в .NET 10
    if (string.IsNullOrWhiteSpace(request.Name))
        return Results.BadRequest(new { error = "Product name is required" });
    if (request.Price <= 0)
        return Results.BadRequest(new { error = "Price must be greater than zero" });
    var product = new Product
    {
        Name = request.Name,
        Description = request.Description,
        Price = request.Price,
        Stock = request.Stock,
        CreatedAt = DateTime.UtcNow
    };
    db.Products.Add(product);
    await db.SaveChangesAsync();
    return Results.Created($"/api/products/{product.Id}", new ProductResponse
    {
        Id = product.Id,
        Name = product.Name,
        Description