Order เป็น state machine
ใน Course A บทที่ 7 เราทิ้งท้ายไว้ตรงๆ ว่า CancelOrderHandler และ state machine ของสถานะออเดอร์ (Placed → Cancelled → คืนเงินสำเร็จ) “ยังไม่ถูกสร้างในคอร์สนี้” — และสัญญาไว้ว่า “คอร์สถัดไปในซีรีส์จะกลับมาสร้าง handler ตัวนี้เต็มๆ พร้อม compensation logic” คอร์สนั้นคือคอร์สนี้เอง และบทนี้คือจุดที่เรากลับมาปิดหนี้ก้อนนั้น
จนถึงตอนนี้ Order ของเรามีแค่จุดเกิด (Place) กับยอดรวม (Total) — ไม่มีสถานะเลยด้วยซ้ำ ในโลกจริงออเดอร์หนึ่งใบเดินทางผ่านหลายสถานะ: ร้านยืนยันหรือปฏิเสธ, เริ่มทำอาหาร, ไรเดอร์มารับ, ส่งถึงลูกค้า — และบางจุดลูกค้าก็ยกเลิกได้ แต่ไม่ใช่ทุกจุด บทนี้จะเติมสถานะเหล่านี้เข้าไปใน Order ให้ถูกวิธี พร้อมกฎ “ถ้าร้านไม่ยืนยันใน 5 นาที ให้ยกเลิกอัตโนมัติ” ที่เป็นตัวอย่างของ PolicyPolicyกฎเชิงปฏิกิริยา 'เมื่อเกิด X ให้ทำ Y' ใน domain (สติกเกอร์สีม่วงใน Event Storming) เช่น เมื่อร้านไม่ยืนยันภายใน 5 นาที ให้ยกเลิกออเดอร์และคืนเงินอัตโนมัติProcess และ seam ที่ต่อไปยังการคืนเงิน
code เต็มของบทนี้อยู่ที่ repo kaen-food-ordering (กำลังจัดทำ) — path: FoodOrdering.Domain/Orders/ (state machine) และ FoodOrdering.Application/Orders/ (policy)
version ดิบ — สถานะที่เป็น string ลอยๆ
หัวข้อที่มีชื่อว่า “version ดิบ — สถานะที่เป็น string ลอยๆ”ถ้าไม่ได้คิดเรื่องนี้ให้ดีตั้งแต่แรก วิธีที่สามัญสำนึกทั่วไปมักเลือกคือเก็บสถานะเป็น string แล้วให้แต่ละ handler เช็กเอาเอง:
// ❌ version ดิบ — ไม่มีอะไรบังคับว่า string ไหน "ถูกกฎ" บ้างpublic class Order{ public string Status { get; set; } = "placed";}
public class ConfirmOrderHandler{ public void Handle(Order order) { if (order.Status == "placed") order.Status = "confirmed"; // ลืมเช็กว่าตอนนี้อาจเป็น "cancelled" ไปแล้วก็ได้ — ยืนยันออเดอร์ที่ถูกยกเลิกไปแล้วเงียบ ๆ }}
public class MarkPickedUpHandler{ public void Handle(Order order) { if (order.Status == "confirmed" || order.Status == "placed") // เผลอเขียนเงื่อนไขหลวมเกินไป order.Status = "picked_up"; // ข้าม "preparing" ไปเลยก็ยังทำได้ ไม่มีใครทัก }}
public class CancelOrderHandler{ public void Handle(Order order) { order.Status = "cancelled"; // เซ็ตได้จากทุกสถานะ — แม้ออเดอร์จะส่งถึงลูกค้าไปแล้วก็ยัง "ยกเลิก" ได้ }}ทุกบรรทัด compile ผ่านหมด แต่ไม่มีที่ไหนสักจุดที่รู้ “เส้นทางที่ถูกกฎ” ของสถานะทั้งหมดพร้อมกัน — แต่ละ handler ต่างคนต่างเช็กเงื่อนไขของตัวเอง พิมพ์ "Confirmed" ผิดเป็น "confirmed" ก็ไม่มี compiler ช่วยเตือน และที่ร้ายที่สุดคือ CancelOrderHandler เซ็ต "cancelled" ได้จากทุกสถานะโดยไม่เช็กอะไรเลย — ออเดอร์ที่ไรเดอร์ส่งถึงมือลูกค้าไปแล้ว ("delivered") ก็ยัง “ยกเลิก” ได้เหมือนเดิม ยิ่งมี handler ใหม่เพิ่มขึ้นเรื่อยๆ ก็ยิ่งต้องคัดลอกเงื่อนไขพวกนี้กระจายไปทุกที่ ไม่มีจุดศูนย์กลางที่รักษากฎการเปลี่ยนสถานะเลยสักจุดเดียว (ดูเพิ่มที่ State ใน DevIQ — pattern ที่แก้ปัญหานี้ตรงๆ)
ทางแก้ไม่ใช่การเพิ่มเงื่อนไขให้ครบทุก handler แต่คือการย้าย “กฎการเปลี่ยนสถานะ” ทั้งชุดเข้าไปอยู่ใน Order เอง ให้มันเป็นเจ้าของเส้นทางเปลี่ยนสถานะของตัวเอง
State Machine — เส้นทางเปลี่ยนสถานะที่ถูกกฎเท่านั้น
หัวข้อที่มีชื่อว่า “State Machine — เส้นทางเปลี่ยนสถานะที่ถูกกฎเท่านั้น”นี่คือตัวอย่างของ State MachineState Machineแบบจำลองที่อ็อบเจ็กต์มีสถานะจำกัดจำนวนหนึ่ง และเปลี่ยนสถานะได้เฉพาะตามเส้นทางที่กฎอนุญาต เช่น Order: Placed→Confirmed→Preparing→PickedUp→Delivered (+ Rejected/Cancelled) — ห้ามข้ามหรือย้อนTactical Design — แบบจำลองที่อ็อบเจ็กต์มีสถานะจำกัดจำนวนหนึ่ง และเปลี่ยนสถานะได้เฉพาะตามเส้นทางที่กฎอนุญาตเท่านั้น ขั้นแรกแทนที่ string ด้วย enum ที่ระบุสถานะที่เป็นไปได้ทั้งหมดไว้ชัดเจน:
// FoodOrdering.Domain/Orders/OrderStatus.cs (ใหม่ในบทนี้)public enum OrderStatus{ Placed, Confirmed, Preparing, PickedUp, Delivered, Rejected, Cancelled,}เส้นทางที่ถูกกฎของ OrderStatus มีหน้าตาแบบนี้:
stateDiagram-v2 [*] --> Placed : วางออเดอร์ Placed --> Confirmed : ร้านยืนยัน Placed --> Rejected : ร้านปฏิเสธ Confirmed --> Preparing : เริ่มทำอาหาร Preparing --> PickedUp : ไรเดอร์รับของ PickedUp --> Delivered : ส่งถึงลูกค้า Placed --> Cancelled : ลูกค้ายกเลิก Confirmed --> Cancelled : ลูกค้ายกเลิก Preparing --> Cancelled : ลูกค้ายกเลิก Rejected --> [*] Delivered --> [*] Cancelled --> [*]
คำบรรยายภาพ: จาก Placed แตกได้สองทาง — Confirmed (ร้านรับ) หรือ Rejected (ร้านปฏิเสธ) จากนั้นเดินหน้าเป็นเส้นตรง Confirmed → Preparing → PickedUp → Delivered ส่วน Cancelled ทำได้เฉพาะจาก Placed, Confirmed หรือ Preparing เท่านั้น — พอไรเดอร์รับของไปแล้ว (PickedUp ขึ้นไป) จะยกเลิกไม่ได้อีก นี่คือกฎที่ใน version ดิบด้านบนไม่มีใครรักษาเลย
ทีนี้ย้ายกฎทั้งเส้นทางนี้เข้าไปอยู่ใน Order เอง ผ่าน method transition ที่เช็กสถานะปัจจุบันก่อนทุกครั้ง ก่อนจะเขียน method พวกนี้ ต้องมี event ที่แต่ละ transition จะปล่อยออกมาก่อน (ตาม pattern เดียวกับ OrderPlaced ที่ Order.Place(...) ปล่อยอยู่แล้วจาก Course A):
// FoodOrdering.Domain/Orders/OrderConfirmed.cs, OrderRejected.cs, OrderCancelled.cs (ใหม่ในบทนี้)public sealed record OrderConfirmed(OrderId OrderId, DateTimeOffset OccurredOn);public sealed record OrderRejected(OrderId OrderId, string Reason, DateTimeOffset OccurredOn);public sealed record OrderCancelled(OrderId OrderId, Money RefundAmount, DateTimeOffset OccurredOn);สังเกตว่า OrderCancelled พก RefundAmount มาด้วย ไม่ใช่แค่บอกว่า “ยกเลิกแล้ว” เฉยๆ — เหตุผลจะชัดในหัวข้อ C3 seam ท้ายบท ก่อนเพิ่ม method Confirm(), Reject(reason), Cancel() เข้าไปใน Order ขอทวน type ที่ ctor ของ Order ใช้อยู่ให้ครบก่อน — id กับ Money ยังเหมือน Course A ทุกตัวอักษร ส่วน OrderLine เป็นรูป entity ที่ยกระดับไว้ในบทที่ 3 แล้ว (เหมือนที่บทที่ 4 ทวน):
public sealed record OrderId(Guid Value);public sealed record OrderLineId(Guid Value);public sealed record ProductId(Guid Value);public sealed record Quantity(int Value); // guard: Value < 1 → ArgumentException "จำนวนต้องมีอย่างน้อย 1"
public sealed record Money(decimal Amount, string Currency) // guard: Amount<0 → "จำนวนเงินต้องไม่ติดลบ"; Currency!="THB" → "ตอนนี้รองรับเฉพาะสกุลเงิน THB"{ public static Money Thb(decimal amount) => new(amount, "THB"); public static Money operator +(Money a, Money b); // ต่างสกุลเงิน → InvalidOperationException "บวกเงินต่างสกุลกันไม่ได้" public static Money operator *(Money unitPrice, int quantity);}
// OrderLine — ยกระดับจาก record เป็น entity เทียบด้วย Id ในบทที่ 3 แล้ว (เหมือนที่บทที่ 4 ทวน)public sealed class OrderLine : Entity<OrderLineId> // Entity<TId>.Equals/GetHashCode เทียบด้วย Id ล้วน ๆ ไม่แตะ ProductId/Quantity/UnitPrice เลย{ public ProductId ProductId { get; } public Quantity Quantity { get; } public Money UnitPrice { get; }
public OrderLine(OrderLineId id, ProductId productId, Quantity quantity, Money unitPrice) : base(id) { ProductId = productId; Quantity = quantity; UnitPrice = unitPrice; }}ทีนี้เพิ่ม method Confirm(), Reject(reason), Cancel() เข้าไปใน Order:
// (Id, _lines, Lines, Total, _domainEvents, DomainEvents, ctor, Place เหมือนเดิมทุกตัวอักษรตามที่ Course A ปั้นไว้ —// เพิ่มแค่ Status กับ3 method transition ใหม่ในบทนี้)public sealed class Order{ private readonly List<OrderLine> _lines = new(); private readonly List<object> _domainEvents = new();
public OrderId Id { get; } public IReadOnlyCollection<OrderLine> Lines => _lines.AsReadOnly(); public Money Total { get; private set; } public OrderStatus Status { get; private set; } // ใหม่ในบทนี้ public IReadOnlyCollection<object> 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); Status = OrderStatus.Placed; // ใหม่ในบทนี้ — ทุกออเดอร์เกิดที่ Placed เสมอ }
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; }
// ใหม่ในบทนี้ — invariant ⑤: ยืนยันได้เฉพาะจากสถานะ Placed เท่านั้น public void Confirm() { if (Status != OrderStatus.Placed) throw new InvalidOperationException( $"ยืนยันออเดอร์ไม่ได้ — สถานะปัจจุบันคือ {Status} ไม่ใช่ Placed");
Status = OrderStatus.Confirmed; _domainEvents.Add(new OrderConfirmed(Id, DateTimeOffset.UtcNow)); }
// ใหม่ในบทนี้ — invariant ⑤: ปฏิเสธได้เฉพาะจากสถานะ Placed เท่านั้น (ร้านยังไม่รับออเดอร์) public void Reject(string reason) { if (Status != OrderStatus.Placed) throw new InvalidOperationException( $"ปฏิเสธออเดอร์ไม่ได้ — สถานะปัจจุบันคือ {Status} ไม่ใช่ Placed");
Status = OrderStatus.Rejected; _domainEvents.Add(new OrderRejected(Id, reason, DateTimeOffset.UtcNow)); }
// ใหม่ในบทนี้ — invariant ⑥: ยกเลิกได้เฉพาะ "ก่อนไปรับของ" (cancel-before-pickup) 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)); }}(method StartPreparing(), MarkPickedUp(), MarkDelivered() เดินตามลายเดียวกันทุกตัว — เช็กว่าสถานะปัจจุบันอยู่จุดที่ถูกต้องก่อนเปลี่ยน แล้วปล่อย event — ไม่คัดลอกมาให้ครบทุก method ในบทนี้เพราะรูปแบบซ้ำกันเป๊ะกับ3 method ข้างบน)
ลองใช้งานดู:
var order = Order.Place(orderId, items); // Status = Placed
order.Confirm(); // ผ่าน — Status: Placed -> Confirmedorder.Confirm(); // throws: "ยืนยันออเดอร์ไม่ได้ — สถานะปัจจุบันคือ Confirmed ไม่ใช่ Placed"จุดต่างจาก version ดิบชัดเจน: ไม่มีใครยืนยันออเดอร์ที่ยืนยันไปแล้วซ้ำได้อีก เพราะ Order เองเป็นคนเช็ก ไม่ใช่ปล่อยให้ handler แต่ละตัวเช็กเอาเอง และออเดอร์ที่ส่งถึงลูกค้าไปแล้วก็ยกเลิกไม่ได้อีกต่อไป:
// สมมติออเดอร์นี้เดินทางมาถึง Status = Delivered แล้วdelivered.Cancel(); // throws: "ยกเลิกออเดอร์ไม่ได้ — สถานะปัจจุบันคือ Delivered เลยจุดที่ยกเลิกได้แล้ว"ทุก method transition มีรูปร่างเดียวกัน: เช็กว่าสถานะปัจจุบันอนุญาตให้เปลี่ยนไปยังสถานะเป้าหมายหรือไม่ก่อน (invariant ⑤/⑥) ถ้าไม่ผ่านก็โยน exception ทันที ถ้าผ่านก็เปลี่ยนสถานะแล้วปล่อย domain event — เส้นทางทั้งหมดถูกรักษาไว้อยู่ที่จุดเดียวใน Order ไม่กระจัดกระจายเหมือน version ดิบ
Policy — กฎ 5 นาที กับนาฬิกาที่อยู่คนละชั้น
หัวข้อที่มีชื่อว่า “Policy — กฎ 5 นาที กับนาฬิกาที่อยู่คนละชั้น”ทีนี้มาดูกฎที่ซับซ้อนกว่านั้นอีกขั้น: “ถ้าร้านยังไม่ยืนยันออเดอร์ภายใน 5 นาที ให้ยกเลิกออเดอร์นั้นอัตโนมัติ” นี่คือตัวอย่างของ Policy — กฎเชิงปฏิกิริยารูปแบบ “เมื่อเกิด X ให้ทำ Y” (สติกเกอร์สีม่วงใน Event Storming) จุดที่ต้องแยกให้ชัดคือ กฎ (“5 นาทีแล้วให้ยกเลิก”) อยู่ใน domain/application ส่วน นาฬิกา ที่ใช้นับเวลาเป็น port ที่อยู่วงนอก — คนละชั้นกันโดยเจตนา:
// Ports จาก Course A (Domain/Application ประกาศ, Infrastructure เป็นคนอิมพลีเมนต์จริง)public interface IClock { DateTimeOffset UtcNow { get; } }public interface IScheduler { void ScheduleAfter(TimeSpan delay, Func<CancellationToken, Task> action); }public interface IOrderRepository{ Task SaveAsync(Order order, CancellationToken ct); Task<Order?> FindAsync(OrderId id, CancellationToken ct);}IClock ให้ “ตอนนี้กี่โมง” แบบที่ mock ได้ใน test ส่วน IScheduler เป็นคนจัดการ “ตั้งเวลาแล้วเรียกกลับ” จริง (คิวงาน, background job หรืออะไรก็ตามที่ Infrastructure เลือกใช้) — ทั้งสองตัวนี้ Order เองไม่รู้จักเลยสักตัว เพราะ policy ไม่ใช่ส่วนหนึ่งของ aggregate โดยตรง มันเป็นตัวประสานงานที่อยู่ชั้น Application คอยเรียก Order.Cancel() ให้ตรงเวลา:
// FoodOrdering.Application/Orders/ConfirmationTimeoutPolicy.cs (ใหม่ในบทนี้)public sealed class ConfirmationTimeoutPolicy{ private static readonly TimeSpan ConfirmationWindow = TimeSpan.FromMinutes(5);
private readonly IClock _clock; private readonly IScheduler _scheduler; private readonly IOrderRepository _orders;
public ConfirmationTimeoutPolicy(IClock clock, IScheduler scheduler, IOrderRepository orders) { _clock = clock; _scheduler = scheduler; _orders = orders; }
// เรียกทันทีหลัง Order.Place สำเร็จ — ตั้งเวลาไว้ล่วงหน้า ไม่ใช่ตั้ง background job ที่ต้อง poll ทั้งตาราง public void ScheduleFor(OrderId orderId) { var deadline = _clock.UtcNow + ConfirmationWindow; // ใช้ IClock คำนวณเวลาที่แน่นอน — mock ได้ใน test
_scheduler.ScheduleAfter(ConfirmationWindow, async ct => { if (_clock.UtcNow < deadline) return; // กันเหนียว — เผื่อ scheduler ปลุกก่อนเวลาจริง (เช่น clock ถูกปรับ หรือ implementation คลาดเคลื่อน)
var order = await _orders.FindAsync(orderId, ct); if (order is null || order.Status != OrderStatus.Placed) return; // ยืนยัน/ปฏิเสธ/ถูกยกเลิกไปแล้วก่อนครบ 5 นาที — ไม่ต้องทำอะไร
order.Cancel(); // เรียกผ่าน method guarded transition เดิม ไม่มี "ทางลัด" แก้ Status ตรง ๆ await _orders.SaveAsync(order, ct); }); }}สังเกตว่า ConfirmationTimeoutPolicy.ScheduleFor ไม่ เซ็ต order.Status = OrderStatus.Cancelled ตรงๆ แต่เรียก order.Cancel() เหมือน code ที่อื่นทุกที่ — แม้แต่กฎอัตโนมัติก็ยังต้องเดินผ่าน method transition เดิม ไม่มีทางลัดเป็นพิเศษให้ policy เลย ถ้าออเดอร์ถูกยกเลิกไปแล้วด้วยเหตุผลอื่นก่อนครบ 5 นาที Cancel() ก็จะโยน exception ตามปกติ — code ข้างบนจึงเช็ก order.Status != OrderStatus.Placed ก่อนเรียกเพื่อเลี่ยงกรณีนั้น ส่วนเช็ก _clock.UtcNow < deadline ก่อนหน้านั้นคือการกันเหนียวอีกชั้น — deadline ที่คำนวณจาก IClock ตอน ScheduleFor ถูกแคปเจอร์ไว้ใน callback แล้วเทียบกับเวลา ณ ตอนที่ callback ทำงานจริง เผื่อ IScheduler ปลุกก่อนกำหนด และเพราะ IClock/IScheduler เป็น port ไม่ใช่ของจริงตรงๆ test ของ ConfirmationTimeoutPolicy จึงสลับเป็น fake ได้ทันที (แนวทางเดียวกับที่ Course A บทที่ 7 ใช้กับ IPaymentGateway) — ไม่ต้องรอเวลาจริง 5 นาทีเพื่อ test policy นี้เลย
C3 seam — ยกเลิกแล้วต้องคืนเงิน
หัวข้อที่มีชื่อว่า “C3 seam — ยกเลิกแล้วต้องคืนเงิน”ออเดอร์ที่ถูกยกเลิกหลังจากร้านยืนยันแล้ว มักหมายความว่าเงินถูกตัดจากลูกค้าไปแล้วด้วย (IPaymentGateway.ChargeAsync ที่ PlaceOrderHandler เรียกตั้งแต่ Course A) — พอยกเลิก จึงต้องคืนเงินตามมา นี่คือเหตุผลที่ OrderCancelled ด้านบนพก RefundAmount ติดไปด้วย ไม่ใช่แค่บอกว่า “ยกเลิกแล้ว” เฉยๆ
// จาก Course A บทที่ 7 (Domain Reference) — port ที่มีอยู่แล้ว ไม่ต้องแก้อะไรเพิ่มpublic interface IPaymentGateway{ Task ChargeAsync(Money amount, CancellationToken ct); Task RefundAsync(Money amount, CancellationToken ct);}ตัว handler ที่ฟัง OrderCancelled แล้วสั่งคืนเงินจริงๆ หน้าตาประมาณนี้:
// FoodOrdering.Application/Orders/OrderCancelledRefundHandler.cs (ภาพรวม)public sealed class OrderCancelledRefundHandler{ private readonly IPaymentGateway _paymentGateway;
public OrderCancelledRefundHandler(IPaymentGateway paymentGateway) => _paymentGateway = paymentGateway;
public Task Handle(OrderCancelled cancelled, CancellationToken ct) => _paymentGateway.RefundAsync(cancelled.RefundAmount, ct);}แต่ตรงนี้คือเส้นแบ่งที่ต้องพูดให้ชัด: บทนี้แสดงแค่ transition กับ domain event ที่อยู่ใน domain — Order.Cancel() เปลี่ยนสถานะถูกกฎแล้วปล่อย OrderCancelled พร้อมยอดที่ต้องคืน ส่วนกลไก dispatch จริง (ใครเป็นคนอ่าน order.DomainEvents หลังบันทึกแล้วส่งต่อให้ handler ตัวไหน) รอบทที่ 6 (Domain Events ใน code) และการทดสอบ saga คืนเงินแบบเต็มๆ — รวมถึงกรณี gateway ล่มระหว่างคืนเงิน, คืนซ้ำ, คืนบางส่วน — เป็นเรื่องของคอร์สทดสอบ (testing-in-practice) ที่ยังไม่เปิดสอน สิ่งที่ต้องจำจากบทนี้คือ: seam ของ saga คืนเงินเริ่มต้นที่ domain transition ตัวนี้เอง ไม่ใช่ที่ handler ข้างนอก
สรุป + สิ่งที่จะสร้างต่อ
หัวข้อที่มีชื่อว่า “สรุป + สิ่งที่จะสร้างต่อ”บทนี้เติม state machine ให้ Order — จาก string Status ที่ไม่มีใครรักษาเส้นทาง ไปเป็น enum OrderStatus กับ method transition (Confirm, Reject, Cancel) ที่เช็กกฎ (invariant ⑤ เส้นทางที่ถูกกฎ, invariant ⑥ cancel-before-pickup) ก่อนเปลี่ยนสถานะทุกครั้ง แล้วปล่อย domain event ตามหลัง จากนั้นเราแยกให้เห็นว่า policy “5 นาทีไม่ยืนยันแล้วยกเลิก” เป็นกฎที่อยู่ domain/app ส่วนนาฬิกาที่ใช้นับเวลาเป็น port วงนอก (IClock/IScheduler) และปิดท้ายด้วย seam ของ C3 — OrderCancelled พกยอดคืนเงินติดไปด้วยตั้งแต่ต้นทาง
บทที่ 6 (Domain Events ใน code) จะกลับมาขยายกลไก raise → collect → dispatch ที่ OrderPlaced/OrderConfirmed/OrderRejected/OrderCancelled ทุกตัวใช้ร่วมกันแบบเต็มๆ และบทที่ 7 (Domain Service & Specification) จะยกกฎ cancel-before-pickup (invariant ⑥) ตัวเดียวกับที่เห็นใน Cancel() วันนี้ ไปห่อเป็น CancellableOrderSpec ที่ใช้ซ้ำได้ทั้งตอนยกเลิกและตอน query หาออเดอร์ที่ยังยกเลิกได้
เจาะลึกแนวคิดในบทนี้ต่อได้ที่คลังอ้างอิง DevIQ:
- State — behavioral pattern ที่ผูกพฤติกรรมเข้ากับสถานะปัจจุบัน แทนการเช็ก if กระจายทุกที่
- Domain Events (คอร์ส DDD Patterns บทที่ 18) — ฉบับเต็มของการออกแบบและปล่อย domain event
- จาก Event Storming สู่ Bounded Context และ code — จุดที่ hotspot “5 นาที” และ “คืนเงิน” ถูกตั้งคำถามไว้ตั้งแต่ตอน Event Storming ก่อนบทนี้จะมาปิดหนี้
เช็กความเข้าใจ — บทที่ 5
ข้อ 1 / 3ทำไม string Status ที่เป็น magic string ถึงพังในระยะยาว?