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

ชั้น Application — use case ด้วย CQRS/MediatR

บท​ที่​แล้ว​เรา​ปั้น FoodOrdering.Domain จนมี Order.Place(...) ที่​รักษา invariant ของ​ตัวเอง​ครบ​แล้ว แต่ Order.Place(...) เป็น​แค่ method — มัน​ไม่รู้จัก HTTP request ไม่รู้จัก MediatR และ​ไม่รู้​ด้วย​ซ้ำ​ว่า​ตัวเอง​ต้อง​ถูก​บันทึก​ลง​ไหน ต้อง​มี “ใคร​สัก​คน” มา​ยืน​อยู่​ตรง​กลาง คอย​รับคำ​สั่ง​จาก​โลก​ภายนอก เรียก Order.Place(...) ให้​ถูก​จังหวะ แล้ว​ส่ง​ผลลัพธ์​กลับ​ไป — นั่น​คือ​งาน​ของ FoodOrdering.Application วง​ที่​เรา​จะ​เข้าไป​ดู​ใน​บท​นี้

📦 code ตัวอย่าง

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

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 ที่​แค่​อ่าน​ข้อมูล​ออก​ไป​แสดง​ผล ไม่​แตะ​สถานะ​อะไร​เลย ความ​ต่าง​นี้​สำคัญ​พอที่​จะ​กลาย​เป็น​หัวข้อ​ถัด​ไป​ทั้งหมด

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);
FoodOrdering.Application/Orders/PlaceOrderHandler.cs
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 เท่านั้น

ก่อน command จะ​ไป​ถึง handler เลย​ด้วย​ซ้ำ เรา​อยาก​เช็ก “ความ​ถูกต้อง​เชิง​รูปแบบ” ก่อน — เช่น รายการ​ต้อง​ไม่​ว่าง จำนวน​ต้อง​มากกว่า 0 — เพื่อ​ตัด​ทิ้ง request ที่​ผิด​รูป​แบบชัดๆ ตั้งแต่​ต้นทาง โดย​ไม่​ต้อง​รอ​ให้ handler เรียก domain แล้ว​โดน exception กลับ​มา FluentValidation ทำ​หน้าที่​นี้​ได้​ดี​ใน​ฐานะ pipeline behavior ของ MediatR:

FoodOrdering.Application/Orders/PlaceOrderCommandValidator.cs
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 อื่น​ใน​อนาคต)

นี่​คือ​จุด​ที่​หลายๆ คน​สับสน​ตอน​เริ่ม​ทำ Clean Architecture ครั้ง​แรก: IMenuCatalog และ IPaymentGateway เป็น portPortinterface ที่​วงใน​ประกาศ​ไว้​เพื่อ​คุย​กับ​โลก​ภายนอก (เช่น IPaymentGateway) โดย​ไม่รู้จัก implementation จริงArchitecture ที่​คุย​กับ​โลก​ภายนอก (เมนู​ร้าน​ค้า, payment gateway จริง) แต่ interface ของ​มัน​ประกาศ​อยู่​ใน FoodOrdering.Application ไม่ใช่ FoodOrdering.Infrastructure:

FoodOrdering.Application/Orders/IMenuCatalog.cs
public interface IMenuCatalog
{
Task<Money> GetPriceAsync(ProductId productId, CancellationToken ct);
}
// FoodOrdering.Application/Orders/IPaymentGateway.cs
public interface IPaymentGateway
{
Task ChargeAsync(Money amount, CancellationToken ct);
}
// FoodOrdering.Application/Orders/IOrderRepository.cs
public 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 กำลัง​ถูก​ละเมิด​อยู่

ทุก​อย่าง​ข้าง​บน​ดู​เรียบร้อย​ดี จน​วัน​หนึ่ง​ฝ่าย​การ​ตลาด​ขอ​มา​ว่า “อยาก​มี​โปรโมชัน​ลด​ราคา” วิธี​ที่​เร็ว​ที่สุด — และ​เป็น​วิธี​ที่​ทีม​ส่วน​ใหญ่​เลือก​ใช้​ตอน​รีบ — คือ​แอบ​เติม​ฟีลด์เข้าไป​ใน PlaceOrderCommand ก่อน แล้ว​แปะ​เงื่อนไข​เพิ่ม​เข้าไป​ใน PlaceOrderHandler ตรงๆ

// PlaceOrderCommand ถูกขยายเงียบ ๆ ให้รับ flag เพิ่มทุกครั้งที่มีโปรโมชันใหม่เข้ามา
public sealed record PlaceOrderCommand(
IReadOnlyList<PlaceOrderItem> Items,
string? PromoCode,
string? CustomerTier) : IRequest<OrderId>;
❌ version ดิบ
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:

✅ version model (พรีวิว)
// 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 — Specification
public 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 interface
public 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

เจาะ​ลึก​แนวคิด​ใน​บท​นี้​ต่อ​ได้ที่​คลัง​อ้างอิง 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 ได้ถูกต้อง?