ชั้น Web — API & error handling
บทที่แล้วเราเติมของจริงให้ IOrderRepository ด้วย EfOrderRepository แล้วทุกชิ้นส่วนก็พร้อมหมด: Domain มี Order.Place(...) ที่รักษา invariant ของตัวเอง, Application มี PlaceOrderCommand/GetOrderQuery ที่ส่งผ่าน MediatR ไปถึง handler, Infrastructure มี EfOrderRepository ที่บันทึกลง PostgreSQL จริง เหลือจุดเดียวที่ยังไม่มีอยู่เลย — ทางที่ทำให้ HTTP request จากเบราว์เซอร์หรือ app มือถือเดินเข้ามาถึงของทั้งหมดนี้ได้ นั่นคือหน้าที่ของ FoodOrdering.Web ที่เราจะสร้างในบทนี้
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 อีกที
endpoint ต่อ1 use case แบบ REPR
หัวข้อที่มีชื่อว่า “endpoint ต่อ1 use case แบบ REPR”แนวคิดที่จัด 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 PlaceOrderapp.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
ProblemDetails — แปล domain exception เป็น HTTP ที่สื่อสารได้
หัวข้อที่มีชื่อว่า “ProblemDetails — แปล domain exception เป็น HTTP ที่สื่อสารได้”จำ 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:
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 ที่ตั้งไว้ตั้งแต่บทที่ 4builder.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 ซึ่งเป็น exception type กลางของ .NET ไม่ใช่ exception เฉพาะของ domain — ใช้ได้เพราะตอนนี้มันเป็น exception ประเภทเดียวที่ Order.Place(...) โยนออกมา แต่ในระบบจริงที่มี invariant หลายแบบ แนวทางที่ดีกว่าคือสร้าง exception เฉพาะทาง (เช่น EmptyOrderException) ตามที่อธิบายไว้ใน Descriptive Error Messages เพื่อให้ดักแยกแต่ละกรณีได้แม่นยำ ไม่ปนกับ InvalidOperationException ทั่วไปที่อาจโยนออกมาจากที่อื่นใน code โดยไม่ตั้งใจ
endpoint คือ adapter ไม่ใช่บ้านของกฎธุรกิจ
หัวข้อที่มีชื่อว่า “endpoint คือ adapter ไม่ใช่บ้านของกฎธุรกิจ”ทวนสิ่งที่ 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", ...):
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:
- REPR (Request-Endpoint-Response) — pattern ออกแบบ API endpoint ต่อ1 use case แทน controller อ้วนที่รวมหลาย action
- Descriptive Error Messages — หลักการเขียนข้อความ error ที่ช่วยทั้งผู้ใช้และนักพัฒนา รวมถึงแนวทางสร้าง domain-specific exception
เช็กความเข้าใจ — บทที่ 5
ข้อ 1 / 3REPR (Request-Endpoint-Response) หมายถึงอะไร?