Skip to content

8.4 - Controllers và Actions ​

Tóm tắt: Controller trong Web API nên mỏng: nhận request, gọi một service, ánh xạ kết quả sang mã HTTP. Hai thứ quyết định trải nghiệm viết controller. [ApiController] bật bốn hành vi cùng lúc — đáng giá nhất là tự trả 400 khi model không hợp lệ, nghĩa là bạn không bao giờ phải viết if (!ModelState.IsValid) nữa. Và model binding tự suy luận nguồn dữ liệu: kiểu phức tạp lấy từ body, kiểu đơn giản lấy từ route hoặc query. Quy tắc suy luận này tiện cho tới lúc nó đoán sai — và lúc đó bạn nhận một tham số null không lời giải thích.

Mục tiêu bài học ​

Sau bài này bạn có thể:

  • Giải thích bốn hành vi mà [ApiController] bật.
  • Dự đoán đúng model binding sẽ lấy dữ liệu từ đâu.
  • Chọn giữa IActionResult, ActionResult<T> và kiểu trả về trần.
  • Trả về đúng mã trạng thái cho từng tình huống.
  • Giữ controller mỏng và kiểm thử được.

Nội dung bài học ​

8.4.1 — ControllerBase, không phải Controller ​

csharp
public class CustomersController : ControllerBase   // Web API
public class HomeController : Controller            // MVC co View

Controller kế thừa ControllerBase và thêm View(), ViewBag, ViewData, PartialView() — toàn bộ máy Razor. Trong một API JSON, chúng không bao giờ được dùng nhưng vẫn nằm trong IntelliSense và vẫn được nạp.

Luôn dùng ControllerBase cho API.

8.4.2 — [ApiController] bật bốn thứ ​

csharp
[ApiController]
[Route("api/v1/customers")]
public class CustomersController(ICustomerService service) : ControllerBase

1. Tự động trả 400 khi model không hợp lệ.

csharp
// KHÔNG cần viết nữa
if (!ModelState.IsValid) return BadRequest(ModelState);

Validation chạy trước khi action được gọi. Lỗi trả về dưới dạng ValidationProblemDetails theo chuẩn RFC 7807 (bài 8.5).

2. Suy luận nguồn binding — mục tiếp theo.

3. Bắt buộc attribute routing. Không có [Route] hay [HttpGet] thì ứng dụng không khởi động được. Đây là tính năng: nó loại bỏ hoàn toàn loại lỗi "action này không bao giờ được gọi".

4. Chi tiết lỗi theo chuẩn. NotFound(), BadRequest() và mọi mã 4xx/5xx trả ProblemDetails thay vì body rỗng.

Muốn đổi định dạng lỗi validation:

csharp
builder.Services.Configure<ApiBehaviorOptions>(options =>
{
    options.InvalidModelStateResponseFactory = context =>
        new BadRequestObjectResult(new { /* định dạng riêng của bạn */ });
});

8.4.3 — Model binding và quy tắc suy luận ​

Với [ApiController], nguồn dữ liệu được suy luận theo thứ tự:

Kiểu tham sốNguồn mặc định
Khớp tên tham số trong route template[FromRoute]
Kiểu phức tạp (class, record)[FromBody]
IFormFile, IFormFileCollection[FromForm]
Kiểu đã đăng ký trong DI[FromServices]
Còn lại (kiểu đơn giản)[FromQuery]
csharp
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetById(
    Guid id,                                    // [FromRoute] — khop route
    [FromQuery] bool includeOrders,             // kieu don gian
    CancellationToken ct)                       // dac biet: token cua request

[HttpPost]
public async Task<IActionResult> Create(
    CreateCustomerRequest request,              // [FromBody] — kieu phuc tap
    [FromHeader(Name = "X-Idempotency-Key")] string? idempotencyKey,
    CancellationToken ct)

Ba điều dễ vấp:

Chỉ một tham số được [FromBody]. Body là stream đọc một lần. Hai tham số phức tạp không có attribute → lỗi lúc khởi động. Cần nhiều thứ thì gộp vào một record.

csharp
// SAI
public IActionResult Create(CreateCustomerRequest req, AddressRequest addr);

// DUNG
public record CreateCustomerRequest(string Name, string Email, AddressRequest Address);

Kiểu đơn giản nhiều tham số thì gom lại:

csharp
// Thay vì 5 tham số [FromQuery] rời rạc
public record PagedRequest(int Page = 1, int PageSize = 20, string? Search = null);

[HttpGet]
public Task<IActionResult> GetAll([FromQuery] PagedRequest request, CancellationToken ct);

[FromQuery] trên một kiểu phức tạp bind từng thuộc tính từ query string — và giờ bạn validate được chúng bằng data annotation.

CancellationToken là tham số đặc biệt. Nó không đến từ request mà được framework tiêm — chính là HttpContext.RequestAborted, huỷ khi client ngắt kết nối. Mọi action có I/O đều nên nhận nó và truyền xuống (bài 6.4).

8.4.4 — Kiểu trả về ​

csharp
// 1. ActionResult<T> — tốt nhất cho API
[HttpGet("{id:guid}")]
public async Task<ActionResult<CustomerDto>> GetById(Guid id, CancellationToken ct)
{
    var customer = await service.GetByIdAsync(id, ct);
    if (customer is null) return NotFound();
    return customer;                              // chuyển ngầm sang Ok(customer)
}

// 2. IActionResult — khi trả nhiều kiểu khác nhau
[HttpPost("export")]
public async Task<IActionResult> Export(CancellationToken ct)
    => File(await service.BuildCsvAsync(ct), "text/csv", "customers.csv");

// 3. Kiểu trần — chỉ khi LUÔN thành công
[HttpGet("count")]
public async Task<int> Count(CancellationToken ct) => await service.CountAsync(ct);

ActionResult<T> là lựa chọn mặc định: Swagger biết kiểu thành công là gì, và bạn vẫn trả được NotFound().

Bổ sung [ProducesResponseType] để tài liệu đầy đủ:

csharp
[HttpGet("{id:guid}")]
[ProducesResponseType<CustomerDto>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<CustomerDto>> GetById(Guid id, CancellationToken ct);

8.4.5 — Mã trạng thái ​

Tình huốngMãHelper
Lấy được dữ liệu200Ok(dto)
Tạo mới xong201CreatedAtRoute("GetCustomer", new { id }, dto)
Nhận việc, xử lý sau202Accepted()
Thành công, không có nội dung204NoContent()
Dữ liệu đầu vào sai400BadRequest() — thường tự động
Chưa đăng nhập401Unauthorized()
Đã đăng nhập, không đủ quyền403Forbid()
Không tồn tại404NotFound()
Xung đột trạng thái409Conflict()
Vi phạm quy tắc nghiệp vụ422UnprocessableEntity()

Ba chỗ hay bị dùng sai:

  • 201 cho POST tạo mới, kèm header Location. Dùng CreatedAtRoute để framework sinh URL thay vì nối chuỗi.
  • 401 và 403 khác nhau: 401 là "tôi không biết bạn là ai", 403 là "tôi biết bạn là ai và bạn không được phép".
  • DELETE trả 204, và nên idempotent — xoá một thứ đã xoá vẫn trả 204, không phải 404.

8.4.6 — Controller phải mỏng ​

csharp
// SAI — nghiep vu nam trong controller
[HttpPost]
public async Task<IActionResult> Create(CreateCustomerRequest request, CancellationToken ct)
{
    if (await _db.Customers.AnyAsync(c => c.Email == request.Email, ct))
        return Conflict("Email da ton tai");

    var customer = new Customer { Name = request.Name, Email = request.Email };
    _db.Customers.Add(customer);
    await _db.SaveChangesAsync(ct);
    await _email.SendWelcomeAsync(customer.Email, ct);
    ...
}
csharp
// ĐÚNG — controller chỉ dịch kết quả sang HTTP
[HttpPost]
[ProducesResponseType<CustomerDto>(StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<CustomerDto>> Create(
    CreateCustomerRequest request, CancellationToken ct)
{
    var result = await service.CreateAsync(request, ct);

    return result.IsSuccess
        ? CreatedAtRoute("GetCustomer", new { id = result.Value.Id }, result.Value)
        : Conflict(result.Error);
}

Nghiệp vụ trong controller không test được nếu không dựng cả HttpContext, không dùng lại được từ background job, và không di chuyển sang Minimal API được. Chi tiết về ranh giới này ở Module 16 — Clean Architecture.

8.4.7 — Rà lại code của bạn ​

Danh sách rà soát controller

  • Mọi controller API kế thừa ControllerBase, không phải Controller.
  • Mọi controller API có [ApiController].
  • Không còn dòng if (!ModelState.IsValid) nào.
  • Mỗi action chỉ có nhiều nhất một tham số [FromBody].
  • Tham số query rời rạc đã gom thành một record [FromQuery].
  • Mọi action có I/O đều nhận CancellationToken và truyền xuống.
  • Kiểu trả về là ActionResult<T> với [ProducesResponseType] đầy đủ.
  • POST tạo mới trả 201 kèm Location qua CreatedAtRoute.
  • Không có truy vấn database hay logic nghiệp vụ nào trong controller.

Bài tập áp dụng ​

Bài 1 — Suy luận gắn dữ liệu đoán sai ​

Viết action nhận hai tham số kiểu phức tạp không có thuộc tính chỉ định, chạy ứng dụng và ghi lại lỗi lúc khởi động. Sửa bằng cách gộp thành một record.

Tiêu chí hoàn thành: bạn nêu được quy tắc suy luận mà [ApiController] áp dụng.

Gợi ý và lời giải — Bài 1

Gợi ý. Một request HTTP chỉ có một thân. Vậy hai tham số kiểu phức tạp cùng được suy là [FromBody] thì lấy dữ liệu ở đâu?

Lời giải — bản hỏng:

csharp
[ApiController]
[Route("api/orders")]
public class OrdersController : ControllerBase
{
    [HttpPost]
    public IActionResult Create(CreateOrderRequest order, CustomerInfo customer) => Ok();
}
text
InvalidOperationException: Action 'Tao' has more than one parameter that
was specified or inferred as bound from request body. Only one parameter
per action may be bound from body.

Lỗi xảy ra lúc khởi động, không phải lúc chạy — đây là điểm tốt, vì nó được phát hiện ngay.

Quy tắc suy luận của [ApiController]:

Kiểu tham sốSuy là
Kiểu phức tạp (class, record)[FromBody] — tối đa một
Kiểu đơn giản: int, string, Guid, DateTime[FromRoute] nếu tên khớp đoạn route, ngược lại [FromQuery]
Kiểu có đăng ký trong DI[FromServices]
IFormFile, IFormFileCollection[FromForm]
CancellationTokenLấy từ HttpContext.RequestAborted

Sửa bằng cách gộp:

csharp
public record CreateOrderRequest(
    int CustomerId,
    string DiaChiGiao,
    List<OrderLineRequest> Lines);

[HttpPost]
public async Task<IActionResult> Tao(CreateOrderRequest req, CancellationToken ct) { }

Một tham số phức tạp, không còn nhập nhằng.

Hoặc chỉ định rõ khi dữ liệu thật sự đến từ nhiều nơi:

csharp
[HttpPost("{customerId:int}/orders")]
public async Task<IActionResult> Tao(
    [FromRoute] int customerId,
    [FromBody] CreateOrderRequest req,
    [FromQuery] bool guiEmail = true,
    CancellationToken ct = default) { }

Ba điều [ApiController] bật ngoài suy luận gắn dữ liệu:

  1. Tự động trả 400 khi model không hợp lệ. Không cần viết if (!ModelState.IsValid) ở mỗi action.
  2. Lỗi trả về theo định dạng ProblemDetails. Nhất quán cho toàn API.
  3. Bắt buộc dùng attribute routing. Không dùng được route theo quy ước.

Điểm 1 rất tiện nhưng cũng đáng lưu ý: nó nghĩa là action không bao giờ chạy khi model sai. Nếu bạn muốn xử lý lỗi xác thực theo cách riêng, phải tắt nó:

csharp
builder.Services.Configure<ApiBehaviorOptions>(o =>
    o.SuppressModelStateInvalidFilter = true);

Một cái bẫy với [FromServices]. Từ .NET 7, suy luận này áp dụng cho mọi kiểu có đăng ký trong DI. Nếu bạn vô tình đăng ký một kiểu DTO vào container, tham số kiểu đó sẽ được lấy từ DI thay vì từ thân request — và bạn nhận một đối tượng rỗng mà không hiểu vì sao. Trường hợp này hiếm nhưng rất khó chẩn đoán, nên khi gặp hành vi lạ ở gắn dữ liệu, hãy kiểm tra xem kiểu đó có nằm trong ServiceCollection không.

Bài 2 — Thấy CancellationToken hoạt động ​

Viết endpoint mất 10 giây, gọi bằng curl rồi ngắt giữa chừng. Log giá trị ct.IsCancellationRequested ở server và xác nhận công việc dừng lại.

Tiêu chí hoàn thành: bạn xác nhận được công việc thật sự dừng, không chỉ là token được đánh dấu.

Gợi ý và lời giải — Bài 2

Gợi ý. Bài này áp dụng những gì đã học ở bài 6.4 vào ngữ cảnh controller. Điểm mới: ASP.NET Core tự động cung cấp token, bạn không phải tạo.

Lời giải.

csharp
[HttpGet("cham")]
public async Task<IActionResult> Cham(CancellationToken ct, ILogger<OrdersController> log)
{
    log.LogInformation("BẮT ĐẦU");

    for (int i = 1; i <= 10; i++)
    {
        await Task.Delay(1000, ct);
        log.LogInformation("Bước {Buoc}/10, đã huỷ: {DaHuy}", i, ct.IsCancellationRequested);
    }

    log.LogInformation("XONG");
    return Ok();
}
bash
curl localhost:5000/api/orders/cham
# bấm Ctrl+C sau 3 giây

Log ở server:

text
info: BẮT ĐẦU
info: Bước 1/10, đã huỷ: False
info: Bước 2/10, đã huỷ: False
info: Bước 3/10, đã huỷ: False
(dừng — không có bước 4, không có XONG)

Vòng lặp dừng ở bước 3. Task.Delay(1000, ct) ném OperationCanceledException khi token bị huỷ, và ngoại lệ đó thoát ra khỏi action.

Bỏ ct khỏi Task.Delay rồi thử lại:

text
info: BẮT ĐẦU
info: Bước 1/10, đã huỷ: False
...
info: Bước 4/10, đã huỷ: True      <- token ĐÃ được đánh dấu
info: Bước 5/10, đã huỷ: True
...
info: Bước 10/10, đã huỷ: True
info: XONG                          <- vẫn chạy hết

Token được đánh dấu đúng lúc, nhưng công việc vẫn chạy tới cùng — vì không ai tôn trọng nó. Đây chính là điểm mấu chốt: huỷ trong .NET là hợp tác, và token chỉ có tác dụng khi code thật sự dùng nó.

Ba nơi bắt buộc truyền ct trong một action:

csharp
[HttpGet]
public async Task<IActionResult> Lay(CancellationToken ct)
{
    var ds = await _db.Orders.ToListAsync(ct);                 // 1. truy vấn database
    var tk = await _http.GetFromJsonAsync<Stats>("...", ct);   // 2. gọi API ngoài
    await _cache.SetAsync("key", ds, ct);                      // 3. thao tác cache
    return Ok(ds);
}

Bỏ sót một chỗ nghĩa là chỗ đó vẫn chạy tới cùng sau khi client đã bỏ đi.

ASP.NET Core xử lý OperationCanceledException thế nào. Nó không ghi như lỗi 500. Middleware nhận ra ngoại lệ này gắn với RequestAborted và bỏ qua lặng lẽ — vì client đã ngắt nên không có ai để trả lời.

Nhưng nếu bạn tự bắt rồi ném một ngoại lệ khác, hệ thống giám sát sẽ báo động giả:

csharp
// SAI — tạo cảnh báo giả mỗi khi người dùng đóng tab
try { await ProcessAsync(ct); }
catch (Exception ex) { throw new ApplicationException("Xử lý thất bại", ex); }

// ĐÚNG — để OperationCanceledException đi qua
try { await ProcessAsync(ct); }
catch (OperationCanceledException) { throw; }
catch (Exception ex) { throw new ApplicationException("Xử lý thất bại", ex); }

Một lưu ý về việc đo tác dụng. Trong môi trường thật, một phần đáng kể request bị huỷ giữa chừng — người dùng đóng tab, bấm lại, chuyển trang. Đo tỉ lệ này bằng một bộ đếm trong middleware sẽ cho bạn biết việc truyền token có đáng giá tới mức nào với hệ thống của mình.

Bài 3 — Làm mỏng một controller ​

Lấy một action có truy vấn database trực tiếp, chuyển nghiệp vụ xuống service, và viết unit test cho service đó mà không dựng HttpContext. So sánh với việc kiểm thử action cũ.

Tiêu chí hoàn thành: bài kiểm thử mới không tham chiếu tới bất kỳ kiểu nào của ASP.NET Core.

Gợi ý và lời giải — Bài 3

Gợi ý. Controller có đúng ba trách nhiệm: nhận dữ liệu vào, gọi nghiệp vụ, ánh xạ kết quả sang phản hồi HTTP. Mọi thứ khác thuộc về nơi khác.

Lời giải — controller béo:

csharp
[HttpPost]
public async Task<IActionResult> Tao(CreateOrderRequest req, CancellationToken ct)
{
    var customer = await _db.Customers.FirstOrDefaultAsync(c => c.Id == req.CustomerId, ct);
    if (customer is null) return NotFound("Không tìm thấy khách hàng");

    if (customer.HasOverdueDebt) return BadRequest("Khách hàng còn nợ quá hạn");

    decimal total = 0;
    foreach (var line in req.Lines)
    {
        var product = await _db.Products.FindAsync([line.ProductId], ct);
        if (product is null) return BadRequest($"Sản phẩm {line.ProductId} không tồn tại");
        if (product.Stock < line.Quantity) return BadRequest($"Sản phẩm {product.Name} không đủ hàng");
        total += product.Price * line.Quantity;
        product.Stock -= line.Quantity;
    }

    var order = new Order { CustomerId = customer.Id, Total = total, CreatedAt = DateTime.UtcNow };
    _db.Orders.Add(order);
    await _db.SaveChangesAsync(ct);

    await _mail.SendConfirmationAsync(customer.Email, order.Id, ct);
    return CreatedAtAction(nameof(GetById), new { id = order.Id }, order);
}

Kiểm thử action này đòi hỏi: một DbContext thật hoặc một bản giả rất phức tạp, một IEmailSender, một ControllerContext với HttpContext, và phải kiểm tra IActionResult bằng cách ép kiểu.

Sau — controller mỏng:

csharp
[HttpPost]
public async Task<IActionResult> Create(CreateOrderRequest req, CancellationToken ct)
{
    var result = await _orderService.CreateAsync(req, ct);

    return result.Kind switch
    {
        ResultKind.Success    => CreatedAtAction(nameof(GetById), new { id = result.Value!.Id }, result.Value),
        ResultKind.NotFound   => NotFound(result.Error),
        ResultKind.RuleViolation => BadRequest(result.Error),
        _ => StatusCode(500)
    };
}

Nghiệp vụ chuyển xuống OrderService, và bài kiểm thử trở thành:

csharp
[Fact]
public async Task CustomerWithOverdueDebt_ReturnsRuleViolation()
{
    var repo = Substitute.For<ICustomerRepository>();
    repo.GetAsync(42, Arg.Any<CancellationToken>())
        .Returns(new Customer("An", "[email protected]") { HasOverdueDebt = true });

    var svc = new OrderService(repo, Substitute.For<IProductRepository>(),
                               Substitute.For<IOrderRepository>(),
                               Substitute.For<IEmailSender>());

    var result = await svc.CreateAsync(new CreateOrderRequest(42, "...", []), default);

    Assert.Equal(ResultKind.RuleViolation, result.Kind);
    Assert.Contains("nợ quá hạn", result.Error);
}

Không một dòng nào tham chiếu tới ASP.NET Core. Không HttpContext, không ControllerContext, không IActionResult.

So sánh:

Kiểm thử action cũKiểm thử service
Cần HttpContextCóKhông
Cần DbContextCó, hoặc bản giả phức tạpKhông — bản giả repository đơn giản
Kiểm tra kết quảÉp kiểu IActionResultSo sánh giá trị trả về
Thời gian chạyHàng trăm mili-giâyVài mili-giây
Số dòng chuẩn bị20 trở lên5

Vẫn nên có kiểm thử tích hợp cho controller — nhưng ít hơn, và cho mục đích khác:

csharp
[Fact]
public async Task Tao_KhachHangKhongTonTai_TraVe404()
{
    var res = await _client.PostAsJsonAsync("/api/orders", new { customerId = 99999, lines = new[] { } });
    Assert.Equal(HttpStatusCode.NotFound, res.StatusCode);
}

Kiểm thử tích hợp xác nhận việc ánh xạ sang HTTP đúng — mã trạng thái, định tuyến, xác thực, thứ tự middleware. Nó không cần bao phủ mọi nhánh nghiệp vụ, vì unit test của service đã làm việc đó nhanh hơn nhiều.

Phép thử một câu cho controller. Action này có dòng nào không phải là nhận dữ liệu, gọi service, hoặc ánh xạ kết quả không? Có thì dòng đó nên chuyển xuống dưới. Với tiêu chí này, phần lớn action chỉ còn 3 tới 8 dòng.

Tự kiểm tra ​

ControllerBase khác Controller ở đâu?

Controller kế thừa ControllerBase và thêm View, ViewBag, ViewData, PartialView, tức toàn bộ máy Razor. Trong một API JSON thì chúng không bao giờ được dùng nhưng vẫn nằm trong IntelliSense và vẫn được nạp. Với API thì luôn dùng ControllerBase.

[ApiController] bật những gì?

Bốn thứ. Tự động trả 400 khi model không hợp lệ, chạy trước khi action được gọi nên không cần viết kiểm tra ModelState. Suy luận nguồn binding. Bắt buộc attribute routing, thiếu thì ứng dụng không khởi động được. Và trả lỗi theo chuẩn ProblemDetails thay vì body rỗng.

Model binding suy luận nguồn dữ liệu thế nào?

Tham số khớp tên trong route template lấy từ route, kiểu phức tạp lấy từ body, IFormFile lấy từ form, kiểu đã đăng ký trong DI lấy từ container, còn lại lấy từ query string. Quy tắc này tiện cho tới lúc nó đoán sai, và khi đó bạn nhận một tham số null không lời giải thích.

Vì sao chỉ một tham số được FromBody?

Vì body là stream đọc một lần. Hai tham số kiểu phức tạp không có attribute sẽ gây lỗi lúc khởi động. Cần nhiều thứ thì gộp chúng vào một record duy nhất.

CancellationToken trong action đến từ đâu?

Nó không đến từ request mà được framework tiêm, chính là HttpContext.RequestAborted, và được kích hoạt khi client ngắt kết nối. Mọi action có I/O đều nên nhận nó và truyền xuống các lớp bên dưới.

401 và 403 khác nhau thế nào?

401 nghĩa là tôi không biết bạn là ai, tức chưa xác thực hoặc token không hợp lệ. 403 nghĩa là tôi biết bạn là ai nhưng bạn không được phép làm việc này. Dùng lẫn hai mã khiến client không biết nên đăng nhập lại hay báo cho quản trị viên.

Kết luận ​

Ba điều đáng nhớ nhất:

  1. [ApiController] xoá bỏ if (!ModelState.IsValid) và bắt buộc attribute routing.
  2. Một [FromBody] duy nhất mỗi action; gom tham số query thành record.
  3. Controller chỉ dịch kết quả sang HTTP. Nghiệp vụ trong controller là nghiệp vụ không test được.

Tham khảo ​

Điều hướng ​

Phát hành theo giấy phép MIT.