ข้าม​ไป​ยัง​เนื้อหา

ชั้น Web — API & error handling

บท​ที่​แล้ว​เรา​เติม​ของ​จริง​ให้ IOrderRepository ด้วย EfOrderRepository แล้ว​ทุก​ชิ้น​ส่วน​ก็​พร้อม​หมด: Domain มี Order.Place(...) ที่​รักษา invariant ของ​ตัวเอง, Application มี PlaceOrderCommand/GetOrderQuery ที่​ส่ง​ผ่าน MediatR ไป​ถึง handler, Infrastructure มี EfOrderRepository ที่​บันทึก​ลง PostgreSQL จริง เหลือ​จุด​เดียว​ที่​ยัง​ไม่มี​อยู่​เลย — ทาง​ที่​ทำให้ HTTP request จาก​เบราว์เซอร์​หรือ app มือถือ​เดิน​เข้า​มา​ถึง​ของ​ทั้งหมด​นี้​ได้ นั่น​คือ​หน้าที่​ของ FoodOrdering.Web ที่​เรา​จะ​สร้าง​ใน​บท​นี้

📦 code ตัวอย่าง

code เต็ม​ของ​บท​นี้​อยู่​ที่ repo kaen-food-ordering (กำลัง​จัด​ทำ) — path: FoodOrdering.Web/

Robert C. Martin เรียก​เว็บ​ว่า​เป็น​แค่ “a detail” ของ​สถาปัตยกรรม — ฟัง​ดู​แปลก​เพราะ​ทุก​วัน​นี้​เรา​เริ่ม project .NET ส่วน​ใหญ่​ด้วย dotnet new webapi ก่อน​สิ่ง​อื่น​ใด แต่ Clean Architecture พลิก​ลำดับ​ความ​สำคัญ​นั้น: Domain คือ​แกน​กลาง​ที่​ทุก​อย่าง​หมุน​รอบ ส่วน ASP.NET Core, HTTP, JSON เป็น​แค่​กลไก​หนึ่ง​ใน​หลาย​ทาง​ที่​จะ “เรียก” แกน​กลาง​นั้น​ให้​ทำงาน ถ้า​พรุ่งนี้​บริษัท​อยาก​เปลี่ยน​จาก REST เป็น gRPC หรือ​แม้แต่​ปิด HTTP ทั้งหมด​แล้ว​รัน​เป็น console job แทน — FoodOrdering.Domain และ FoodOrdering.Application ที่​สร้าง​มา​ตลอด​สาม​บท​ที่​ผ่าน​มา​ไม่​ต้อง​แก้​แม้แต่​บรรทัด​เดียว เพราะ​พวก​มัน​ไม่​เคย​รู้จัก ASP.NET Core มา​ตั้งแต่​ต้น สิ่ง​ที่ FoodOrdering.Web ต้อง​ทำ​จึง​มี​แค่​สาม​อย่าง: รับ HTTP เข้า​มา แปลง​เป็น​ภาษา​ที่ Application เข้าใจ แล้ว​แปลง​ผลลัพธ์​กลับ​เป็น HTTP อีก​ที

แนวคิด​ที่​จัด API ให้ endpoint หนึ่ง​ตัว​รับผิดชอบ use case เดียว​เรียก​ว่า REPRREPR (Request-Endpoint-Response)รูปแบบ​จัด API เป็น endpoint ต่อ1 use case ที่​รับ Request ทำงาน แล้ว​คืน Response — เข้า​กับ Clean Architecture ดี​กว่า controller อ้วน​ที่​รวม​หลาย actionArchitecture — ย่อ​จาก Request → Endpoint → Response: รับ Request เข้า​มา (รูปร่าง​ข้อมูล input ดิบๆ), ให้ Endpoint ทำงาน​ตัว​เดียว​จบ​โดย​ไม่ share constructor หรือ dependency กับ endpoint อื่น แล้ว​คืน Response กลับ​ไป ตรง​ข้าม​กับ controller อ้วน​แบบ MVC ดั้งเดิม​ที่​ยัด​หลาย action ไว้​ใน class เดียว จน​แต่ละ action ต้อง​แบก dependency ของ action อื่น​ที่​ตัวเอง​ไม่​ได้​ใช้​ไป​ด้วย ด้วย minimal API ของ ASP.NET Core เรา​เขียน endpoint แบบ REPR ได้ตรงๆ โดย​ไม่​ต้อง​พึ่ง controller เลย — endpoint หนึ่ง​อัน​คือ route หนึ่ง​เส้น ผูก​กับ use case เดียว

Request ที่ endpoint รับ​เข้า​มา​ไม่ใช่ PlaceOrderCommand ตรงๆ แต่​เป็น DTO ของ​ตัวเอง​ที่​ประกาศ​ไว้​ใน​ฝั่ง Web:

// FoodOrdering.Web/Contracts/PlaceOrderRequest.cs — รูปร่าง JSON ที่ client ส่งเข้ามา
public sealed record PlaceOrderRequest(
IReadOnlyList<PlaceOrderRequestItem> Items,
string? PromoCode,
string? CustomerTier);
public sealed record PlaceOrderRequestItem(Guid ProductId, int Quantity);

สังเกต​ว่า PlaceOrderRequestItem รับ Guid ProductId ดิบๆ ไม่ใช่ value object ProductId ของ Domain โดย​ตั้งใจ — JSON ที่​ยิง​เข้า​มา​ทาง HTTP ไม่มี​ทาง​รู้จัก type ของ .NET เลย มัน​มี​แค่ string/number/object ธรรมดา งาน​แปลง “ข้อมูล​ดิบ​จาก​ภายนอก” ให้​กลาย​เป็น “ภาษา​ที่ Application เข้าใจ” จึง​เป็น​หน้าที่​ของ endpoint เอง:

// FoodOrdering.Web/Program.cs — endpoint สำหรับ use case PlaceOrder
app.MapPost("/orders", async (PlaceOrderRequest request, ISender sender, CancellationToken ct) =>
{
var command = new PlaceOrderCommand(
request.Items.Select(i => new PlaceOrderItem(new ProductId(i.ProductId), i.Quantity)).ToList());
var orderId = await sender.Send(command, ct);
return Results.Created($"/orders/{orderId.Value}", new { orderId = orderId.Value });
});
// endpoint ที่สอง — use case คนละแบบ (query) จึงเป็นคนละ route คนละ command โดยสิ้นเชิง
app.MapGet("/orders/{id:guid}", async (Guid id, ISender sender, CancellationToken ct) =>
{
var dto = await sender.Send(new GetOrderQuery(new OrderId(id)), ct);
return Results.Ok(dto);
});

สังเกต​ว่า PlaceOrderRequest ยัง​เปิด​รับ PromoCode/CustomerTier ไว้​ที่​พื้น​ผิว API ก็​จริง แต่ command ที่​ประกอบ​ขึ้น​มา​มี​แค่ Items อย่าง​เดียว — โปรโมชัน​ถูก​คิด​ผ่าน​สาย PromotionEngine (หัวข้อ C1 ของ​บท​ที่ 3) ไม่​ได้​ร้อย​ผ่าน PlaceOrderCommand เพราะ​การ​ยัด​ฟีลด์โปรโมชัน​เข้า command ตรงๆ คือ​กลิ่น “command ที่​ถูก​ขยาย​เงียบๆ” ที่​บท​นั้น​รื้อ​ทิ้ง​ไป​แล้ว

sender ใน​ทั้ง2 endpoint มี type เป็น ISender — interface ของ MediatR ที่​เปิด​แค่ method Send (ต่าง​จาก IMediator เต็ม​ตัว​ที่​มี Publish ของ domain event ด้วย) endpoint ต้องการ​แค่​ส่ง command/query เข้าไป​เท่านั้น จึง​ขอ ISender แคบๆ พอ ASP.NET Core minimal API ฉีด ISender เข้า​มา​ให้​อัตโนมัติ​จาก DI container โดย​ไม่​ต้อง​เขียน constructor ใดๆ เลย ทั้ง2 endpoint ใช้ PlaceOrderCommand, PlaceOrderItem, GetOrderQuery, OrderId, ProductId ตัว​เดิม​ทุก​ตัว​จาก​บท​ที่ 2–3 ไม่มี​สัก​ตัว​ที่​ถูก​สร้าง​ใหม่​ใน​ชั้น​นี้

flowchart LR
  A["HTTP POST /orders<br/>(JSON body)"] --> B["PlaceOrderRequest<br/>(DTO ดิบจาก JSON)"]
  B --> C["PlaceOrderCommand<br/>(ผ่าน ISender.Send)"]
  C --> D["PlaceOrderHandler<br/>(บทที่ 3 — ไม่แก้แม้แต่บรรทัดเดียว)"]
  D --> E["201 Created<br/>Location: /orders/{id}"]
  classDef inner fill:#16a34a,stroke:#065f46,color:#f8fafc;
  class C,D inner;

คำ​บรรยาย​ภาพ: endpoint ทำ​แค่​สอง​ก้าว​ที่​ขอบ​ซ้าย​สุด — แปลง JSON เป็น PlaceOrderRequest (ฟรี​จาก model binding ของ ASP.NET Core) แล้ว​แปลง PlaceOrderRequest เป็น PlaceOrderCommand เอง จาก​จุด​นั้น​ไป​คือ​ดิน​แดน​ของ Application ที่ endpoint ไม่รู้จัก​เลย​ด้วย​ซ้ำ​ว่า PlaceOrderHandler ทำงาน​ยังไง​ข้าง​ใน มัน​รู้​แค่​ว่า​ส่ง command เข้า ISender แล้ว​รอ​ผลลัพธ์​กลับ​มา​เป็น OrderId

จำ invariant ① ของ Order.Place(...) จาก​บท​ที่ 2 ได้​ไหม — ถ้า​ตะกร้า​ว่างเปล่า มัน​จะ throw new InvalidOperationException("ตะกร้าว่างเปล่า สร้างออเดอร์ไม่ได้") exception นั้น​เกิด​ขึ้น​ลึก​อยู่​ใน Domain แต่​ถ้า​ปล่อย​ให้​มัน​หลุด​ขึ้น​มา​ถึง endpoint ตรงๆ โดย​ไม่มี​ใคร​ดัก client จะ​ได้ HTTP 500 พร้อม stack trace ดิบๆ กลับ​ไป — ทั้ง​ไม่​สื่อ​ความหมาย​กับ​ผู้​เรียก​ใช้ API และ​รั่ว​รายละเอียด​ภายใน​ออก​ไป​โดย​ไม่​จำเป็น จุด​นี้​คือ​ที่​ที่ ProblemDetails เข้า​มา​ช่วย: มัน​คือ​รูปแบบ HTTP response มาตรฐาน (RFC 9457) ที่​บอก status/title/detail ของ​ข้อ​ผิดพลาด​แบบ​มี​โครงสร้าง ให้ client อ่าน​แล้ว​ตัดสิน​ใจ​ต่อ​ได้​จริง

ASP.NET Core 8 ขึ้น​ไป​ให้​เรา​ดัก exception ที่​ระดับ​กลาง​ของ app ด้วย IExceptionHandler แทนที่​จะ try/catch ซ้ำ​ใน​ทุก endpoint:

FoodOrdering.Web/DomainExceptionHandler.cs
public sealed class DomainExceptionHandler : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken ct)
{
if (exception is not InvalidOperationException domainError)
return false; // ไม่รู้จัก — ปล่อยให้ handler อื่นหรือ default 500 จัดการต่อ
httpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
await httpContext.Response.WriteAsJsonAsync(new ProblemDetails
{
Status = StatusCodes.Status400BadRequest,
Title = "คำสั่งซื้อไม่ผ่านเงื่อนไขของ domain",
Detail = domainError.Message, // ข้อความเดิมจาก Order.Place(...) ในบทที่ 2
}, cancellationToken: ct);
return true;
}
}

แล้ว​ลง​ทะเบียน​เพิ่ม​ที่ composition root เดียว​กับ​ที่​บท​ที่​แล้ว​ลง​ทะเบียน EfOrderRepository ไว้:

// FoodOrdering.Web/Program.cs — ส่วนเพิ่มเติมจาก composition root ที่ตั้งไว้ตั้งแต่บทที่ 4
builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblyContaining<PlaceOrderHandler>());
builder.Services.AddValidatorsFromAssemblyContaining<PlaceOrderCommandValidator>();
builder.Services.AddExceptionHandler<DomainExceptionHandler>();
builder.Services.AddProblemDetails();
// ...
app.UseExceptionHandler();

การ​ลง​ทะเบียน​นี้​ก็​เป็น dependency injectionDependency Injectionเทคนิค​ส่ง dependency (ที่ implement ตาม interface) เข้า​มา​จาก​ภายนอก​แทนที่​จะ​สร้าง​เอง ทำให้​วงใน​พึ่งพา abstraction ไม่ใช่​ของ​จริง และ​สลับ/ทดสอบ​ได้​ง่ายArchitecture แบบ​เดียว​กับ​ที่​บท​ที่​แล้ว​ใช้​ผูก IOrderRepository เข้า​กับ EfOrderRepository — จุด​เดียว​ที่​รู้จัก DomainExceptionHandler, PlaceOrderHandler, และ PlaceOrderCommandValidator พร้อม​กัน​ทั้งหมด​คือ Program.cs เท่านั้น ไม่มี​ชั้น​ไหน​อื่น​ต้อง​รู้จัก​กันเอง

สังเกต​ว่า​ตัวอย่าง​ข้าง​บน​ดัก InvalidOperationException แล้ว​ตอบ 400 Bad Request เพราะ​นี่​คือ​กรณี​ที่ request ไม่​ผ่าน​เงื่อนไข​ของ domain ตั้งแต่​แรก (ตะกร้า​ว่าง) ส่วน​กรณี​ที่​ควร​ตอบ 409 Conflict คือ​เมื่อ request ถูกต้อง​ใน​ตัว​มัน​เอง แต่​ไป​ขัด​กับสถานะ​ปัจจุบันของ aggregate เช่น พยายาม​แก้ไข​ออเดอร์​ที่​ถูก​ยืนยัน​ไป​แล้ว — ตาม​ที่​บท​ที่ 4 บอก​ไว้​ว่า​ยัง​ไม่มี use case ไหน​ใน​คอร์ส​นี้​ที่​โหลด​ออเดอร์​เก่า​มา​แก้ไข​เลย จึง​ยัง​ไม่มี exception ประเภท conflict จริงๆ ให้​ดัก​ตอน​นี้ หลักการ​แม็พ​เดียวกัน — ดัก exception เฉพาะ​ทาง แล้ว​เลือก status code ที่​ตรง​กับธรรมชาติของ​ข้อ​ผิดพลาด ไม่ใช่​ตอบ 500 รวด — จะ​กลับ​มา​ใช้​อีก​ครั้ง​ตอน​คอร์ส​นี้​แตะ​เคส​ยกเลิก/แก้ไข​ออเดอร์​ใน​ภายหลัง

เหตุ​ใด​จึง​ดัก InvalidOperationException ตรงๆ

ตัวอย่าง​ข้าง​บน​ดัก InvalidOperationException ซึ่ง​เป็น exception type กลาง​ของ .NET ไม่ใช่ exception เฉพาะ​ของ domain — ใช้ได้​เพราะ​ตอน​นี้​มัน​เป็น exception ประเภท​เดียว​ที่ Order.Place(...) โยน​ออก​มา แต่​ใน​ระบบ​จริง​ที่​มี invariant หลาย​แบบ แนวทาง​ที่​ดี​กว่า​คือ​สร้าง exception เฉพาะ​ทาง (เช่น EmptyOrderException) ตาม​ที่​อธิบาย​ไว้​ใน Descriptive Error Messages เพื่อ​ให้​ดัก​แยก​แต่ละ​กรณี​ได้​แม่นยำ ไม่​ปน​กับ InvalidOperationException ทั่วไป​ที่​อาจ​โยน​ออก​มา​จาก​ที่​อื่น​ใน code โดย​ไม่​ตั้งใจ

ทวน​สิ่ง​ที่ API EndpointAPI Endpointจุด​เข้า​ของ Web layer ที่​แปลง HTTP request เป็น command/query ส่ง​เข้า Application แล้ว​แปลง​ผลกลับ​เป็น HTTP response — เป็น adapter ไม่ใช่​ที่​อยู่​ของ​กฎ​ธุรกิจArchitecture ทั้ง​สอง​ตัว​ข้างบนทำจริงๆ: รับ HTTP เข้า​มา, แปลง​เป็น command/query, ส่ง​เข้า ISender, แล้ว​แปลง​ผลลัพธ์​กลับ​เป็น HTTP response — ไม่มี​บรรทัด​ไหน​คำนวณ​ราคา ไม่มี​บรรทัด​ไหน​เช็ก invariant เอง เพราะ​นั่น​ไม่ใช่​หน้าที่​ของ​มัน นี่​คือ​งาน “แปล​ภาษา” ล้วนๆ แบบ​เดียว​กับ​ที่ EfOrderRepository ทำ​ใน​บท​ที่​แล้ว (แปล Order เป็น​แถว​ใน​ตาราง) เพียง​แต่​ครั้ง​นี้​แปล HTTP request/response แทน SQL — ทั้ง​สอง​ชั้น​อยู่​วงนอก​ของ Clean Architecture ด้วย​เหตุผล​เดียวกัน: เทคโนโลยี​ที่​พวก​มัน​คุย​ด้วย (HTTP, EF Core) เปลี่ยน​ได้​บ่อย​กว่า​กฎ​ธุรกิจ​มาก การ​ไม่​ยัด​กฎ​ธุรกิจ​ลง​ไป​ใน​ชั้น​เหล่า​นี้​จึง​ทำให้​กฎ​ธุรกิจ​ไม่​ต้อง​เปลี่ยน​ตาม​เทคโนโลยี​ที่​เปลี่ยน

ลอง​จินตนาการ​ว่า​เรา​รีบ​และ​เผลอ​เติม​เช็ก​แบบ​นี้ลงไปตรงๆ ใน app.MapPost("/orders", ...):

❌ endpoint เริ่ม​มี​เหตุผล​ทาง​ธุรกิจ​เป็น​ของ​ตัวเอง
if (request.Items.Count == 0)
return Results.BadRequest("ตะกร้าว่างเปล่า"); // ใครเป็นเจ้าของกฎนี้กันแน่?

ดู​เผินๆ เหมือน​ไม่มี​พิษ​ภัย แต่​ตอน​นี้​กฎ “ห้าม​ตะกร้า​ว่าง” มี​เจ้าของ​สอง​คน​พร้อม​กัน — endpoint เช็ก​เอง แล้ว Order.Place(...) ก็​ยัง​เช็ก​ซ้ำ​อยู่ดี (invariant ① จาก​บท​ที่ 2) วัน​ไหน​กฎ​เปลี่ยน (เช่น “ห้าม​ตะกร้า​ว่าง เว้น​แต่​เป็น​ออเดอร์​แบบ​สมัคร​สมาชิก​ราย​เดือน”) ต้อง​จำ​แก้​สอง​ที่​พร้อม​กัน​เสมอ ไม่​งั้น​จะ​มี​จุด​ที่​กฎ​ไม่​ตรง​กัน — เป็น​อาการ​เดียว​กับ​ที่​โปรโมชัน​ใน​บท​ที่ 3 เคย​พัง​ตอน​ถูก​ยัด​เข้า handler ตรงๆ เพียง​แต่​คราว​นี้​เกิด​ที่ endpoint แทน endpointจึง​ตั้งใจ​ไม่​เช็กอะไร​แบบ​นี้​เลย — ปล่อย​ให้ FluentValidation (pipeline behavior ของ MediatR จาก​บท​ที่ 3) คัด​กรอง request ที่​ผิด​รูปแบบ​ไป​ก่อน​ถึง handler และ​ปล่อย​ให้ Order.Place(...) เป็น​ด่าน​สุดท้าย​ที่​รักษา invariant จริงๆ ของ​ธุรกิจ endpoint จึง​เบา​ที่สุด​เท่า​ที่​มัน​ควร​จะ​เป็น — เป็น use caseUse Caseกฎ​ธุรกิจ​เฉพาะ application (application business rule) ที่​ประสาน​การ​ไหล​ของ​ข้อมูล​เข้า-ออก Entities เพื่อ​ทำงาน​หนึ่ง​อย่าง​ให้​จบ เช่น PlaceOrder — ใน code คือ handler ของ command/queryArchitecture เดียว​ต่อ route เดียว ไม่ใช่​ที่​เก็บ​กฎ​อะไร​เลย

บท​นี้​เรา​สร้าง FoodOrdering.Web ให้​เป็น​วงนอก​สุด​ของ​สถาปัตยกรรม — ชั้น​ที่​รู้จัก ASP.NET Core แต่ Domain กับ Application ไม่มี​วัน​รู้จัก​มัน​กลับ: endpoint สอง​ตัว​ตาม pattern REPR (app.MapPost("/orders", ...) กับ app.MapGet("/orders/{id:guid}", ...)) ที่​แปลง PlaceOrderRequest/Guid ดิบ​จาก HTTP เป็น PlaceOrderCommand/GetOrderQuery แล้ว​ส่ง​ผ่าน ISender ของ MediatR, DomainExceptionHandler ที่​ดัก InvalidOperationException จาก Order.Place(...) แล้ว​แปลง​เป็น ProblemDetails 400 ที่ client อ่าน​แล้ว​เข้าใจ​ได้​จริง และ​สุดท้าย​คือ​เหตุผล​ที่ endpoint ต้อง​เบา​ที่สุด — มัน​เป็น adapter ที่​แปล​ภาษา​เท่านั้น ไม่ใช่​บ้าน​ของ​กฎ​ธุรกิจ ตอน​นี้​ทั้ง4 project (Domain, Application, Infrastructure, Web) ประกอบ​กัน​เป็น​ระบบ​ที่​รัน​ได้​จริง​ครบ​วง​แล้ว

บท​ถัด​ไป​เรา​จะ​หยุด​มอง Program.cs ทั้ง file เป็น​ครั้ง​แรก — composition root ที่ port ทุก​ตัว​มา​บรรจบ​กับ adapter จริง​ผ่าน dependency injection เป็น​ที่​เดียว​ใน​ระบบ​ที่ “รู้จัก​ทุก​อย่าง” และ​เป็น​กลไก​ที่​ทำให้ dependency ทุก​เส้น​ชี้​เข้า​ด้าน​ใน​ได้​จริง​อย่าง​ที่ Dependency Rule สัญญา​ไว้​ตั้งแต่​บท​แรก


🔗 อ้างอิง​เพิ่มเติม​ใน DevIQ

เจาะ​ลึก​แนวคิด​ใน​บท​นี้​ต่อ​ได้ที่​คลัง​อ้างอิง DevIQ:

  • REPR (Request-Endpoint-Response) — pattern ออกแบบ API endpoint ต่อ1 use case แทน controller อ้วน​ที่​รวม​หลาย action
  • Descriptive Error Messages — หลักการ​เขียน​ข้อความ error ที่​ช่วย​ทั้ง​ผู้​ใช้​และ​นัก​พัฒนา รวม​ถึง​แนวทาง​สร้าง domain-specific exception

เช็กความเข้าใจ — บทที่ 5

ข้อ 1 / 3

REPR (Request-Endpoint-Response) หมายถึงอะไร?