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

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 ตัวอย่าง

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

ก่อน​เขียน​กฎ​ใหม่​สัก​ข้อ คำถาม​แรก​ที่​ควร​ถาม​คือ “ใคร​ควร​เป็น​เจ้าของ​มัน” — ไม่ใช่​ทุก​กฎ​จะ​ไป​ลง​ที่ entity หรือ value object เสมอ บท​นี้​วาง​กรอบ​ตัดสิน​ใจ​สาม​ทาง​ที่​ใช้ได้​กับ​ทุก​กฎ​ใน domain นี้:

ลักษณะ​ของ​กฎควร​อยู่​ที่ตัวอย่าง​ใน domain นี้
เป็น​ของ object เดียว ใช้​แค่​ข้อมูล​ของ​ตัวเองEntity / Value ObjectOrder.Place(...) เช็ก​ตะกร้า​ว่างเปล่า, Money.Round()
คร่อม​หลาย object พร้อม​กัน และ​ไร้​สถานะ​ของ​ตัวเอง (รับ input คำนวณ output)Domain ServicePromotionEngine (ดู​หลาย​โปรโมชัน​พร้อม​กัน), DeliveryFeeCalculator
เป็น “เงื่อนไข​คัดเลือก/ตรวจสอบ” ที่​อยาก​เรียก​ซ้ำ​ได้​หลาย​ที่ (guard, filter, query)SpecificationEligiblePromotionSpec, CancellableOrderSpec

จุด​ที่​มัก​สับสน​คือ​แถว​ที่​สอง​กับ​สาม — ทั้ง​คู่ “คร่อม” มากกว่า1 object เหมือน​กัน แต่​ต่าง​กัน​ที่​ผลลัพธ์: domain service คำนวณค่า (เช่น ส่วนลด​เป็น​บาท, ค่า​ส่ง​เป็น​บาท) ส่วน specification ตอบ​แค่ ใช่/ไม่ใช่ (ใช้​โปรโมชัน​นี้​ได้​ไหม, ยกเลิก​ออเดอร์​นี้​ได้​ไหม) แล้ว​มัก​ถูก domain service หรือ aggregate เรียก​ใช้​ต่อ​อีก​ที บท​นี้​จะ​ไล่​ดู​ทั้ง​สอง​แบบ​ที​ละ​ตัว โดย​เริ่ม​จาก domain service ก่อน

ก่อน​ไป​ต่อ ต้อง​ทวน​ของ​เดิม​ให้​ครบ​ก่อน​ว่า 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 สร้าง​ไว้​เป็นพรีวิว — ทวน​แบบ​เต็ม​ทุก​ตัว​อักษร เพราะ​บท​นี้​จะ​ขยาย​มัน​ต่อ:

FoodOrdering.Domain/Promotions/Discount.cs
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 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)!;
}
}

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 ดิบ — สะสม​ส่วนลดสดๆ ใน 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 กลับ​มา​ไม่รู้จัก​ตัวเลข​เปอร์เซ็นต์​หรือ​เพดาน​สัก​ตัว​เดียว​อีก​ครั้ง มัน​แค่​ถาม​คำถาม​เดียว: “ส่วนลด​สะสม​สำหรับ​ออเดอร์​นี้​คือ​เท่าไร”

บท​ที่ 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

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

  • Specification — นิยาม​เต็ม​ของ pattern นี้ พร้อม​การ​ประกอบ​ด้วย And/Or/Not
  • Strategy — pattern ที่ IPromotion/PromotionEngine ใช้​สลับ​กฎ​โปรโมชัน​โดย​ไม่​ต้อง​แก้ code เดิม
  • Domain Services (คอร์ส DDD Patterns บท​ที่ 17) — ฉบับ​เต็ม​ของ​การ​แยก Domain Service ออก​จาก Application Service

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

ข้อ 1 / 3

เวลาจะตัดสินใจว่ากฎธุรกิจข้อหนึ่งควรอยู่ตรงไหน หลักการแบ่งข้อใดถูกต้อง?