ชั้น Application — use case ด้วย CQRS/MediatR
บทที่แล้วเราปั้น FoodOrdering.Domain จนมี Order.Place(...) ที่รักษา invariant ของตัวเองครบแล้ว แต่ Order.Place(...) เป็นแค่ method — มันไม่รู้จัก HTTP request ไม่รู้จัก MediatR และไม่รู้ด้วยซ้ำว่าตัวเองต้องถูกบันทึกลงไหน ต้องมี “ใครสักคน” มายืนอยู่ตรงกลาง คอยรับคำสั่งจากโลกภายนอก เรียก Order.Place(...) ให้ถูกจังหวะ แล้วส่งผลลัพธ์กลับไป — นั่นคืองานของ FoodOrdering.Application วงที่เราจะเข้าไปดูในบทนี้
code เต็มของบทนี้อยู่ที่ repo kaen-food-ordering (กำลังจัดทำ) — path: FoodOrdering.Application/Orders/
Use Case คืออะไร
หัวข้อที่มีชื่อว่า “Use Case คืออะไร”use caseUse Caseกฎธุรกิจเฉพาะ application (application business rule) ที่ประสานการไหลของข้อมูลเข้า-ออก Entities เพื่อทำงานหนึ่งอย่างให้จบ เช่น PlaceOrder — ใน code คือ handler ของ command/queryArchitecture คือกฎที่เจาะจงกับ “application นี้” (application business rule) ต่างจากกฎใน domain ที่จริงไม่ว่า app จะมีอยู่หรือไม่ (“ออเดอร์ห้ามว่างเปล่า” เป็นจริงในธุรกิจร้านอาหารเสมอ) use case คือ ขั้นตอนที่ประสานให้งานหนึ่งอย่างเสร็จจบ — ดึงข้อมูลที่ต้องใช้เข้ามา, เรียกให้ domain ตัดสินใจ, แล้วส่งผลไปบันทึกหรือแจ้งต่อ มันไม่ใช่ที่อยู่ของกฎธุรกิจ (นั่นเป็นหน้าที่ของ Order) แต่เป็น “script” ที่คอยเรียกใช้กฎเหล่านั้นให้ถูกลำดับ
domain ฟู้ดเดลิเวอรีของเรามี use case สองแบบที่ต่างกันโดยพื้นฐาน: PlaceOrder ที่เปลี่ยนสถานะของระบบ (สร้างออเดอร์ใหม่) และ GetOrder ที่แค่อ่านข้อมูลออกไปแสดงผล ไม่แตะสถานะอะไรเลย ความต่างนี้สำคัญพอที่จะกลายเป็นหัวข้อถัดไปทั้งหมด
CQRS + MediatR — แยกเส้นทางอ่าน/เขียน
หัวข้อที่มีชื่อว่า “CQRS + MediatR — แยกเส้นทางอ่าน/เขียน”CQRSCQRSCommand Query Responsibility Segregation — แยกฝั่ง 'สั่งให้เปลี่ยนสถานะ' (command) ออกจากฝั่ง 'อ่านข้อมูล' (query) เป็นคนละเส้นทาง มักใช้คู่กับ MediatR ใน .NETArchitecture (Command Query Responsibility Segregation) บอกว่า operation หนึ่งๆ ควรเป็น “คำสั่งที่เปลี่ยนสถานะ” (command) หรือ “การอ่านที่ไม่เปลี่ยนอะไรเลย” (query) อย่างใดอย่างหนึ่งเท่านั้น ไม่ปนกัน PlaceOrder จึงเป็น command — สั่งให้ระบบสร้างออเดอร์ใหม่ ส่วน GetOrder เป็น query — ขอดูข้อมูลออเดอร์ที่มีอยู่แล้ว code สองเส้นทางนี้ไม่จำเป็นต้องผ่าน handler ร่วมกันเลยด้วยซ้ำ
ใน .NET เครื่องมือยอดนิยมที่ทำให้แยกสองเส้นทางนี้เป็นรูปธรรมคือ MediatRMediatRlibrary .NET ยอดนิยมสำหรับส่ง command/query/notification ไปยัง handler แบบ in-process ช่วยแยก Application layer ออกจาก WebArchitecture — library ที่ให้เราประกาศ command/query เป็น object เดี่ยวๆ แล้วมี handler เดี่ยวๆ รับผิดชอบแต่ละอัน โดย endpoint ไม่ต้องรู้จัก handler ตรงๆ เลย รู้แค่ว่า “ส่ง request นี้เข้า mediator ไป”:
// FoodOrdering.Application/Orders/PlaceOrderCommand.cs — command: เปลี่ยนสถานะpublic sealed record PlaceOrderCommand(IReadOnlyList<PlaceOrderItem> Items) : IRequest<OrderId>;
public sealed record PlaceOrderItem(ProductId ProductId, int Quantity);// FoodOrdering.Application/Orders/GetOrderQuery.cs — query: อ่านอย่างเดียวpublic sealed record GetOrderQuery(OrderId OrderId) : IRequest<OrderDto>;
public sealed record OrderDto(OrderId Id, decimal Total, string Currency, int LineCount);สังเกตว่า PlaceOrderCommand รับแค่ “วัตถุดิบดิบๆ” (ProductId กับ int Quantity) ไม่ใช่ OrderLine ที่ปั้นเสร็จแล้ว เพราะฝั่งที่ต้องหาราคาต่อชิ้นและประกอบเป็น OrderLine ที่แท้จริงคือ handler — ไม่ใช่ตัว command เอง ส่วน GetOrderQuery คืน OrderDto ที่แบนและเบา เอาไว้แสดงผลอย่างเดียว ไม่ใช่ Order ตัวจริงที่มีพฤติกรรมและ invariant ติดมาด้วย (การคืน aggregate ตัวจริงออกไปให้ชั้นนอกจับต้องคือช่องให้มีใครมาแก้ state โดยไม่ผ่าน Order.Place(...) ได้ ซึ่งเป็นสิ่งที่เราตั้งใจปิดกั้นมาตั้งแต่บทที่แล้ว)
PlaceOrderHandler คือที่ที่ทุกอย่างมาบรรจบกัน — รับ command เข้ามา ประสานงานกับทั้ง domain และ port ต่างๆ จนจบ use case ก่อนอื่นขอทวนสั้นๆ ว่า OrderLine กับกลุ่ม ID ที่เป็น value object หน้าตาเป็นยังไง เพราะ handler จะประกอบมันขึ้นมาตรงๆ:
// ทบทวนจากชั้น Domain (บทที่ 2) — building blocks ที่ Application หยิบมาใช้public sealed record OrderId(Guid Value);public sealed record OrderLineId(Guid Value);public sealed record ProductId(Guid Value);public sealed record OrderLine(OrderLineId Id, ProductId ProductId, Quantity Quantity, Money UnitPrice);public sealed class PlaceOrderHandler : IRequestHandler<PlaceOrderCommand, OrderId>{ private readonly IMenuCatalog _menu; private readonly IOrderRepository _orders; private readonly IPaymentGateway _payments;
public PlaceOrderHandler(IMenuCatalog menu, IOrderRepository orders, IPaymentGateway payments) { _menu = menu; _orders = orders; _payments = payments; }
public async Task<OrderId> Handle(PlaceOrderCommand request, CancellationToken ct) { var orderId = new OrderId(Guid.NewGuid());
var lines = new List<OrderLine>(); foreach (var item in request.Items) { var unitPrice = await _menu.GetPriceAsync(item.ProductId, ct); lines.Add(new OrderLine(new OrderLineId(Guid.NewGuid()), item.ProductId, new Quantity(item.Quantity), unitPrice)); }
var order = Order.Place(orderId, lines); // invariant รักษาในตัว Order เอง
await _payments.ChargeAsync(order.Total, ct); await _orders.SaveAsync(order, ct);
return order.Id; }}สิ่งที่ handler ทำมีแค่สี่ก้าว: หาราคาผ่าน IMenuCatalog, เรียก Order.Place(...) ให้ domain ตัดสินใจว่าออเดอร์นี้ถูกต้องไหม, สั่งตัดเงินผ่าน IPaymentGateway, แล้วบันทึกผ่าน IOrderRepository — มันไม่มีบรรทัดไหนคำนวณราคาเอง ไม่มีบรรทัดไหนเช็ก invariant เอง เพราะงานเหล่านั้นมีเจ้าของอยู่แล้ว (Order และเหล่า port) หน้าที่ของ handler คือ “ประสาน” ล้วนๆ
sequenceDiagram
participant EP as Endpoint
participant MED as MediatR
participant H as PlaceOrderHandler
participant MENU as IMenuCatalog
participant DOM as Order
participant PAY as IPaymentGateway
participant REPO as IOrderRepository
EP->>MED: Send(PlaceOrderCommand)
MED->>H: Handle(command)
H->>MENU: GetPriceAsync(productId)
MENU-->>H: ราคาต่อรายการ
H->>DOM: Order.Place(orderId, lines)
DOM-->>H: order (invariant ผ่านแล้ว)
H->>PAY: ChargeAsync(order.Total)
PAY-->>H: ผลชำระเงิน
H->>REPO: SaveAsync(order)
H-->>MED: OrderId
MED-->>EP: OrderId
คำบรรยายภาพ: Endpoint ไม่รู้จัก PlaceOrderHandler ตรงๆ เลย มันรู้แค่ว่ามี MediatR ให้ส่ง command เข้าไป ส่วน handler เองก็ไม่รู้จัก EF Core, HTTP client หรือ ASP.NET เลยสักบรรทัด — มันคุยกับโลกภายนอกผ่าน IMenuCatalog, IPaymentGateway, IOrderRepository ซึ่งเป็นแค่ interface เท่านั้น
validation ด้วย FluentValidation
หัวข้อที่มีชื่อว่า “validation ด้วย FluentValidation”ก่อน command จะไปถึง handler เลยด้วยซ้ำ เราอยากเช็ก “ความถูกต้องเชิงรูปแบบ” ก่อน — เช่น รายการต้องไม่ว่าง จำนวนต้องมากกว่า 0 — เพื่อตัดทิ้ง request ที่ผิดรูปแบบชัดๆ ตั้งแต่ต้นทาง โดยไม่ต้องรอให้ handler เรียก domain แล้วโดน exception กลับมา FluentValidation ทำหน้าที่นี้ได้ดีในฐานะ pipeline behavior ของ MediatR:
public sealed class PlaceOrderCommandValidator : AbstractValidator<PlaceOrderCommand>{ public PlaceOrderCommandValidator() { RuleFor(c => c.Items).NotEmpty().WithMessage("ต้องสั่งอย่างน้อยหนึ่งรายการ"); RuleForEach(c => c.Items).ChildRules(item => item.RuleFor(i => i.Quantity).GreaterThan(0).WithMessage("จำนวนต้องมากกว่า 0")); }}สังเกตว่ากฎในนี้เป็นแค่ “รูปแบบข้อมูลดิบ” (ว่าง/ติดลบ) ไม่ใช่กฎธุรกิจ — กฎธุรกิจแท้ๆ อย่าง “Quantity ต้องไม่ต่ำกว่า 1” ยังถูกรักษาไว้ซ้ำอีกชั้นอยู่ใน Quantity เองตามที่เห็นในบทที่แล้ว นี่ไม่ใช่ความซ้ำซ้อนที่สิ้นเปลือง แต่เป็น defense in depth: FluentValidation ปฏิเสธ request แปลกๆ ได้เร็วและถูกจุดที่ขอบระบบ ส่วน guard clause ใน domain ยังคงเป็นด่านสุดท้ายที่ไว้ใจได้เสมอ ต่อให้มีทางอื่นเรียก Order.Place(...) โดยไม่ผ่าน validator นี้เลยก็ตาม (เช่นจาก test หรือ use case อื่นในอนาคต)
ports ประกาศที่นี่ ไม่ใช่ที่ Infrastructure
หัวข้อที่มีชื่อว่า “ports ประกาศที่นี่ ไม่ใช่ที่ Infrastructure”นี่คือจุดที่หลายๆ คนสับสนตอนเริ่มทำ Clean Architecture ครั้งแรก: IMenuCatalog และ IPaymentGateway เป็น portPortinterface ที่วงในประกาศไว้เพื่อคุยกับโลกภายนอก (เช่น IPaymentGateway) โดยไม่รู้จัก implementation จริงArchitecture ที่คุยกับโลกภายนอก (เมนูร้านค้า, payment gateway จริง) แต่ interface ของมันประกาศอยู่ใน FoodOrdering.Application ไม่ใช่ FoodOrdering.Infrastructure:
public interface IMenuCatalog{ Task<Money> GetPriceAsync(ProductId productId, CancellationToken ct);}
// FoodOrdering.Application/Orders/IPaymentGateway.cspublic interface IPaymentGateway{ Task ChargeAsync(Money amount, CancellationToken ct);}
// FoodOrdering.Application/Orders/IOrderRepository.cspublic interface IOrderRepository{ Task SaveAsync(Order order, CancellationToken ct); Task<Order?> FindAsync(OrderId id, CancellationToken ct);}นี่คือ dependency injectionDependency Injectionเทคนิคส่ง dependency (ที่ implement ตาม interface) เข้ามาจากภายนอกแทนที่จะสร้างเอง ทำให้วงในพึ่งพา abstraction ไม่ใช่ของจริง และสลับ/ทดสอบได้ง่ายArchitecture ที่ทำงานตามหลัก DIP ที่วางไว้ตั้งแต่บทที่ 1 พอดี — PlaceOrderHandler (วงใน) ประกาศว่า “ฉันต้องการของแบบนี้” ผ่าน interface โดยไม่สนใจว่าใครจะมาเติมของจริงให้ ส่วน class ที่ implement IMenuCatalog ด้วย HTTP call จริง หรือ implement IPaymentGateway ด้วย SDK ของผู้ให้บริการจริง จะไปอยู่ใน FoodOrdering.Infrastructure (บทถัดไปในซีรีส์นี้) — ProjectReference ของ Infrastructure ชี้เข้าหา Application เพื่อ implement interface พวกนี้ ไม่ใช่กลับกัน ถ้าวันไหน IPaymentGateway ถูกประกาศไว้ใน Infrastructure แทน นั่นคือสัญญาณว่า Dependency Rule กำลังถูกละเมิดอยู่
C1: เมื่อโปรโมชันโผล่เข้ามา
หัวข้อที่มีชื่อว่า “C1: เมื่อโปรโมชันโผล่เข้ามา”ทุกอย่างข้างบนดูเรียบร้อยดี จนวันหนึ่งฝ่ายการตลาดขอมาว่า “อยากมีโปรโมชันลดราคา” วิธีที่เร็วที่สุด — และเป็นวิธีที่ทีมส่วนใหญ่เลือกใช้ตอนรีบ — คือแอบเติมฟีลด์เข้าไปใน PlaceOrderCommand ก่อน แล้วแปะเงื่อนไขเพิ่มเข้าไปใน PlaceOrderHandler ตรงๆ
// PlaceOrderCommand ถูกขยายเงียบ ๆ ให้รับ flag เพิ่มทุกครั้งที่มีโปรโมชันใหม่เข้ามาpublic sealed record PlaceOrderCommand( IReadOnlyList<PlaceOrderItem> Items, string? PromoCode, string? CustomerTier) : IRequest<OrderId>;public async Task<OrderId> Handle(PlaceOrderCommand request, CancellationToken ct){ // ...หาราคา ประกอบ lines เหมือนเดิม... var order = Order.Place(orderId, lines);
// เริ่มยัดกฎโปรโมชันตรงนี้ — โปรโมชันแรกดูไม่มีพิษภัย decimal discount = 0; if (request.PromoCode == "NEWYEAR" && order.Total.Amount >= 500) { discount = order.Total.Amount * 0.10m; } else if (request.PromoCode == "STUDENT") { // แล้วมีคนขอ "ลดพิเศษช่วงเที่ยง" ต่อ... if (DateTime.Now.Hour is >= 11 and <= 14) discount = 20m; else if (order.Lines.Count >= 3) discount = order.Total.Amount * 0.05m; } else if (request.CustomerTier == "GOLD" && order.Total.Amount >= 1000) { discount = 50m; } // ...แล้วก็มีโปรโมชันที่ 4, ที่ 5 ตามมาเรื่อย ๆ
var payable = new Money(order.Total.Amount - discount, order.Total.Currency); await _payments.ChargeAsync(payable, ct); await _orders.SaveAsync(order, ct); return order.Id;}สังเกตสัญญาณเตือน: PlaceOrderCommand ต้องแบกฟีลด์ PromoCode/CustomerTier ที่เป็น string ดิบๆ เพิ่มขึ้นต่อเนื่อง, if/else ซ้อนลึกขึ้นทุกครั้งที่มีโปรโมชันใหม่, และ handler ที่เดิมมีหน้าที่แค่ “ประสาน” ตอนนี้ต้องรู้จักกฎการตลาดไปด้วย — ทุกครั้งที่ทีมการตลาดคิดโปรโมชันใหม่ นักพัฒนาต้องกลับมาแก้ method เดิมที่ทำงานอยู่แล้ว เสี่ยงพังโปรโมชันเก่าที่เคยถูกต้องอยู่แล้วโดยไม่ตั้งใจ — นี่คือกลิ่น Conditional Complexity ตัวเป็นๆ
ทางแก้คือดึงกฎโปรโมชันทั้งหมดออกจาก handler ไปเป็นของตัวเอง แทนที่จะเทียบ string ดิบ เราสร้าง specificationSpecificationpattern ห่อ 'กฎการคัดเลือก/เงื่อนไข' เป็น object นำมาประกอบและนำกลับมาใช้ซ้ำได้ เช่น EligiblePromotionSpec, CancellableOrderSpecTactical Design ชื่อ EligiblePromotionSpec ไว้ตอบคำถามเดียว “โปรโมชันนี้ใช้กับออเดอร์นี้ได้ไหม” และห่อแต่ละโปรโมชันเป็น class ของตัวเองที่ implement interface ร่วมกัน — นี่คือ pattern Strategy:
// FoodOrdering.Domain/Promotions/Discount.cs — value object เล็ก ๆpublic sealed record Discount(decimal Amount){ public Discount { if (Amount < 0) throw new ArgumentException("ส่วนลดต้องไม่ติดลบ", nameof(Amount)); }}
// FoodOrdering.Domain/Promotions/EligiblePromotionSpec.cs — Specificationpublic sealed class EligiblePromotionSpec{ private readonly Func<Order, bool> _rule; public EligiblePromotionSpec(Func<Order, bool> rule) => _rule = rule; public bool IsSatisfiedBy(Order order) => _rule(order);}
// FoodOrdering.Domain/Promotions/IPromotion.cs — Strategy interfacepublic interface IPromotion{ bool IsEligible(Order order); Discount CalculateDiscount(Order order);}
// FoodOrdering.Domain/Promotions/NewYearPromotion.cs — concrete strategy หนึ่งตัวpublic sealed class NewYearPromotion : IPromotion{ private readonly EligiblePromotionSpec _spec = new(order => order.Total.Amount >= 500);
public bool IsEligible(Order order) => _spec.IsSatisfiedBy(order); public Discount CalculateDiscount(Order order) => new(order.Total.Amount * 0.10m);}// FoodOrdering.Application/Promotions/PromotionEngine.cs — เลือกโปรโมชันที่คุ้มที่สุดpublic sealed class PromotionEngine{ private readonly IReadOnlyList<IPromotion> _promotions; public PromotionEngine(IEnumerable<IPromotion> promotions) => _promotions = promotions.ToList();
public Discount BestDiscountFor(Order order) { var eligible = _promotions.Where(p => p.IsEligible(order)).ToList(); return eligible.Count == 0 ? new Discount(0) : eligible.Select(p => p.CalculateDiscount(order)).MaxBy(d => d.Amount)!; }}PlaceOrderHandler แค่รับ PromotionEngine เข้ามาเป็น dependency ตัวที่สี่ในคอนสตรักเตอร์ (เหมือน IMenuCatalog, IOrderRepository, IPaymentGateway ที่มีอยู่แล้ว) ส่วน Handle(...) ก็เปลี่ยนแค่ตรงคำนวณส่วนลด ไม่ต้องแตะ code ส่วนอื่นเลย:
// PlaceOrderHandler.Handle(...) — ส่วนที่เปลี่ยน (คอนสตรักเตอร์เพิ่ม PromotionEngine promotions เข้ามา เก็บไว้ที่ _promotions)var discount = _promotions.BestDiscountFor(order);var payable = new Money(order.Total.Amount - discount.Amount, order.Total.Currency);await _payments.ChargeAsync(payable, ct);ผลลัพธ์คือ PlaceOrderHandler กลับมาไม่รู้จักกฎโปรโมชันสักข้อเดียวอีกครั้ง — มันแค่ถาม PromotionEngine ว่า “ส่วนลดที่ดีที่สุดสำหรับออเดอร์นี้คือเท่าไร” พอมีโปรโมชันใหม่เข้ามา เราแค่เพิ่มclass IPromotion ใหม่หนึ่งตัวและลงทะเบียนกับ DI container โดยไม่ต้องแตะ PlaceOrderHandler หรือโปรโมชันเก่าที่ทำงานถูกอยู่แล้วเลยแม้แต่บรรทัดเดียว — นี่คือ Open-Closed Principle: เปิดให้ขยาย ปิดไม่ให้แก้ไข code เดิม
code ข้างบนเป็นแค่ พรีวิว ของการใช้ Strategy + Specification จัดการกฎซับซ้อน ยังมีคำถามอีกมากที่บทนี้ยังไม่ตอบ เช่น โปรโมชันซ้อนกันได้ไหม, ลำดับความสำคัญเป็นยังไง, หรือควร model Promotion เป็น aggregate ของตัวเองหรือเปล่า — เรื่องพวกนี้ลึกเกินขอบเขตของคอร์สที่เน้นโครงสร้าง Clean Architecture คอร์สนี้ ถ้าอยากลงลึกเรื่องการ model กฎธุรกิจซับซ้อนแบบนี้จริงจัง คอร์สถัดไปในซีรีส์ (ที่ว่าด้วยเคสซับซ้อน C1–C3) จะกลับมาแกะโปรโมชันตัวนี้อีกครั้งแบบเต็มๆ
สรุป + สิ่งที่จะสร้างต่อ
หัวข้อที่มีชื่อว่า “สรุป + สิ่งที่จะสร้างต่อ”บทนี้เราสร้าง FoodOrdering.Application ให้มีเนื้อจริง: use case สองแบบ PlaceOrder (command) กับ GetOrder (query) แยกเส้นทางกันตามหลัก CQRS แล้วส่งผ่าน MediatR, PlaceOrderHandler ที่ทำหน้าที่ประสานล้วนๆ ไม่คำนวณหรือเช็กกฎเอง, FluentValidation ที่คัดกรอง request ผิดรูปแบบตั้งแต่ขอบระบบเป็นด่านแรก ก่อนที่ domain จะเป็นด่านสุดท้าย, ports สามตัว (IMenuCatalog, IPaymentGateway, IOrderRepository) ที่ประกาศไว้ที่นี่ตามหลัก DIP รอให้ Infrastructure มา implement และสุดท้ายคือบทเรียนจากโปรโมชัน — เห็น code เน่าจาก if/else ที่ยัดเข้า handler ตรงๆ แล้วดึงออกมาเป็น PromotionEngine (Strategy) กับ EligiblePromotionSpec (Specification) แทน
บทถัดไปเราจะขยับออกมาอีกวง สร้าง FoodOrdering.Infrastructure ที่ implement IMenuCatalog, IPaymentGateway และ IOrderRepository ด้วยเทคโนโลยีจริง — EF Core สำหรับ repository และ HTTP client สำหรับ payment gateway — พร้อมพิสูจน์ว่า Application layer ที่เราเพิ่งสร้างไม่ต้องแก้แม้แต่บรรทัดเดียวตอนสลับของจริงเข้ามา
เจาะลึกแนวคิดในบทนี้ต่อได้ที่คลังอ้างอิง DevIQ:
- CQRS — แยก model อ่านออกจาก model เขียนโดยเด็ดขาด รากของแนวคิดที่บทนี้ใช้แบ่ง command/query
- Strategy — pattern ที่
PromotionEngineใช้สลับกฎโปรโมชันโดยไม่ต้องแก้ handler เดิม - CQRS: สถาปัตยกรรมภายใน Bounded Context — คอร์ส DDD Patterns ที่ลงลึกเรื่อง CQRS ระดับ bounded context เต็มๆ
- Commands: Application Service สำหรับประมวลผล Business Use Case — บทที่ว่าด้วย application service ที่ลึกกว่านี้ รวมถึง pattern การประมวลผล command แบบอื่นๆ
เช็กความเข้าใจ — บทที่ 3
ข้อ 1 / 3ข้อใดอธิบายความต่างระหว่าง command กับ query ใน CQRS ได้ถูกต้อง?