Skip to content

8.3 - Routing ​

Tóm tắt: Routing quyết định request nào chạy code nào, và nó xảy ra ở hai bước tách biệt: UseRouting() chọn endpoint, MapControllers() chạy endpoint — mọi middleware nằm giữa hai bước đó đều biết endpoint nào sắp chạy. Thứ đáng học kỹ nhất là route constraint: {id:int} không chỉ để đẹp, nó khiến /customers/abc trả 404 thay vì đi vào action rồi nổ khi ép kiểu. Và khi nhiều route cùng khớp, ASP.NET Core không chọn cái đăng ký trước — nó chấm điểm theo độ cụ thể, nên /customers/active thắng /customers/{id} bất kể thứ tự.

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

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

  • Thiết kế route REST với attribute routing.
  • Dùng constraint để chặn dữ liệu sai ngay ở tầng định tuyến.
  • Giải thích cách ASP.NET Core chọn khi nhiều route cùng khớp.
  • Triển khai versioning cho API.
  • Chọn giữa controller và Minimal API có lý do.

Nội dung bài học ​

8.3.1 — Hai bước của routing ​

csharp
app.UseRouting();        // buoc 1: CHON endpoint, chua chay
app.UseAuthorization();  // biết endpoint rồi -> đọc được [Authorize] trên nó
app.MapControllers();    // buoc 2: CHAY endpoint

Tách làm hai bước là có chủ đích: middleware nằm giữa có thể đọc metadata của endpoint sắp chạy. Đó là cách UseAuthorization() biết action nào cần quyền gì mà không phải tự phân tích URL.

Sơ đồ hai bước của routing: Request đi qua các middleware đầu pipeline như ExceptionHandler và StaticFiles, lúc này GetEndpoint() còn trả null. UseRouting() là bước 1, chọn endpoint; nếu không route nào khớp, ví dụ /customers/abc với constraint id:guid, kết quả là 404. Sau UseRouting(), middleware của bạn và UseAuthorization() đọc được metadata của endpoint như [Authorize]. MapControllers() là bước 2, chạy action đã chọn. Khi nhiều route cùng khớp, route cụ thể nhất thắng, không theo thứ tự khai báo.Sơ đồ hai bước của routing: Request đi qua các middleware đầu pipeline như ExceptionHandler và StaticFiles, lúc này GetEndpoint() còn trả null. UseRouting() là bước 1, chọn endpoint; nếu không route nào khớp, ví dụ /customers/abc với constraint id:guid, kết quả là 404. Sau UseRouting(), middleware của bạn và UseAuthorization() đọc được metadata của endpoint như [Authorize]. MapControllers() là bước 2, chạy action đã chọn. Khi nhiều route cùng khớp, route cụ thể nhất thắng, không theo thứ tự khai báo.

File gốc: nền sáng · nền tối

csharp
app.UseRouting();
app.Use(async (ctx, next) =>
{
    var name = ctx.GetEndpoint()?.DisplayName;   // null nếu đặt trước UseRouting
    ...
    await next(ctx);
});

8.3.2 — Attribute routing ​

csharp
[ApiController]
[Route("api/v1/[controller]")]              // => api/v1/customers
public class CustomersController : ControllerBase
{
    [HttpGet]                                // GET    api/v1/customers
    public Task<IActionResult> GetAll([FromQuery] PagedRequest request, CancellationToken ct);

    [HttpGet("{id:guid}", Name = "GetCustomer")]  // GET  api/v1/customers/{guid}
    public Task<IActionResult> GetById(Guid id, CancellationToken ct);

    [HttpGet("{id:guid}/orders")]            // GET    api/v1/customers/{guid}/orders
    public Task<IActionResult> GetOrders(Guid id, CancellationToken ct);

    [HttpPost]                               // POST   api/v1/customers
    public Task<IActionResult> Create(CreateCustomerRequest request, CancellationToken ct);

    [HttpPut("{id:guid}")]                   // PUT    api/v1/customers/{guid}
    public Task<IActionResult> Update(Guid id, UpdateCustomerRequest request, CancellationToken ct);

    [HttpDelete("{id:guid}")]                // DELETE api/v1/customers/{guid}
    public Task<IActionResult> Delete(Guid id, CancellationToken ct);
}

Ba điểm đáng chú ý:

  • [controller] là token thay bằng tên lớp bỏ hậu tố Controller. Tiện, nhưng nghĩa là đổi tên lớp là đổi URL — một thay đổi phá vỡ hợp đồng API mà refactor tool sẽ vui vẻ làm hộ bạn. Với API công khai, viết thẳng [Route("api/v1/customers")].
  • Name = "GetCustomer" cho phép CreatedAtRoute("GetCustomer", ...) sinh URL mà không nối chuỗi.
  • Route lồng nhau phản ánh quan hệ sở hữu: /customers/{id}/orders nói rằng order thuộc về customer. Đừng lồng quá hai tầng — /customers/{a}/orders/{b}/lines/{c} khó đọc và khó phân quyền.

Route tuyệt đối (bắt đầu bằng /) bỏ qua [Route] ở lớp:

csharp
[HttpGet("/health/customers")]    // KHÔNG có tiền tố api/v1

8.3.3 — Constraint chặn lỗi sớm ​

ConstraintVí dụKhớp
int{id:int}Số nguyên
guid{id:guid}GUID
long, decimal, bool, datetime{d:datetime}Theo kiểu
min(n), max(n), range(a,b){page:min(1)}Giá trị số
minlength(n), maxlength(n), length(n){code:length(3)}Độ dài chuỗi
alpha{name:alpha}Chỉ chữ cái
regex(...){code:regex(^[A-Z]{{3}}$)}Biểu thức chính quy
required{id:required}Không rỗng
csharp
[HttpGet("{id:guid}")]           // /customers/abc -> 404, không vào action
[HttpGet("page/{n:int:min(1)}")] // ghép nhiều constraint bằng dấu hai chấm

Điểm quan trọng: constraint sai thì trả 404, không phải 400. Về REST điều đó hợp lý — không có tài nguyên nào ở URL đó. Nhưng nó có nghĩa là client nhận 404 mà không biết mình gõ sai định dạng. Với những chỗ cần thông báo rõ, nhận string rồi validate trong action để trả 400 kèm lý do.

Trong regex, dấu { và } phải nhân đôi vì route template đã dùng chúng.

Constraint không phải cơ chế bảo mật hay validation nghiệp vụ — nó chỉ lọc định dạng. Validation thật ở bài 8.5.

8.3.4 — Khi nhiều route cùng khớp ​

csharp
[HttpGet("{id:guid}")]     // /customers/{guid}
[HttpGet("active")]        // /customers/active

ASP.NET Core không chọn theo thứ tự đăng ký. Nó chấm điểm theo độ cụ thể, ưu tiên giảm dần:

  1. Đoạn chữ cố định (active) thắng tham số.
  2. Tham số có constraint ({id:guid}) thắng tham số trần.
  3. Tham số trần ({id}) thắng catch-all.
  4. Catch-all ({**rest}) thua tất cả.

Nên /customers/active luôn vào đúng action dù nó đăng ký sau.

Khi hai route cùng điểm, bạn nhận AmbiguousMatchException lúc chạy — không phải lúc biên dịch:

csharp
[HttpGet("{id:int}")]      public IActionResult A(int id);
[HttpGet("{code:int}")]    public IActionResult B(int code);   // cùng điểm -> ném

Gỡ bằng [HttpGet("...", Order = -1)] (số nhỏ chạy trước), nhưng tốt hơn là sửa thiết kế route cho không mơ hồ.

8.3.5 — Versioning ​

bash
dotnet add package Asp.Versioning.Mvc
csharp
builder.Services.AddApiVersioning(options =>
{
    options.DefaultApiVersion = new ApiVersion(1, 0);
    options.AssumeDefaultVersionWhenUnspecified = true;
    options.ReportApiVersions = true;          // tra header api-supported-versions
}).AddMvc();
csharp
[ApiVersion("1.0")]
[ApiVersion("2.0")]
[Route("api/v{version:apiVersion}/customers")]
public class CustomersController : ControllerBase
{
    [HttpGet, MapToApiVersion("1.0")]
    public Task<IActionResult> GetAllV1(CancellationToken ct);

    [HttpGet, MapToApiVersion("2.0")]
    public Task<IActionResult> GetAllV2(CancellationToken ct);
}

Ba cách đặt version, và mỗi cách có cái giá của nó:

CáchVí dụĐánh đổi
URL/api/v2/customersDễ thấy, dễ test bằng trình duyệt; nhưng URL của cùng một tài nguyên thay đổi theo version
Headerapi-version: 2.0URL ổn định, đúng tinh thần REST; nhưng không test được bằng thanh địa chỉ
Query?api-version=2.0Dễ dùng; nhưng lẫn với tham số nghiệp vụ

URL versioning phổ biến nhất trong thực tế vì nó hiển nhiên — người đọc log, người viết tài liệu và người debug đều thấy ngay.

8.3.6 — Minimal API hay controller ​

csharp
var customers = app.MapGroup("/api/v1/customers")
                   .WithTags("Customers")
                   .RequireAuthorization();

customers.MapGet("/", async (ICustomerService svc, CancellationToken ct)
    => Results.Ok(await svc.GetAllAsync(ct)));

customers.MapGet("/{id:guid}", async (Guid id, ICustomerService svc, CancellationToken ct)
    => await svc.GetByIdAsync(id, ct) is { } c ? Results.Ok(c) : Results.NotFound())
    .WithName("GetCustomer");

customers.MapPost("/", async (CreateCustomerRequest req, ICustomerService svc, CancellationToken ct) =>
{
    var c = await svc.CreateAsync(req, ct);
    return Results.CreatedAtRoute("GetCustomer", new { id = c.Id }, c);
});

MapGroup (.NET 7+) xoá phần lớn lý do cũ để chọn controller: nó cho bạn tiền tố chung, RequireAuthorization() chung, filter chung.

Chọn Minimal API khiChọn controller khi
Service nhỏ, ít endpointAPI lớn, nhiều action cùng nhóm
Ưu tiên thời gian khởi động và thông lượngCần action filter, model binder tuỳ biến
Endpoint chủ yếu là chuyển tiếpCần [ApiController] với validation tự động
Team quen lập trình hàmTeam quen MVC, có sẵn quy ước

Đừng trộn hai kiểu cho cùng một tài nguyên. Hai nơi định nghĩa /customers với hai quy ước phản hồi khác nhau là nợ kỹ thuật ngay từ ngày đầu.

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

Danh sách rà soát routing

  • Mọi tham số route đều có constraint phù hợp kiểu dữ liệu.
  • API công khai không dùng token [controller] trong route.
  • Route có tên khi cần sinh URL bằng CreatedAtRoute.
  • Không lồng tài nguyên quá hai tầng.
  • Đã kiểm tra không có route nào mơ hồ gây AmbiguousMatchException.
  • API có chiến lược version rõ ràng và nhất quán.
  • Một tài nguyên chỉ được định nghĩa bằng một kiểu: controller hoặc Minimal API.
  • Middleware cần biết endpoint được đặt sau UseRouting().

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

Bài 1 — Constraint chặn lỗi sớm ​

Viết hai action, một dùng {id:guid} và một dùng {id}. Gọi cả hai với abc và ghi lại mã trạng thái cùng nơi lỗi xảy ra.

Tiêu chí hoàn thành: bạn chỉ ra được hai lỗi xảy ra ở hai tầng khác nhau của đường ống.

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

Gợi ý. Constraint được kiểm tra trong bước khớp route, tức trước khi action được chọn. Không có constraint thì việc chuyển đổi kiểu diễn ra ở bước gắn dữ liệu, muộn hơn.

Lời giải.

csharp
[HttpGet("v1/{id:guid}")]
public IActionResult CoConstraint(Guid id) => Ok(id);

[HttpGet("v2/{id}")]
public IActionResult KhongConstraint(Guid id) => Ok(id);
bash
curl -i localhost:5000/api/customers/v1/abc
curl -i localhost:5000/api/customers/v2/abc

Kết quả:

v1 có constraintv2 không constraint
Mã trạng thái404 Not Found400 Bad Request
Xảy ra ở tầngKhớp routeGắn dữ liệu
Action có chạy khôngKhôngKhông
Thân phản hồiTrốngProblemDetails với lỗi theo trường

404 với v1 là đúng. Constraint là một phần của định nghĩa route. Đường dẫn /v1/abc không khớp với route nào, nên theo đúng ngữ nghĩa HTTP, tài nguyên đó không tồn tại.

400 với v2 cũng đúng. Route khớp, nhưng giá trị abc không chuyển được thành Guid, nên yêu cầu sai định dạng.

Chọn cái nào — tuỳ vào điều bạn muốn nói với client:

Muốn nóiDùng
"Đường dẫn này không tồn tại"Constraint, trả 404
"Đường dẫn đúng, giá trị sai"Không constraint, trả 400 kèm chi tiết

Với API công khai, 400 kèm chi tiết thường hữu ích hơn — client biết mình sai ở đâu. Với route nội bộ hoặc khi muốn giấu cấu trúc, 404 an toàn hơn.

Lợi ích thật của constraint: phân giải nhập nhằng.

csharp
[HttpGet("{id:int}")]      public IActionResult TheoId(int id) => Ok();
[HttpGet("{slug}")]        public IActionResult TheoSlug(string slug) => Ok();

Không có :int, hai route này nhập nhằng và ứng dụng ném AmbiguousMatchException. Với constraint, /42 vào action đầu và /khach-hang-vip vào action sau — routing tự phân biệt.

Các constraint hay dùng:

csharp
"{id:int}"                  // số nguyên
"{id:guid}"                 // GUID
"{id:min(1)}"               // số nguyên từ 1
"{page:int:min(1)}"         // ghép nhiều constraint
"{code:length(3)}"          // đúng 3 ký tự
"{slug:regex(^[a-z0-9-]+$)}"// biểu thức chính quy
"{date:datetime}"           // ngày giờ

Cảnh báo về regex. Biểu thức chính quy phức tạp trong route được đánh giá cho mọi request đi qua, và một biểu thức viết kém có thể bị khai thác để gây quá tải CPU. Với quy tắc phức tạp, hãy xác thực ở tầng gắn dữ liệu hoặc ở tầng nghiệp vụ thay vì nhét vào route.

Viết constraint riêng khi cần:

csharp
public class BranchCodeConstraint : IRouteConstraint
{
    private static readonly HashSet<string> Valid = new(StringComparer.OrdinalIgnoreCase)
        { "HN", "HCM", "DN", "CT" };

    public bool Match(HttpContext? c, IRouter? r, string k,
                      RouteValueDictionary v, RouteDirection d)
        => v.TryGetValue(k, out var x) && x is string s && Valid.Contains(s);
}

builder.Services.Configure<RouteOptions>(o =>
    o.ConstraintMap["branch"] = typeof(BranchCodeConstraint));

// Dùng: [HttpGet("{code:branch}/customers")]

Bài 2 — Thứ tự khai báo không quyết định route nào thắng ​

Đăng ký [HttpGet("{id:guid}")] trước [HttpGet("active")], gọi /customers/active và xác nhận nó vào đúng action thứ hai.

Tiêu chí hoàn thành: bạn nêu được quy tắc chấm điểm mà routing dùng để chọn.

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

Gợi ý. Routing của ASP.NET Core không chọn theo thứ tự khai báo. Nó tính một điểm số cho từng route khớp được và chọn route cụ thể nhất.

Lời giải.

csharp
[ApiController]
[Route("api/customers")]
public class CustomersController : ControllerBase
{
    [HttpGet("{id:guid}")]                          // khai báo TRƯỚC
    public IActionResult TheoId(Guid id) => Ok($"theo id: {id}");

    [HttpGet("active")]                             // khai báo SAU
    public IActionResult DangHoatDong() => Ok("danh sách đang hoạt động");
}
bash
curl localhost:5000/api/customers/active
# "danh sách đang hoạt động"     <- action thứ hai, dù khai báo sau

Quy tắc chấm điểm — đoạn cụ thể thắng đoạn tham số:

Loại đoạn routeĐộ ưu tiên
Văn bản cố định: activeCao nhất
Tham số có constraint: {id:guid}Trung bình
Tham số không constraint: {id}Thấp
Tham số bắt phần còn lại: {**path}Thấp nhất

active là văn bản cố định nên nó thắng {id:guid}, bất kể thứ tự khai báo. Đây là hành vi đúng và mong muốn: route càng cụ thể thì càng nên được ưu tiên.

Vì sao thiết kế như vậy. Nếu routing chọn theo thứ tự khai báo, việc thêm một action mới có thể vô tình "che" một action cũ, và thứ tự các phương thức trong file trở thành một phần của hành vi hệ thống — rất dễ vỡ khi ai đó sắp xếp lại code.

Khi nào cần Order. Chỉ khi hai route có cùng điểm số và bạn muốn một cái thắng:

csharp
[HttpGet("{slug}", Order = 1)]        // xét sau
public IActionResult TheoSlug(string slug) => Ok();

[HttpGet("{id:int}", Order = 0)]      // xét trước
public IActionResult TheoId(int id) => Ok();

Nhưng trong ví dụ này Order là không cần thiết, vì {id:int} đã có constraint nên điểm cao hơn {slug}. Dùng Order khi chưa cần là dấu hiệu bạn đang chữa triệu chứng thay vì sửa thiết kế route.

Khi thật sự nhập nhằng — ứng dụng ném lúc chạy:

csharp
[HttpGet("{a}")] public IActionResult M1(string a) => Ok();
[HttpGet("{b}")] public IActionResult M2(string b) => Ok();
text
AmbiguousMatchException: The request matched multiple endpoints. Matches:
  CustomersController.M1
  CustomersController.M2

Chú ý: ngoại lệ này xảy ra lúc chạy, không phải lúc build — và chỉ khi có request thật đi tới đường dẫn đó. Với một route ít dùng, nó có thể nằm im nhiều tháng.

Phát hiện sớm bằng kiểm thử:

csharp
[Fact]
public void KhongCoRouteNaoNhapNhang()
{
    var nguon = _factory.Services.GetRequiredService<EndpointDataSource>();
    var trung = nguon.Endpoints
        .OfType<RouteEndpoint>()
        .GroupBy(e => e.RoutePattern.RawText)
        .Where(g => g.Count() > 1)
        .ToList();

    Assert.Empty(trung);
}

Bài kiểm thử này chạy trong CI và bắt được nhập nhằng trước khi triển khai. Nó cũng là ví dụ tốt cho việc kiểm thử tích hợp bắt được lớp lỗi mà unit test không chạm tới.

Xem toàn bộ route đã đăng ký khi cần gỡ rối:

csharp
app.MapGet("/routes", (EndpointDataSource ds) =>
    ds.Endpoints.OfType<RouteEndpoint>()
      .Select(e => new { e.RoutePattern.RawText, e.Order, e.DisplayName }));

Chỉ bật ở môi trường Development.

Bài 3 — Route nhập nhằng và hai cách gỡ ​

Tạo hai action có route cùng điểm số, chạy ứng dụng và ghi lại AmbiguousMatchException. Gỡ bằng Order rồi bằng cách sửa thiết kế, so sánh hai cách.

Tiêu chí hoàn thành: bạn nêu được vì sao sửa thiết kế tốt hơn dùng Order.

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

Gợi ý. Order khiến routing chọn được, nhưng nó không trả lời câu hỏi: vì sao hai đường dẫn khác nhau về ý nghĩa lại trông giống hệt nhau?

Lời giải — tình huống nhập nhằng thật:

csharp
[HttpGet("{id}")]
public async Task<IActionResult> TheoId(string id) { }          // /customers/42

[HttpGet("{email}")]
public async Task<IActionResult> TheoEmail(string email) { }    // /customers/[email protected]
text
AmbiguousMatchException: The request matched multiple endpoints.

Cách 1 — dùng Order:

csharp
[HttpGet("{id}", Order = 0)]
public async Task<IActionResult> TheoId(string id) { }

[HttpGet("{email}", Order = 1)]
public async Task<IActionResult> TheoEmail(string email) { }

Ứng dụng chạy được. Nhưng action thứ hai không bao giờ được gọi tới — mọi thứ đều khớp {id} trước. Bạn vừa đổi một ngoại lệ ồn ào thành một action chết im lặng.

Cách 2 — thêm constraint để chúng thật sự khác nhau:

csharp
[HttpGet("{id:int}")]
public async Task<IActionResult> TheoId(int id) { }

[HttpGet("by-email/{email}")]
public async Task<IActionResult> TheoEmail(string email) { }

Cách 3 — sửa thiết kế, và đây là cách đúng nhất:

csharp
[HttpGet("{id:int}")]
public async Task<IActionResult> TheoId(int id) { }

[HttpGet]
public async Task<IActionResult> Tim([FromQuery] string? email, [FromQuery] string? ten,
                                     [FromQuery] int page = 1, [FromQuery] int size = 20) { }

Vì sao cách 3 tốt nhất. Nó phản ánh đúng ngữ nghĩa REST ở bài 9.1:

  • Đường dẫn định danh một tài nguyên cụ thể: /customers/42.
  • Tham số truy vấn dùng để lọc một tập hợp: /[email protected].

Tìm theo email về bản chất là lọc, không phải định danh — nên nó thuộc về tham số truy vấn. Nhập nhằng biến mất không phải vì ta thêm mẹo, mà vì thiết kế đã đúng.

So sánh ba cách:

OrderConstraint / đổi đường dẫnSửa thiết kế
Hết ngoại lệCóCóCó
Cả hai action dùng đượcKhôngCóCó
Đường dẫn phản ánh ngữ nghĩaKhôngMột phầnCó
Mở rộng thêm tiêu chí lọcThêm action mớiThêm action mớiThêm tham số

Dòng cuối đáng chú ý: với cách 3, thêm tìm theo số điện thoại chỉ là thêm một tham số, không phải thêm một endpoint.

Nguyên tắc chung. Nhập nhằng route hầu như luôn là triệu chứng của thiết kế API chưa rõ ràng, không phải một vấn đề kỹ thuật cần mẹo để vượt qua. Khi gặp nó, câu hỏi đầu tiên nên là "hai đường dẫn này khác nhau về ý nghĩa ở chỗ nào?" — và nếu câu trả lời khó diễn đạt, thiết kế cần sửa.

Order nên được dành cho những trường hợp hiếm hoi mà bạn thật sự muốn một route bắt phần còn lại xếp sau cùng, ví dụ route dự phòng cho ứng dụng một trang:

csharp
app.MapFallbackToFile("index.html");     // luôn xét cuối cùng

Tự kiểm tra ​

Vì sao routing tách thành UseRouting và MapControllers?

Để mọi middleware nằm giữa hai bước đó biết endpoint nào sắp chạy và đọc được metadata của nó. Đó là cách UseAuthorization biết action nào cần quyền gì mà không phải tự phân tích URL. Middleware đặt trước UseRouting thì GetEndpoint trả về null.

Route constraint sai thì trả mã lỗi nào?

404 chứ không phải 400, vì về mặt REST thì không có tài nguyên nào ở URL đó. Nghĩa là client nhận 404 mà không biết mình gõ sai định dạng. Với những chỗ cần thông báo rõ thì nhận string rồi validate trong action để trả 400 kèm lý do.

Khi nhiều route cùng khớp thì ASP.NET Core chọn thế nào?

Không theo thứ tự đăng ký mà theo độ cụ thể: đoạn chữ cố định thắng tham số, tham số có constraint thắng tham số trần, tham số trần thắng catch-all. Nên /customers/active luôn vào đúng action dù đăng ký sau /customers/{id}.

AmbiguousMatchException xảy ra khi nào?

Khi hai route có cùng điểm số cụ thể, và nó xảy ra lúc chạy chứ không phải lúc biên dịch. Có thể gỡ bằng thuộc tính Order với số nhỏ chạy trước, nhưng tốt hơn là sửa thiết kế route cho không mơ hồ.

Dùng token [controller] trong route có vấn đề gì?

Nó lấy tên lớp bỏ hậu tố Controller, nên đổi tên lớp là đổi URL. Đó là một thay đổi phá vỡ hợp đồng API mà công cụ refactor sẽ vui vẻ làm hộ bạn mà không cảnh báo. Với API công khai thì nên viết thẳng đường dẫn.

Chọn Minimal API hay controller?

Minimal API hợp với service nhỏ, ít endpoint, ưu tiên thời gian khởi động và thông lượng; MapGroup từ .NET 7 đã xoá phần lớn lý do cũ để chọn controller. Controller hợp với API lớn cần action filter, model binder tuỳ biến hoặc validation tự động của ApiController. Quan trọng nhất là đừng trộn hai kiểu cho cùng một tài nguyên.

Kết luận ​

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

  1. Constraint chặn dữ liệu sai ở tầng định tuyến — nhưng nó trả 404, không phải 400.
  2. Độ cụ thể quyết định, không phải thứ tự đăng ký.
  3. Chọn một kiểu cho mỗi tài nguyên — controller hoặc Minimal API, đừng trộn.

Tham khảo ​

Điều hướng ​

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