Domain Service & Specification
ใน Course A บทที่ 3 เราเจอปัญหาโปรโมชันครั้งแรก: เริ่มจาก if (request.PromoCode == "NEWYEAR") ... else if ... ยัดอยู่ใน PlaceOrderHandler ตรงๆ แล้ว refactor ออกมาเป็น PromotionEngine (Strategy ผ่าน IPromotion) กับ EligiblePromotionSpec — แต่บทนั้นปิดท้ายด้วย Callout บอกตรงๆ ว่านี่เป็นแค่ “พรีวิว” และทิ้งคำถามค้างไว้สามข้อ: โปรโมชันซ้อนกันได้ไหม, ลำดับความสำคัญเป็นยังไง, และควร model Promotion เป็น aggregate ของตัวเองหรือเปล่า บทนี้กลับมาตอบสองข้อแรกให้เต็มๆ พร้อมแนะนำเครื่องมือใหม่ที่ทำให้ตอบคำถามพวกนี้ได้อย่างมีระบบ: Domain ServiceDomain Serviceบริการที่ถือ 'ตรรกะธุรกิจ' ซึ่งไม่เข้ากับ Entity หรือ Value Object ตัวใดตัวหนึ่งโดยธรรมชาติ ไร้สถานะ (stateless) เช่น PromotionEngine, DeliveryFeeCalculatorTactical Design และ SpecificationSpecificationpattern ห่อ 'กฎการคัดเลือก/เงื่อนไข' เป็นอ็อบเจ็กต์ที่มี IsSatisfiedBy(...) นำมาประกอบ (and/or/not) และใช้ซ้ำได้ เช่น EligiblePromotionSpec, CancellableOrderSpecTactical Design
code เต็มของบทนี้อยู่ที่ repo kaen-food-ordering (กำลังจัดทำ) — path: FoodOrdering.Domain/Promotions/, FoodOrdering.Domain/Delivery/, FoodOrdering.Domain/Specifications/
กฎธุรกิจข้อนี้ควรอยู่ตรงไหน
หัวข้อที่มีชื่อว่า “กฎธุรกิจข้อนี้ควรอยู่ตรงไหน”ก่อนเขียนกฎใหม่สักข้อ คำถามแรกที่ควรถามคือ “ใครควรเป็นเจ้าของมัน” — ไม่ใช่ทุกกฎจะไปลงที่ entity หรือ value object เสมอ บทนี้วางกรอบตัดสินใจสามทางที่ใช้ได้กับทุกกฎใน domain นี้:
| ลักษณะของกฎ | ควรอยู่ที่ | ตัวอย่างใน domain นี้ |
|---|---|---|
| เป็นของ object เดียว ใช้แค่ข้อมูลของตัวเอง | Entity / Value Object | Order.Place(...) เช็กตะกร้าว่างเปล่า, Money.Round() |
| คร่อมหลาย object พร้อมกัน และไร้สถานะของตัวเอง (รับ input คำนวณ output) | Domain Service | PromotionEngine (ดูหลายโปรโมชันพร้อมกัน), DeliveryFeeCalculator |
| เป็น “เงื่อนไขคัดเลือก/ตรวจสอบ” ที่อยากเรียกซ้ำได้หลายที่ (guard, filter, query) | Specification | EligiblePromotionSpec, CancellableOrderSpec |
จุดที่มักสับสนคือแถวที่สองกับสาม — ทั้งคู่ “คร่อม” มากกว่า1 object เหมือนกัน แต่ต่างกันที่ผลลัพธ์: domain service คำนวณค่า (เช่น ส่วนลดเป็นบาท, ค่าส่งเป็นบาท) ส่วน specification ตอบแค่ ใช่/ไม่ใช่ (ใช้โปรโมชันนี้ได้ไหม, ยกเลิกออเดอร์นี้ได้ไหม) แล้วมักถูก domain service หรือ aggregate เรียกใช้ต่ออีกที บทนี้จะไล่ดูทั้งสองแบบทีละตัว โดยเริ่มจาก domain service ก่อน
Domain Service เต็มรูป: แกะ PromotionEngine ที่ Course A แอบโชว์ไว้
หัวข้อที่มีชื่อว่า “Domain Service เต็มรูป: แกะ PromotionEngine ที่ Course A แอบโชว์ไว้”ก่อนไปต่อ ต้องทวนของเดิมให้ครบก่อนว่า Order กับ Money (ที่โปรโมชันต้องใช้) หน้าตาเป็นยังไง:
// FoodOrdering.Domain/Orders/Order.cs (ทบทวนจากบทที่ 1 — Money.Thb, operator +, operator * ตามที่ทบทวนไว้เต็ม ๆ ในบทที่ 2 และ _domainEvents รัดชนิดเป็น IDomainEvent ตั้งแต่บทที่ 6)public sealed class Order{ private readonly List<OrderLine> _lines = new(); public OrderId Id { get; } public IReadOnlyCollection<OrderLine> Lines => _lines.AsReadOnly(); public Money Total { get; private set; } private readonly List<IDomainEvent> _domainEvents = new(); public IReadOnlyCollection<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();
private Order(OrderId id, IEnumerable<OrderLine> lines) { Id = id; _lines.AddRange(lines); Total = _lines.Aggregate(Money.Thb(0), (sum, line) => sum + line.UnitPrice * line.Quantity.Value); }
public static Order Place(OrderId id, IReadOnlyList<OrderLine> items) { if (items.Count == 0) throw new InvalidOperationException("ตะกร้าว่างเปล่า สร้างออเดอร์ไม่ได้");
var order = new Order(id, items); order._domainEvents.Add(new OrderPlaced(order.Id, order.Total, DateTimeOffset.UtcNow)); return order; }}และนี่คือชุดโปรโมชันที่ Course A บทที่ 3 สร้างไว้เป็นพรีวิว — ทวนแบบเต็มทุกตัวอักษร เพราะบทนี้จะขยายมันต่อ:
public sealed record Discount(decimal Amount){ public Discount { if (Amount < 0) throw new ArgumentException("ส่วนลดต้องไม่ติดลบ", nameof(Amount)); }}
// FoodOrdering.Domain/Promotions/EligiblePromotionSpec.cs — Specification (พรีวิวจาก Course A บทที่ 3)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 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)!; }}PromotionEngine คือ Domain Service ตัวแรกที่เราเห็นในคอร์สนี้ — สังเกตลักษณะสองข้อที่ทำให้มันเป็น domain service ไม่ใช่ entity หรือ value object: (1) มันไม่มี identity และไม่มี “ค่า” ของตัวเอง มันแค่รับ Order เข้ามาแล้วคืนเลขออกไป และ (2) มันไร้สถานะ (stateless) — _promotions เป็นแค่ collaborator ที่ฉีดเข้ามาตอนสร้าง ไม่ใช่ state ที่เปลี่ยนไปตามการเรียกใช้แต่ละครั้ง เรียก BestDiscountFor(order) กี่รอบด้วย order เดิม ก็ได้ผลลัพธ์เดิมเสมอ (อ่านเพิ่มเรื่องลักษณะสองข้อนี้ได้ที่ Domain Services ในคอร์ส DDD Patterns — Vaughn Vernon ใน Implementing Domain-Driven Design เน้นย้ำเรื่องนี้เช่นกัน ว่า domain service ที่ดีควรตั้งชื่อตามคำกริยาในภาษาธุรกิจ ไม่ใช่ “Manager” หรือ “Helper” ที่ไม่สื่อความหมายอะไรเลย)
โปรโมชันซ้อนกันได้ กับเพดานส่วนลดสูงสุด
หัวข้อที่มีชื่อว่า “โปรโมชันซ้อนกันได้ กับเพดานส่วนลดสูงสุด”BestDiscountFor(...) ข้างบนถูกออกแบบมาให้เลือก ตัวที่ดีที่สุดตัวเดียว (MaxBy) — ตอบคำถามของ Course A ได้ดีตอนนั้น แต่ไม่ตอบคำถามใหม่ที่ฝ่ายการตลาดเพิ่งขอมา: “ให้โปรโมชันปีใหม่กับโปรโมชัน ‘ตะกร้าใหญ่’ ใช้พร้อมกันได้ แต่ส่วนลดรวมห้ามเกิน 500 บาทต่อออเดอร์” นักพัฒนาที่รีบและลืมบทเรียนจาก Course A บทที่ 3 มักแก้ปัญหานี้แบบเดิมซ้ำอีกครั้ง — เขียนสดๆ ตรงใน handler:
// ❌ version ดิบ — PlaceOrderHandler.Handle(...) (_payments, _orders คือ field เดิมจาก Course A บทที่ 3)public async Task<OrderId> Handle(PlaceOrderCommand request, CancellationToken ct){ // ...หาราคา ประกอบ lines เรียก Order.Place(...) เหมือนเดิม...
var discount = 0m; if (order.Total.Amount >= 500) discount += order.Total.Amount * 0.10m; // ก็อปกฎของ NewYearPromotion มาแปะสด ๆ if (order.Lines.Count >= 5) discount += 50m; // โปรโมชัน "ตะกร้าใหญ่" ที่การตลาดเพิ่งขอ
var payable = new Money(order.Total.Amount - discount, order.Total.Currency); await _payments.ChargeAsync(payable, ct); await _orders.SaveAsync(order, ct); return order.Id;}ลองสั่งออเดอร์ใหญ่ที่เข้าเงื่อนไขทั้งสองโปรโมชัน — order.Total.Amount = 6000, มี 5 รายการขึ้นไป:
// discount = 6000 * 0.10 (600) + 50 = 650 บาท// เพดานที่การตลาดขอคือ 500 บาท — code ข้างบนไม่มีบรรทัดไหนเช็กเรื่องนี้เลย// payable = 6000 - 650 = 5350 — ไม่ throw ไม่ crash แค่ผิดกฎธุรกิจเงียบ ๆbug นี้ไม่ใช่แค่ “ลืมเช็กเพดาน” อย่างเดียว — กฎ 10% ของ NewYearPromotion ถูกก็อปมาพิมพ์ซ้ำในสองที่แล้ว (ใน NewYearPromotion.CalculateDiscount ตัวจริง กับใน code ข้างบน) พอมีคนแก้เปอร์เซ็นต์ที่หนึ่งแต่ลืมอีกที่ ระบบก็จะคิดราคาไม่ตรงกันโดยไม่มีใครรู้ตัว — อาการเดียวกับที่ Course A บทที่ 3 เคยเตือนไว้เรื่อง Conditional Complexity แค่กลับมาในรูปแบบใหม่
ทางแก้ไม่ใช่การเขียนเพดานเช็กเพิ่มในบรรทัดสุดท้ายของ handler แต่คือการถามคำถามจากส่วนที่ 1: กฎ “ส่วนลดรวมต้องไม่เกินเพดาน” คร่อมโปรโมชันหลายตัวพร้อมกัน และไร้สถานะของตัวเอง (แค่รับออเดอร์คำนวณเลข) — มันจึงเป็นหน้าที่ของ PromotionEngine (domain service ที่เรามีอยู่แล้ว) ไม่ใช่ของ handler หรือของ IPromotion ตัวใดตัวหนึ่ง เพราะไม่มี IPromotion ตัวไหนควรรู้จักโปรโมชันตัวอื่นเลย — นั่นจะทำลาย Strategy pattern ที่ตั้งใจให้แต่ละกฎเป็นอิสระจากกัน เราจึงเพิ่ม method ใหม่ให้ PromotionEngine แทน:
// FoodOrdering.Application/Promotions/PromotionEngine.cs — เพิ่มใหม่ในบทนี้public sealed class PromotionEngine{ // ... constructor, _promotions, BestDiscountFor เหมือนเดิมทุกตัวอักษรตามที่ทบทวนไว้ด้านบน
private static readonly Discount MaxDiscountCap = new(500m); // เพดานส่วนลดสูงสุดต่อออเดอร์ — กฎใหม่จากฝ่ายการตลาด
// ใหม่ในบทนี้: รวมส่วนลดจากทุกโปรโมชันที่เข้าเงื่อนไข แล้วครอบด้วยเพดานเดียว public Discount StackedDiscountFor(Order order) { var combined = _promotions .Where(p => p.IsEligible(order)) .Select(p => p.CalculateDiscount(order)) .Aggregate(new Discount(0), (sum, d) => new Discount(sum.Amount + d.Amount));
return combined.Amount > MaxDiscountCap.Amount ? MaxDiscountCap : combined; }}Handle(...) เปลี่ยนแค่บรรทัดเดียวกลับไปเหมือนที่ Course A ทำไว้ตอน refactor ครั้งแรก — เปลี่ยนจากเรียก BestDiscountFor เป็น StackedDiscountFor:
// PlaceOrderHandler.Handle(...) — ส่วนที่เปลี่ยน (_promotions คือ field ชนิด PromotionEngine เดิมจาก Course A บทที่ 3)var discount = _promotions.StackedDiscountFor(order);var payable = new Money(order.Total.Amount - discount.Amount, order.Total.Currency);ตอนนี้กฎ “10% สำหรับปีใหม่”, “50 บาทสำหรับตะกร้าใหญ่” และ “เพดานรวม 500 บาท” อยู่ คนละที่กันตามความรับผิดชอบ — สองกฎแรกอยู่ใน IPromotion แต่ละตัว (รู้แค่เรื่องของตัวเอง) ส่วนกฎเพดานอยู่ใน PromotionEngine (รู้ภาพรวมของทุกโปรโมชันรวมกัน) PlaceOrderHandler กลับมาไม่รู้จักตัวเลขเปอร์เซ็นต์หรือเพดานสักตัวเดียวอีกครั้ง มันแค่ถามคำถามเดียว: “ส่วนลดสะสมสำหรับออเดอร์นี้คือเท่าไร”
Domain Service อีกตัว: DeliveryFeeCalculator
หัวข้อที่มีชื่อว่า “Domain Service อีกตัว: DeliveryFeeCalculator”บทที่ 2 ทิ้งท้ายไว้ว่า DeliveryFee เป็น Money ที่ปลอดภัยแล้ว แต่ยังไม่มีสูตรคำนวณว่าควรเป็นเท่าไร — นี่คือ domain service ตัวที่สองของบทนี้ และเป็นตัวอย่างที่ดีของการแยก “สูตร” (business rule, อยู่ใน domain) ออกจาก “ระยะทาง” (I/O เรียก API แผนที่จริง, อยู่ที่ port) เหมือนที่ Course A แยก IMenuCatalog/IPaymentGateway ออกจาก domain logic ไว้แล้วตั้งแต่บทที่ 3
// FoodOrdering.Application/Delivery/IMapsDistance.cs — port (Application ประกาศ, Infrastructure implement)// Address คือ value object จากบทที่ 2 (Line1, District, Postcode)public interface IMapsDistance{ Task<double> GetDistanceKmAsync(Address origin, Address destination, CancellationToken ct);}
// FoodOrdering.Domain/Delivery/DeliveryFeeCalculator.cs — domain service ใหม่ในบทนี้public sealed class DeliveryFeeCalculator{ private const decimal BaseFee = 10m; // ค่าส่งพื้นฐาน (บาท) private const decimal PerKmRate = 5m; // บาทต่อกิโลเมตรที่เกินระยะฟรี private const double FreeDeliveryRadiusKm = 1.0; // ระยะฟรีค่าส่ง (กม.)
public Money Calculate(double distanceKm) { if (distanceKm <= FreeDeliveryRadiusKm) return Money.Thb(0);
var billableKm = (decimal)(distanceKm - FreeDeliveryRadiusKm); return Money.Thb(Math.Round(BaseFee + PerKmRate * billableKm, 2, MidpointRounding.AwayFromZero)); }}สังเกตว่า DeliveryFeeCalculator ไม่รู้จัก IMapsDistance เลยด้วยซ้ำ — มันรับแค่ double distanceKm ที่คำนวณมาแล้วเข้ามา ส่วนใครจะเป็นคนเรียก IMapsDistance.GetDistanceKmAsync(...) แล้วส่งผลลัพธ์ต่อให้ Calculate(...) เป็นหน้าที่ของ Application layer (handler) ไม่ใช่ของ domain service ตัวนี้ การแยกแบบนี้ทำให้ สูตรค่าส่ง ทดสอบได้ทันทีโดยไม่ต้องยิง API แผนที่จริงสักครั้งเดียว — เหมือนกับที่ Course A บทที่ 7 ทดสอบ Order.Place(...) ได้โดยไม่ต้อง mock อะไรเลย เพราะมันไม่มี dependency ภายนอก บทนี้ตั้งใจแตะ DeliveryFeeCalculator แค่สั้นๆ เท่านี้ — โซนค่าส่งซับซ้อนกว่านี้ (ส่วนลดค่าส่ง, โซนพิเศษ) เป็นเรื่องที่ลึกเกินขอบเขตของบทนี้
Specification — ห่อ “เงื่อนไขคัดเลือก” ให้เป็น object ใช้ซ้ำได้
หัวข้อที่มีชื่อว่า “Specification — ห่อ “เงื่อนไขคัดเลือก” ให้เป็น object ใช้ซ้ำได้”กลับไปดู EligiblePromotionSpec ที่ NewYearPromotion ใช้อยู่ข้างบน — ตอนนี้มันเป็นแค่ wrapper บางๆ รอบ Func<Order, bool> เท่านั้น ยังไม่ได้ implement สัญญาอะไรที่เป็นทางการ Specification คือ pattern ที่ทำให้ “เงื่อนไขคัดเลือก” แบบนี้เป็นทางการ — แนวคิดนี้มาจาก Eric Evans และ Martin Fowler ในบทความ “Specifications” (2002) และเป็นหนึ่งใน tactical pattern หลักที่ Evans ระบุไว้ใน Domain-Driven Design (2003) หัวใจของมันคือ interface เดียวที่ตอบคำถามเดียว:
// FoodOrdering.Domain/Specifications/ISpecification.cs — ใหม่ในบทนี้public interface ISpecification<T>{ bool IsSatisfiedBy(T candidate);}สัญญาที่ตั้งใจคือ implementation แต่ละตัวห่อกฎการคัดเลือกไว้ ข้อเดียว ตั้งชื่อให้สื่อความหมาย และเรียกซ้ำได้จากทุกที่ที่ต้องเช็กเงื่อนไขเดียวกัน — ไม่ต้องเขียน if ซ้ำทุกจุดที่ใช้งาน ในทางปฏิบัติ interface นี้มักมี method ช่วยประกอบ And/Or/Not ด้วย (ดูตัวอย่างเต็มที่ Specification ใน DevIQ) แนวทางที่บทนี้ใช้ได้แรงบันดาลใจจาก library โอเพนซอร์ส Ardalis.Specification ของ Steve Smith (Ardalis) ที่ทีมงานจริงจำนวนมากใช้มาตรฐานเดียวกันนี้ทั้ง project
ตอนนี้เรา refactor EligiblePromotionSpec ให้ implement สัญญานี้จริงๆ — code แทบไม่เปลี่ยน แค่ประกาศว่ามันเป็น ISpecification<Order> อย่างเป็นทางการ:
// FoodOrdering.Domain/Promotions/EligiblePromotionSpec.cs — refactor ในบทนี้: implement ISpecification<Order> จริง ๆpublic sealed class EligiblePromotionSpec : ISpecification<Order>{ private readonly Func<Order, bool> _rule; public EligiblePromotionSpec(Func<Order, bool> rule) => _rule = rule; public bool IsSatisfiedBy(Order order) => _rule(order);}classDiagram
class ISpecification~Order~ {
<<interface>>
+IsSatisfiedBy(Order candidate) bool
}
class EligiblePromotionSpec {
+IsSatisfiedBy(Order order) bool
}
class CancellableOrderSpec {
+IsSatisfiedBy(Order candidate) bool
}
class IPromotion {
<<interface>>
+IsEligible(Order order) bool
+CalculateDiscount(Order order) Discount
}
class NewYearPromotion {
+IsEligible(Order order) bool
+CalculateDiscount(Order order) Discount
}
class PromotionEngine {
-promotions IPromotion[]
+BestDiscountFor(Order order) Discount
+StackedDiscountFor(Order order) Discount
}
ISpecification~Order~ <|.. EligiblePromotionSpec
ISpecification~Order~ <|.. CancellableOrderSpec
IPromotion <|.. NewYearPromotion
PromotionEngine ..> IPromotion : uses
คำบรรยายภาพ: EligiblePromotionSpec และ CancellableOrderSpec (หัวข้อถัดไป) implement สัญญาเดียวกันคือ ISpecification<Order> ส่วน IPromotion (Strategy) กับ PromotionEngine (Domain Service) เป็นคนละโครงสร้างที่ทำงานร่วมกัน — PromotionEngine “ใช้” IPromotion หลายตัวพร้อมกัน ไม่ใช่ “เป็น” IPromotion เอง
CancellableOrderSpec — seam เดียวที่ใช้ทั้งตอนยกเลิกและตอน query
หัวข้อที่มีชื่อว่า “CancellableOrderSpec — seam เดียวที่ใช้ทั้งตอนยกเลิกและตอน query”บทที่ 5 เพิ่ม state machine ให้ Order และ method Cancel() ที่รักษา invariant ⑥: ยกเลิกได้เฉพาะ “ก่อนไปรับของ” (cancel-before-pickup) ทวนของเดิมจากบทนั้นก่อน — สถานะทั้งหมด, event OrderCancelled ที่พก RefundAmount ติดไปด้วย (สำหรับ seam คืนเงิน C3), และ Cancel() version แรก:
// จากบทที่ 5 (Order เป็น state machine)public enum OrderStatus { Placed, Confirmed, Preparing, PickedUp, Delivered, Rejected, Cancelled }
// จากบทที่ 5 — event ที่ Cancel() ปล่อยออกมา (RefundAmount ไว้ให้ seam คืนเงิน C3 ใช้ต่อ)public sealed record OrderCancelled(OrderId OrderId, Money RefundAmount, DateTimeOffset OccurredOn);ตอนบทที่ 5 เขียน Cancel() ครั้งแรก มันเช็ก invariant ⑥ ด้วย if ตรงๆ ในตัวเอง:
// FoodOrdering.Domain/Orders/Order.cs — เพิ่มจากบทที่ 5 (ส่วนอื่นของ Order เหมือนเดิมทุกตัวอักษรตามที่ทบทวนไว้ด้านบน)public sealed class Order{ // ... เหมือนเดิม (Id, Lines, Total, DomainEvents, ctor, Place)
public OrderStatus Status { get; private set; }
// version บทที่ 5 — invariant ⑥ เช็กตรงในตัวเอง public void Cancel() { var cancellable = Status is OrderStatus.Placed or OrderStatus.Confirmed or OrderStatus.Preparing; if (!cancellable) throw new InvalidOperationException( $"ยกเลิกออเดอร์ไม่ได้ — สถานะปัจจุบันคือ {Status} เลยจุดที่ยกเลิกได้แล้ว");
Status = OrderStatus.Cancelled; _domainEvents.Add(new OrderCancelled(Id, Total, DateTimeOffset.UtcNow)); }}ใช้งานได้ดี แต่มีปัญหาเดียวกับที่เจอในโปรโมชัน: ถ้าวันหนึ่งมีจุดอื่นในระบบต้องถามคำถามเดียวกัน — เช่น “ดึงรายการออเดอร์ที่ยังยกเลิกได้อยู่ตอนนี้มาแสดงในหน้าแอดมิน” — นักพัฒนาจะต้องเขียน Status is OrderStatus.Placed or OrderStatus.Confirmed or OrderStatus.Preparing ซ้ำอีกรอบที่จุดนั้น เสี่ยงพิมพ์เงื่อนไขไม่ตรงกับใน Cancel() โดยไม่ตั้งใจ นี่คือจุดที่ Specification เข้ามาช่วยพอดี — ห่อเงื่อนไขนี้เป็น object เดียว แล้วให้ทั้งสองจุดเรียกใช้ตัวเดียวกัน:
// FoodOrdering.Domain/Specifications/CancellableOrderSpec.cs — ใหม่ในบทนี้ (C3 seam)public sealed class CancellableOrderSpec : ISpecification<Order>{ // invariant ⑥: ยกเลิกได้ก่อนถึงขั้น PickedUp เท่านั้น public bool IsSatisfiedBy(Order candidate) => candidate.Status is OrderStatus.Placed or OrderStatus.Confirmed or OrderStatus.Preparing;}แล้ว refactor Cancel() ให้เรียก spec ตัวนี้แทนการเช็กเงื่อนไขเอง — พฤติกรรมเดิมทุกอย่าง (ข้อความ exception, การปล่อย OrderCancelled) ยังอยู่ครบ แค่ตัวตัดสินใจว่า “ยกเลิกได้ไหม” ย้ายไปอยู่ที่ spec แทน:
// FoodOrdering.Domain/Orders/Order.cs — Cancel() refactor ในบทนี้public void Cancel(){ if (!new CancellableOrderSpec().IsSatisfiedBy(this)) throw new InvalidOperationException( $"ยกเลิกออเดอร์ไม่ได้ — สถานะปัจจุบันคือ {Status} เลยจุดที่ยกเลิกได้แล้ว");
Status = OrderStatus.Cancelled; _domainEvents.Add(new OrderCancelled(Id, Total, DateTimeOffset.UtcNow));}และหน้าแอดมินที่ต้องการกรองออเดอร์ที่ยกเลิกได้ก็ใช้ spec ตัวเดียวกันนี้ตรงๆ กับ list ที่ดึงมาจาก repository (repository contract เต็มๆ รอบทที่ 8):
// ตัวอย่าง: กรองออเดอร์ที่ "ยังยกเลิกได้" จากรายการที่ดึงมาแล้วIReadOnlyList<Order> orders = /* ... จาก IOrderRepository (บทที่ 8) ... */;var spec = new CancellableOrderSpec();var stillCancellable = orders.Where(spec.IsSatisfiedBy).ToList();ตอนนี้ invariant ⑥ มีบ้านอยู่ที่เดียว — Cancel() กับหน้ากรองรายการอ่านกฎเดียวกันจาก CancellableOrderSpec เสมอ ถ้าวันหนึ่งกฎเปลี่ยน (เช่น ร้านขอเพิ่มว่า “ยกเลิกได้ถึงแม้ Preparing แล้ว แต่ห้ามหลัง 5 นาทีให้หลัง”) ก็แก้ที่เดียวจบ ไม่ต้องไล่หาทุกจุดที่เคยก็อปเงื่อนไขนี้ไปวาง — นี่คือเหตุผลที่ specification ถูกออกแบบมาให้เป็น object แยกต่างหาก ไม่ใช่แค่ if ที่ซ่อนอยู่ใน method เดียว
สรุป + สิ่งที่จะสร้างต่อ
หัวข้อที่มีชื่อว่า “สรุป + สิ่งที่จะสร้างต่อ”บทนี้ตอบคำถามที่ Course A บทที่ 3 ทิ้งไว้: กฎที่ “คร่อมหลาย object และไร้สถานะ” อย่างการสะสมส่วนลดพร้อมเพดาน หรือสูตรค่าส่ง ควรอยู่ใน Domain Service (PromotionEngine, DeliveryFeeCalculator) ไม่ใช่ handler — ส่วนกฎที่เป็น “เงื่อนไขคัดเลือก” ที่ต้องใช้ซ้ำหลายที่ ควรห่อเป็น Specification (EligiblePromotionSpec, CancellableOrderSpec) ที่มีสัญญาเดียวกันคือ ISpecification<T>.IsSatisfiedBy(...) — สังเกตว่า CancellableOrderSpec ทำหน้าที่สองอย่างพร้อมกันโดยไม่มี code ซ้ำเลย: เป็น guard ให้ Order.Cancel() และเป็นตัวกรอง query ให้หน้าแอดมิน
บทถัดไป (บทสุดท้ายของคอร์ส) จะพา Order กลับไปที่คำถามพื้นฐานสองข้อที่ยังไม่ตอบ: สร้าง aggregate ที่ซับซ้อนอย่างถูกวิธีได้ยังไง (FactoryFactoryตัวที่ห่อหุ้มตรรกะการ 'สร้าง' Aggregate ที่ซับซ้อน และรับประกันว่าอ็อบเจ็กต์ใหม่ถูกต้องตาม invariant ตั้งแต่แรกเกิด — 'สร้างของใหม่' (repository หา 'ของเก่า')Tactical Design) และ ค้นหามันกลับมาจากที่เก็บข้อมูลยังไงโดยไม่ให้รายละเอียดฐานข้อมูลรั่วเข้ามาใน domain (RepositoryRepositoryตัวที่ทำให้เข้าถึง Aggregate ราวกับเป็น collection ในหน่วยความจำ ซ่อนรายละเอียดฐานข้อมูล — interface (contract) อยู่วงใน, implementation อยู่ Infrastructure; 1 repository ต่อ1 Aggregate RootTactical Design) — และ specification อย่าง CancellableOrderSpec ที่เพิ่งสร้างในบทนี้ จะกลับมามีบทบาทอีกครั้งตอนคุยเรื่อง query ผ่าน repository
เจาะลึกแนวคิดในบทนี้ต่อได้ที่คลังอ้างอิง DevIQ:
- Specification — นิยามเต็มของ pattern นี้ พร้อมการประกอบด้วย And/Or/Not
- Strategy — pattern ที่
IPromotion/PromotionEngineใช้สลับกฎโปรโมชันโดยไม่ต้องแก้ code เดิม - Domain Services (คอร์ส DDD Patterns บทที่ 17) — ฉบับเต็มของการแยก Domain Service ออกจาก Application Service
เช็กความเข้าใจ — บทที่ 7
ข้อ 1 / 3เวลาจะตัดสินใจว่ากฎธุรกิจข้อหนึ่งควรอยู่ตรงไหน หลักการแบ่งข้อใดถูกต้อง?