Коли ви вперше створюєте REST API в ASP.NET Core, перед вами постає фундаментальне питання архітектурного вибору: використовувати Minimal API з його лаконічним функціональним стилем чи Controller-based API з об'єктно-орієнтованою структурою? Це не просто питання синтаксичних переваг — це вибір між двома філософіями організації коду, кожна з яких має свої сильні сторони та обмеження.
Уявіть, що ви будуєте невелику кав'ярню. Minimal API — це як відкритий бар, де бариста стоїть за стійкою і безпосередньо приймає замовлення, готує каву та віддає її клієнту. Все відбувається швидко, без зайвих формальностей, і для невеликого закладу це ідеальне рішення. Controller-based API — це як ресторан з офіціантами, кухнею та чіткою ієрархією: замовлення приймає офіціант, передає на кухню, отримує готову страву і сервірує її за встановленими стандартами. Це вимагає більше структури, але дозволяє масштабуватися до великого закладу з десятками столиків.
У попередніх розділах ви вже досконало опанували Minimal API — його middleware pipeline, dependency injection, routing та конфігурацію. Ви знаєте, як створювати endpoints через app.MapGet(), app.MapPost() та інші методи розширення. Тепер настав час зрозуміти, коли цього підходу стає недостатньо і як еволюціонувати до більш структурованої архітектури без втрати продуктивності.
Ми побудуємо Todo API — класичний CRUD-сервіс для управління завданнями. Але замість того, щоб просто написати код, ми створимо два паралельні рішення: одне на Minimal API, друге на Web API Controllers. Це дозволить вам побачити не теоретичні абстракції, а реальні відмінності в організації коду, тестованості та масштабованості.
До кінця статті ви зможете:
Controller та ControllerBase[ApiController] "під капотом"Minimal API, представлений у .NET 6, втілює функціональний підхід до побудови веб-сервісів. Його філософія базується на ідеї, що HTTP endpoint — це просто функція, яка приймає запит і повертає відповідь. Не потрібні класи, атрибути чи складна ієрархія — лише чистий код.
Розглянемо типовий Minimal API endpoint:
app.MapGet("/api/todos/{id}", async (int id, TodoDbContext db) =>
{
var todo = await db.Todos.FindAsync(id);
return todo is not null ? Results.Ok(todo) : Results.NotFound();
});
Що відбувається в цьому коді?
Program.cs через lambda-виразid автоматично береться з маршруту, db — з DI-контейнераResults для формування HTTP-відповідіЦей підхід надзвичайно ефективний для невеликих API або мікросервісів з обмеженою кількістю endpoints. Код читається лінійно, зверху вниз, і вся логіка маршрутизації знаходиться в одному місці. Для команди з 2-3 розробників, які працюють над сервісом з 10-15 endpoints, це ідеальне рішення.
Web API Controllers втілюють об'єктно-орієнтований підхід, де endpoints групуються в класи за доменною логікою. Замість розкиданих по Program.cs функцій, ми створюємо контролери — класи, що успадковуються від ControllerBase і містять методи-дії (actions), кожен з яких обробляє окремий HTTP-запит.
Той самий endpoint у Controller-based підході:
[ApiController]
[Route("api/[controller]")]
public class TodosController : ControllerBase
{
private readonly TodoDbContext _db;
public TodosController(TodoDbContext db)
{
_db = db;
}
[HttpGet("{id}")]
public async Task<ActionResult<Todo>> GetById(int id)
{
var todo = await _db.Todos.FindAsync(id);
return todo is not null ? Ok(todo) : NotFound();
}
}
Декомпозиція цього коду:
[ApiController] — атрибут, що вмикає спеціальну поведінку для API (про це детально нижче)[Route("api/[controller]")] — шаблон маршруту на рівні класу; [controller] замінюється на "todos" (без суфікса "Controller")ControllerBase — базовий клас, що надає допоміжні методи (Ok(), NotFound(), тощо)ActionResult<T> — generic тип повернення, що дозволяє повертати як T, так і HTTP-результати (NotFound, BadRequest)Одне з найпоширеніших непорозумінь серед розробників, які переходять від MVC до Web API — це вибір між Controller та ControllerBase. Ця відмінність не є косметичною; вона відображає фундаментальну різницю в призначенні.
Controller (з простору імен Microsoft.AspNetCore.Mvc) — це клас для MVC-додатків, що повертають HTML-представлення (Views). Він успадковується від ControllerBase і додає функціональність для роботи з Razor Views:
View() — повертає Razor ViewPartialView() — повертає часткове представленняViewData, ViewBag, TempData — механізми передачі даних у ViewRedirectToAction() — перенаправлення на інший action з поверненням HTMLControllerBase — це мінімалістична база для API-контролерів, що повертають дані (JSON/XML), а не HTML. Він містить лише те, що потрібно для обробки HTTP-запитів:
Ok(), Created(), NoContent() — методи для формування успішних відповідейBadRequest(), NotFound(), Conflict() — методи для помилокRequest, Response, User — доступ до HTTP-контекстуModelState — стан валідації моделіControllerBase. Якщо повертає HTML (MVC) — використовуйте Controller. Використання Controller для API додає ~50 KB непотрібного коду у ваш додаток через підключення Razor View Engine.Ось візуальна ієрархія:
Атрибут [ApiController] — це не просто декоративна мітка. Він активує набір конвенцій та автоматичної поведінки, що спрощують розробку API та роблять його більш передбачуваним. Розглянемо кожну з цих можливостей детально.
Без [ApiController] вам потрібно вручну перевіряти ModelState у кожному action:
// БЕЗ [ApiController] — ручна перевірка
[HttpPost]
public async Task<IActionResult> Create(CreateTodoDto dto)
{
if (!ModelState.IsValid) // Ручна перевірка
{
return BadRequest(ModelState); // Ручне повернення помилки
}
// Логіка створення...
}
З [ApiController] ця перевірка відбувається автоматично. Якщо модель не пройшла валідацію (наприклад, порушені [Required] чи [MaxLength] атрибути), фреймворк автоматично повертає 400 Bad Request з деталями помилок у форматі ProblemDetails (RFC 9457):
// З [ApiController] — автоматична валідація
[ApiController]
[Route("api/[controller]")]
public class TodosController : ControllerBase
{
[HttpPost]
public async Task<IActionResult> Create(CreateTodoDto dto)
{
// ModelState вже перевірено! Якщо ми тут — модель валідна
// Логіка створення...
}
}
Приклад відповіді при невалідній моделі:
{
"type": "https://tools.ietf.org/html/rfc9457#section-3.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"Title": ["The Title field is required."],
"DueDate": ["The field DueDate must be between 01/01/2024 and 12/31/2030."]
}
}
ModelStateInvalidFilter — спеціальний фільтр, що додається до pipeline при наявності [ApiController]. Ви можете налаштувати його поведінку через ApiBehaviorOptions у Program.cs.У Minimal API та звичайних контролерах без [ApiController] вам потрібно явно вказувати, звідки брати дані для складних типів:
// БЕЗ [ApiController]
[HttpPost]
public IActionResult Create([FromBody] CreateTodoDto dto) // Явний [FromBody]
{
// ...
}
З [ApiController] фреймворк автоматично визначає джерело за правилами:
[FromBody] (з тіла запиту як JSON)[FromRoute][FromQuery]IFormFile, IFormFileCollection → [FromForm]// З [ApiController] — автоматичний inference
[HttpPost]
public IActionResult Create(CreateTodoDto dto) // [FromBody] додається автоматично
{
// dto автоматично десеріалізується з JSON-тіла запиту
}
[HttpGet("{id}")]
public IActionResult GetById(int id) // [FromRoute] додається автоматично
{
// id автоматично береться з URL
}
[HttpGet]
public IActionResult Search(string query) // [FromQuery] додається автоматично
{
// query береться з ?query=...
}
Це робить код чистішим та зменшує кількість boilerplate-атрибутів. Однак, якщо вам потрібна нестандартна поведінка (наприклад, складний тип з query string), ви завжди можете явно вказати атрибут:
[HttpGet]
public IActionResult Filter([FromQuery] TodoFilterDto filter) // Явне перевизначення
{
// filter десеріалізується з query string: ?status=completed&priority=high
}
Контролери з [ApiController] вимагають наявності атрибута маршрутизації на рівні класу або методу. Це запобігає випадковому використанню convention-based routing (який призначений для MVC з Views):
[ApiController] // Помилка компіляції, якщо немає [Route]!
public class TodosController : ControllerBase
{
// ...
}
Правильний варіант:
[ApiController]
[Route("api/[controller]")] // Обов'язковий атрибут
public class TodosController : ControllerBase
{
// ...
}
Коли ваш action повертає статус-код помилки (400, 404, 500), [ApiController] автоматично обгортає відповідь у стандартизований формат ProblemDetails (RFC 9457). Це забезпечує консистентність API та полегшує обробку помилок на клієнті:
[HttpGet("{id}")]
public ActionResult<Todo> GetById(int id)
{
var todo = _db.Todos.Find(id);
if (todo is null)
return NotFound(); // Автоматично перетворюється на ProblemDetails
return Ok(todo);
}
Відповідь для NotFound():
{
"type": "https://tools.ietf.org/html/rfc9457#section-3.1",
"title": "Not Found",
"status": 404,
"traceId": "00-a1b2c3d4e5f6-7890abcdef-00"
}
Тепер, коли ми розглянули обидва підходи детально, зведемо ключові відмінності в таблицю:
| Аспект | Minimal API | Web API Controllers |
|---|---|---|
| Базовий клас | Немає | ControllerBase |
| Визначення endpoints | app.MapGet("/api/todos", ...) | [HttpGet] атрибут на методі |
| Маршрутизація | Inline у Program.cs | [Route] атрибути на класі/методі |
| Dependency Injection | Параметри handler-функції | Конструктор контролера |
| Model Binding | Автоматичний (параметри) | Автоматичний + [ApiController] inference |
| Валідація | Ручна або FluentValidation | Автоматична через [ApiController] |
| Повернення результату | Results.Ok(data) | Ok(data) або ActionResult<T> |
| Групування логіки | За файлами/модулями | За контролерами (класами) |
| OpenAPI/Swagger | WithOpenApi() extension | Автоматично через Swashbuckle |
| Тестування | Складніше (потрібен WebApplicationFactory) | Простіше (unit-тести методів) |
| Масштабованість | До ~20-30 endpoints | Необмежена (сотні endpoints) |
| Продуктивність | Трохи швидше (~5-10%) | Трохи повільніше (overhead класів) |
Настав час перейти від теорії до практики. Ми створимо повноцінний CRUD API для управління завданнями (Todo items), реалізувавши його двома паралельними способами: спочатку через Minimal API, потім через Web API Controllers. Це дозволить вам побачити не абстрактні відмінності, а реальний код у дії.
Почнемо зі створення нового ASP.NET Core проєкту та налаштування Entity Framework Core для роботи з SQLite (для простоти демонстрації).
Відкрийте термінал та виконайте команди:
Створіть файл Models/Todo.cs:
namespace TodoApi.Models;
public class Todo
{
public int Id { get; set; }
public required string Title { get; set; }
public string? Description { get; set; }
public bool IsCompleted { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? CompletedAt { get; set; }
}
Створіть файл Data/TodoDbContext.cs:
using Microsoft.EntityFrameworkCore;
using TodoApi.Models;
namespace TodoApi.Data;
public class TodoDbContext : DbContext
{
public TodoDbContext(DbContextOptions<TodoDbContext> options)
: base(options)
{
}
public DbSet<Todo> Todos => Set<Todo>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Todo>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.Title).IsRequired().HasMaxLength(200);
entity.Property(e => e.Description).HasMaxLength(1000);
entity.HasIndex(e => e.IsCompleted);
});
}
}
Відкрийте Program.cs та додайте реєстрацію DbContext:
using Microsoft.EntityFrameworkCore;
using TodoApi.Data;
var builder = WebApplication.CreateBuilder(args);
// Реєстрація DbContext з SQLite
builder.Services.AddDbContext<TodoDbContext>(options =>
options.UseSqlite("Data Source=todos.db"));
// Додаємо підтримку Controllers (поки що закоментовано)
// builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
// Автоматичне застосування міграцій при старті (тільки для dev!)
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<TodoDbContext>();
db.Database.EnsureCreated();
}
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
// Тут будуть наші endpoints
app.Run();
EnsureCreated(): У production-середовищі замість EnsureCreated() використовуйте міграції через dotnet ef migrations add та dotnet ef database update. EnsureCreated() — це швидкий спосіб для прототипування, але він не підтримує версіонування схеми.Для дотримання принципу розділення відповідальності створимо окремі класи для вхідних даних (запитів) та вихідних (відповідей). Створіть файл DTOs/TodoDtos.cs:
using System.ComponentModel.DataAnnotations;
namespace TodoApi.DTOs;
// DTO для створення нового Todo
public record CreateTodoDto
{
[Required(ErrorMessage = "Title is required")]
[MaxLength(200, ErrorMessage = "Title cannot exceed 200 characters")]
public required string Title { get; init; }
[MaxLength(1000, ErrorMessage = "Description cannot exceed 1000 characters")]
public string? Description { get; init; }
}
// DTO для оновлення існуючого Todo
public record UpdateTodoDto
{
[MaxLength(200)]
public string? Title { get; init; }
[MaxLength(1000)]
public string? Description { get; init; }
public bool? IsCompleted { get; init; }
}
// DTO для відповіді (можна додати додаткові поля, які не є в моделі)
public record TodoResponseDto
{
public int Id { get; init; }
public required string Title { get; init; }
public string? Description { get; init; }
public bool IsCompleted { get; init; }
public DateTime CreatedAt { get; init; }
public DateTime? CompletedAt { get; init; }
}
record замість class для DTO, оскільки records надають immutability за замовчуванням, автоматичну реалізацію Equals/GetHashCode та лаконічний синтаксис. Це ідеально для об'єктів, що передають дані без поведінки.Тепер реалізуємо повний CRUD функціонал через Minimal API endpoints. Додайте наступний код у Program.cs перед app.Run():
// ============= MINIMAL API ENDPOINTS =============
var todosGroup = app.MapGroup("/api/todos")
.WithTags("Todos (Minimal API)")
.WithOpenApi();
// GET /api/todos - Отримати всі завдання
todosGroup.MapGet("/", async (TodoDbContext db) =>
{
var todos = await db.Todos
.OrderByDescending(t => t.CreatedAt)
.ToListAsync();
return Results.Ok(todos.Select(t => new TodoResponseDto
{
Id = t.Id,
Title = t.Title,
Description = t.Description,
IsCompleted = t.IsCompleted,
CreatedAt = t.CreatedAt,
CompletedAt = t.CompletedAt
}));
})
.Produces<IEnumerable<TodoResponseDto>>(StatusCodes.Status200OK);
// GET /api/todos/{id} - Отримати завдання за ID
todosGroup.MapGet("/{id:int}", async (int id, TodoDbContext db) =>
{
var todo = await db.Todos.FindAsync(id);
if (todo is null)
return Results.NotFound(new { message = $"Todo with ID {id} not found" });
var response = new TodoResponseDto
{
Id = todo.Id,
Title = todo.Title,
Description = todo.Description,
IsCompleted = todo.IsCompleted,
CreatedAt = todo.CreatedAt,
CompletedAt = todo.CompletedAt
};
return Results.Ok(response);
})
.Produces<TodoResponseDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);
// POST /api/todos - Створити нове завдання
todosGroup.MapPost("/", async (CreateTodoDto dto, TodoDbContext db) =>
{
// Ручна валідація (у Minimal API немає автоматичної)
var validationResults = new List<ValidationResult>();
var validationContext = new ValidationContext(dto);
if (!Validator.TryValidateObject(dto, validationContext, validationResults, true))
{
var errors = validationResults
.GroupBy(r => r.MemberNames.FirstOrDefault() ?? "")
.ToDictionary(
g => g.Key,
g => g.Select(r => r.ErrorMessage ?? "").ToArray()
);
return Results.ValidationProblem(errors);
}
var todo = new Todo
{
Title = dto.Title,
Description = dto.Description,
IsCompleted = false,
CreatedAt = DateTime.UtcNow
};
db.Todos.Add(todo);
await db.SaveChangesAsync();
var response = new TodoResponseDto
{
Id = todo.Id,
Title = todo.Title,
Description = todo.Description,
IsCompleted = todo.IsCompleted,
CreatedAt = todo.CreatedAt,
CompletedAt = todo.CompletedAt
};
return Results.Created($"/api/todos/{todo.Id}", response);
})
.Produces<TodoResponseDto>(StatusCodes.Status201Created)
.Produces(StatusCodes.Status400BadRequest);
// PUT /api/todos/{id} - Оновити завдання
todosGroup.MapPut("/{id:int}", async (int id, UpdateTodoDto dto, TodoDbContext db) =>
{
var todo = await db.Todos.FindAsync(id);
if (todo is null)
return Results.NotFound(new { message = $"Todo with ID {id} not found" });
// Оновлюємо тільки ті поля, що передані
if (dto.Title is not null)
todo.Title = dto.Title;
if (dto.Description is not null)
todo.Description = dto.Description;
if (dto.IsCompleted.HasValue)
{
todo.IsCompleted = dto.IsCompleted.Value;
todo.CompletedAt = dto.IsCompleted.Value ? DateTime.UtcNow : null;
}
await db.SaveChangesAsync();
var response = new TodoResponseDto
{
Id = todo.Id,
Title = todo.Title,
Description = todo.Description,
IsCompleted = todo.IsCompleted,
CreatedAt = todo.CreatedAt,
CompletedAt = todo.CompletedAt
};
return Results.Ok(response);
})
.Produces<TodoResponseDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);
// DELETE /api/todos/{id} - Видалити завдання
todosGroup.MapDelete("/{id:int}", async (int id, TodoDbContext db) =>
{
var todo = await db.Todos.FindAsync(id);
if (todo is null)
return Results.NotFound(new { message = $"Todo with ID {id} not found" });
db.Todos.Remove(todo);
await db.SaveChangesAsync();
return Results.NoContent();
})
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status404NotFound);
Декомпозиція Minimal API коду:
MapGroup("/api/todos") — створює групу endpoints з спільним префіксом, що зменшує дублювання.WithTags("Todos (Minimal API)") — додає тег для Swagger UI, щоб згрупувати endpoints.WithOpenApi() — генерує метадані OpenAPI для кожного endpointValidator.TryValidateObject() для перевірки DataAnnotationsResults.Created() — повертає 201 з Location header, що вказує на створений ресурс.Produces<T>() — метадані для OpenAPI про можливі типи відповідейProgram.cs, що робить його важко підтримуваним.Тепер створимо той самий функціонал через Controller-based підхід. Спочатку увімкніть підтримку Controllers у Program.cs:
// У Program.cs, після AddDbContext
builder.Services.AddControllers(); // Розкоментуйте цей рядок
// Перед app.Run()
app.MapControllers(); // Додайте цей рядок
Створіть файл Controllers/TodosController.cs:
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using TodoApi.Data;
using TodoApi.DTOs;
using TodoApi.Models;
namespace TodoApi.Controllers;
[ApiController]
[Route("api/[controller]")]
[Produces("application/json")]
public class TodosController : ControllerBase
{
private readonly TodoDbContext _db;
private readonly ILogger<TodosController> _logger;
public TodosController(TodoDbContext db, ILogger<TodosController> logger)
{
_db = db;
_logger = logger;
}
/// <summary>
/// Отримати всі завдання
/// </summary>
/// <returns>Список всіх завдань</returns>
[HttpGet]
[ProducesResponseType(typeof(IEnumerable<TodoResponseDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<IEnumerable<TodoResponseDto>>> GetAll()
{
_logger.LogInformation("Fetching all todos");
var todos = await _db.Todos
.OrderByDescending(t => t.CreatedAt)
.ToListAsync();
var response = todos.Select(MapToDto);
return Ok(response);
}
/// <summary>
/// Отримати завдання за ID
/// </summary>
/// <param name="id">Ідентифікатор завдання</param>
/// <returns>Завдання або 404</returns>
[HttpGet("{id:int}")]
[ProducesResponseType(typeof(TodoResponseDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<TodoResponseDto>> GetById(int id)
{
_logger.LogInformation("Fetching todo with ID {TodoId}", id);
var todo = await _db.Todos.FindAsync(id);
if (todo is null)
{
_logger.LogWarning("Todo with ID {TodoId} not found", id);
return NotFound(new { message = $"Todo with ID {id} not found" });
}
return Ok(MapToDto(todo));
}
/// <summary>
/// Створити нове завдання
/// </summary>
/// <param name="dto">Дані для створення</param>
/// <returns>Створене завдання</returns>
[HttpPost]
[ProducesResponseType(typeof(TodoResponseDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<TodoResponseDto>> Create(CreateTodoDto dto)
{
// Валідація відбувається автоматично завдяки [ApiController]!
_logger.LogInformation("Creating new todo: {Title}", dto.Title);
var todo = new Todo
{
Title = dto.Title,
Description = dto.Description,
IsCompleted = false,
CreatedAt = DateTime.UtcNow
};
_db.Todos.Add(todo);
await _db.SaveChangesAsync();
var response = MapToDto(todo);
return CreatedAtAction(
nameof(GetById),
new { id = todo.Id },
response
);
}
/// <summary>
/// Оновити існуюче завдання
/// </summary>
/// <param name="id">Ідентифікатор завдання</param>
/// <param name="dto">Дані для оновлення</param>
/// <returns>Оновлене завдання або 404</returns>
[HttpPut("{id:int}")]
[ProducesResponseType(typeof(TodoResponseDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<TodoResponseDto>> Update(int id, UpdateTodoDto dto)
{
_logger.LogInformation("Updating todo with ID {TodoId}", id);
var todo = await _db.Todos.FindAsync(id);
if (todo is null)
{
_logger.LogWarning("Todo with ID {TodoId} not found", id);
return NotFound(new { message = $"Todo with ID {id} not found" });
}
// Оновлюємо тільки передані поля
if (dto.Title is not null)
todo.Title = dto.Title;
if (dto.Description is not null)
todo.Description = dto.Description;
if (dto.IsCompleted.HasValue)
{
todo.IsCompleted = dto.IsCompleted.Value;
todo.CompletedAt = dto.IsCompleted.Value ? DateTime.UtcNow : null;
}
await _db.SaveChangesAsync();
return Ok(MapToDto(todo));
}
/// <summary>
/// Видалити завдання
/// </summary>
/// <param name="id">Ідентифікатор завдання</param>
/// <returns>204 No Content або 404</returns>
[HttpDelete("{id:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Delete(int id)
{
_logger.LogInformation("Deleting todo with ID {TodoId}", id);
var todo = await _db.Todos.FindAsync(id);
if (todo is null)
{
_logger.LogWarning("Todo with ID {TodoId} not found", id);
return NotFound(new { message = $"Todo with ID {id} not found" });
}
_db.Todos.Remove(todo);
await _db.SaveChangesAsync();
return NoContent();
}
// Приватний допоміжний метод для маппінгу
private static TodoResponseDto MapToDto(Todo todo) => new()
{
Id = todo.Id,
Title = todo.Title,
Description = todo.Description,
IsCompleted = todo.IsCompleted,
CreatedAt = todo.CreatedAt,
CompletedAt = todo.CompletedAt
};
}
Декомпозиція Controller-based коду:
TodoDbContext та ILogger ін'єктуються один раз, а не в кожному методі[ApiController] немає потреби в ручній перевірці ModelState/// <summary> генерують документацію для Swagger UI[ProducesResponseType] — явно вказуємо можливі типи відповідей для OpenAPICreatedAtAction() — автоматично генерує Location header з URL створеного ресурсуMapToDto() — централізований маппінг, що зменшує дублювання кодуILogger з параметризованими повідомленнямиWebApplicationFactory)Тепер, коли ми маємо дві реалізації одного API, проаналізуємо їх за ключовими метриками:
| Метрика | Minimal API | Controllers | Різниця |
|---|---|---|---|
| Рядків коду для CRUD | ~180 | ~150 | -17% |
| Рядків валідації | ~15 на endpoint | 0 (автоматично) | -100% |
| Рядків маппінгу | ~8 на endpoint | ~8 (один раз) | -87% |
| Загальна складність | Висока (дублювання) | Низька (DRY) | — |
Minimal API:
// Потрібен WebApplicationFactory для інтеграційних тестів
public class TodosMinimalApiTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;
public TodosMinimalApiTests(WebApplicationFactory<Program> factory)
{
_client = factory.CreateClient();
}
[Fact]
public async Task GetAll_ReturnsOkResult()
{
var response = await _client.GetAsync("/api/todos");
response.EnsureSuccessStatusCode();
// ...
}
}
Controllers:
// Можна писати прості unit-тести методів
public class TodosControllerTests
{
[Fact]
public async Task GetAll_ReturnsOkResult()
{
// Arrange
var mockDb = CreateMockDbContext();
var controller = new TodosController(mockDb, Mock.Of<ILogger<TodosController>>());
// Act
var result = await controller.GetAll();
// Assert
var okResult = Assert.IsType<OkObjectResult>(result.Result);
var todos = Assert.IsAssignableFrom<IEnumerable<TodoResponseDto>>(okResult.Value);
Assert.NotEmpty(todos);
}
}
Якщо ви вже маєте проєкт на Minimal API і хочете мігрувати на Controllers, використовуйте цю таблицю як довідник:
| Minimal API | Web API Controllers | Примітки |
|---|---|---|
app.MapGet("/api/todos", handler) | [HttpGet] на методі | Атрибут замість методу розширення |
app.MapPost("/api/todos", handler) | [HttpPost] на методі | — |
app.MapPut("/api/todos/{id}", handler) | [HttpPut("{id}")] | Параметр маршруту в атрибуті |
app.MapDelete("/api/todos/{id}", handler) | [HttpDelete("{id}")] | — |
Results.Ok(data) | Ok(data) або return data | Метод базового класу |
Results.Created(uri, data) | CreatedAtAction(nameof(Action), routeValues, data) | Автоматична генерація URI |
Results.NotFound() | NotFound() або NotFound(message) | — |
Results.BadRequest() | BadRequest() або BadRequest(ModelState) | — |
Results.NoContent() | NoContent() | — |
Results.ValidationProblem(errors) | Автоматично через [ApiController] | Не потрібно вручну |
Параметр TodoDbContext db | Конструктор TodosController(TodoDbContext db) | DI через конструктор |
.WithTags("Todos") | [Tags("Todos")] на класі | Атрибут замість методу |
.WithOpenApi() | Автоматично через Swashbuckle | Не потрібно явно вказувати |
.Produces<T>(200) | [ProducesResponseType(typeof(T), 200)] | Атрибут на методі |
Ручна валідація Validator.TryValidateObject() | Автоматична через [ApiController] | Економія 10-15 рядків на endpoint |
До (Minimal API):
app.MapPost("/api/todos", async (CreateTodoDto dto, TodoDbContext db) =>
{
var validationResults = new List<ValidationResult>();
var validationContext = new ValidationContext(dto);
if (!Validator.TryValidateObject(dto, validationContext, validationResults, true))
{
return Results.ValidationProblem(/* ... */);
}
var todo = new Todo { Title = dto.Title, /* ... */ };
db.Todos.Add(todo);
await db.SaveChangesAsync();
return Results.Created($"/api/todos/{todo.Id}", MapToDto(todo));
});
Після (Controllers):
[HttpPost]
public async Task<ActionResult<TodoResponseDto>> Create(CreateTodoDto dto)
{
// Валідація автоматична!
var todo = new Todo { Title = dto.Title, /* ... */ };
_db.Todos.Add(todo);
await _db.SaveChangesAsync();
return CreatedAtAction(nameof(GetById), new { id = todo.Id }, MapToDto(todo));
}
Економія: 8 рядків коду, автоматична валідація, автоматична генерація Location header.
Вибір між Minimal API та Controllers не є бінарним "правильно/неправильно". Це залежить від контексту вашого проєкту. Ось структурована матриця для прийняття рішення:
✅ Використовуйте Minimal API
✅ Використовуйте Controllers
🔀 Гібридний підхід
Контекст: Команда з 2 розробників створює MVP для валідації ідеї. Потрібно швидко випустити продукт на ринок.
Рішення: Minimal API
Обґрунтування:
Контекст: Велика компанія розробляє внутрішню CRM-систему. Команда з 10 розробників, проєкт на 3 роки.
Рішення: Web API Controllers
Обґрунтування:
Контекст: E-commerce платформа з 15 мікросервісами (auth, catalog, cart, payment, shipping, тощо).
Рішення: Minimal API для кожного сервісу
Обґрунтування:
Контекст: Існуючий монолітний API на Controllers, планується поступова міграція на мікросервіси.
Рішення: Гібридний підхід
Обґрунтування:
Закріпіть вивчений матеріал, виконавши наступні завдання. Вони розбиті на три рівні складності.
У наступному коді є 3 помилки. Знайдіть та виправте їх:
[ApiController]
public class ProductsController : Controller // Помилка 1
{
[HttpGet]
public IActionResult GetAll()
{
var products = new[] { "Product 1", "Product 2" };
return products; // Помилка 2
}
}
// Помилка 3: відсутній атрибут Route
Помилки:
Controller замість ControllerBase (для API потрібен ControllerBase)Ok(products)[Route] (обов'язковий для [ApiController])Виправлений код:
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
[HttpGet]
public IActionResult GetAll()
{
var products = new[] { "Product 1", "Product 2" };
return Ok(products);
}
}
Конвертуйте наступний Minimal API endpoint у Controller action:
app.MapGet("/api/users/{id}", async (int id, UserDbContext db) =>
{
var user = await db.Users.FindAsync(id);
return user is not null ? Results.Ok(user) : Results.NotFound();
});
[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
private readonly UserDbContext _db;
public UsersController(UserDbContext db)
{
_db = db;
}
[HttpGet("{id:int}")]
[ProducesResponseType(typeof(User), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<User>> GetById(int id)
{
var user = await _db.Users.FindAsync(id);
return user is not null ? Ok(user) : NotFound();
}
}
Поясніть, що станеться, якщо видалити атрибут [ApiController] з контролера. Які 3 речі перестануть працювати автоматично?
Без [ApiController]:
ModelState.IsValid[FromBody], [FromRoute]Розширте TodosController методом GetByStatus, що повертає завдання за статусом (completed/pending):
GET /api/todos/status/completed
GET /api/todos/status/pending
[HttpGet("status/{status}")]
[ProducesResponseType(typeof(IEnumerable<TodoResponseDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<IEnumerable<TodoResponseDto>>> GetByStatus(string status)
{
if (status != "completed" && status != "pending")
{
return BadRequest(new { message = "Status must be 'completed' or 'pending'" });
}
bool isCompleted = status == "completed";
var todos = await _db.Todos
.Where(t => t.IsCompleted == isCompleted)
.OrderByDescending(t => t.CreatedAt)
.ToListAsync();
var response = todos.Select(MapToDto);
return Ok(response);
}
Додайте пагінацію до методу GetAll. Приймайте параметри page (номер сторінки) та pageSize (кількість елементів):
GET /api/todos?page=1&pageSize=10
[HttpGet]
[ProducesResponseType(typeof(PagedResult<TodoResponseDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResult<TodoResponseDto>>> GetAll(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 10)
{
if (page < 1) page = 1;
if (pageSize < 1 || pageSize > 100) pageSize = 10;
var totalCount = await _db.Todos.CountAsync();
var todos = await _db.Todos
.OrderByDescending(t => t.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
var items = todos.Select(MapToDto);
var result = new PagedResult<TodoResponseDto>
{
Items = items,
Page = page,
PageSize = pageSize,
TotalCount = totalCount,
TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize)
};
return Ok(result);
}
// DTO для пагінації
public record PagedResult<T>
{
public required IEnumerable<T> Items { get; init; }
public int Page { get; init; }
public int PageSize { get; init; }
public int TotalCount { get; init; }
public int TotalPages { get; init; }
}
Створіть повноцінний CategoriesController для управління категоріями завдань:
Модель:
public class Category
{
public int Id { get; set; }
public required string Name { get; set; }
public string? Description { get; set; }
public int TodoCount { get; set; } // Кількість завдань у категорії
}
Endpoints:
GET /api/categories — всі категоріїGET /api/categories/{id} — категорія за IDPOST /api/categories — створити категоріюPUT /api/categories/{id} — оновити категоріюDELETE /api/categories/{id} — видалити категорію (тільки якщо TodoCount = 0)[ApiController]
[Route("api/[controller]")]
public class CategoriesController : ControllerBase
{
private readonly TodoDbContext _db;
public CategoriesController(TodoDbContext db)
{
_db = db;
}
[HttpGet]
public async Task<ActionResult<IEnumerable<Category>>> GetAll()
{
var categories = await _db.Categories.ToListAsync();
return Ok(categories);
}
[HttpGet("{id:int}")]
public async Task<ActionResult<Category>> GetById(int id)
{
var category = await _db.Categories.FindAsync(id);
return category is not null ? Ok(category) : NotFound();
}
[HttpPost]
public async Task<ActionResult<Category>> Create(CreateCategoryDto dto)
{
var category = new Category
{
Name = dto.Name,
Description = dto.Description,
TodoCount = 0
};
_db.Categories.Add(category);
await _db.SaveChangesAsync();
return CreatedAtAction(nameof(GetById), new { id = category.Id }, category);
}
[HttpPut("{id:int}")]
public async Task<ActionResult<Category>> Update(int id, UpdateCategoryDto dto)
{
var category = await _db.Categories.FindAsync(id);
if (category is null) return NotFound();
if (dto.Name is not null) category.Name = dto.Name;
if (dto.Description is not null) category.Description = dto.Description;
await _db.SaveChangesAsync();
return Ok(category);
}
[HttpDelete("{id:int}")]
public async Task<IActionResult> Delete(int id)
{
var category = await _db.Categories.FindAsync(id);
if (category is null) return NotFound();
if (category.TodoCount > 0)
{
return BadRequest(new
{
message = "Cannot delete category with existing todos",
todoCount = category.TodoCount
});
}
_db.Categories.Remove(category);
await _db.SaveChangesAsync();
return NoContent();
}
}
Уявіть, що у вас є проєкт з 30 Minimal API endpoints у Program.cs (600+ рядків коду). Створіть план міграції на Controllers:
План міграції:
Фаза 1: Підготовка (тиждень 1)
builder.Services.AddControllers() та app.MapControllers()Controllers/, DTOs/, Services/Фаза 2: Міграція за доменами (тижні 2-4)
UsersController, ProductsController, тощоФаза 3: Тестування (тиждень 5)
Фаза 4: Видалення Minimal API (тиждень 6)
Backward compatibility:
У цій статті ми здійснили подорож від функціонального підходу Minimal API до об'єктно-орієнтованої архітектури Web API Controllers. Ви навчилися не просто писати код двома способами, а розуміти філософію кожного підходу та обґрунтовано вибирати інструмент для конкретної задачі.
Ключові висновки:
ControllerBase — він не містить зайвої функціональності для Views та економить ресурси.У наступній статті ми заглибимося в ActionResult<T> та Response Types, розглянемо ієрархію результатів, правильні HTTP-коди та патерни повернення даних з API.