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

Order เป็น state machine

ใน Course A บท​ที่ 7 เรา​ทิ้ง​ท้ายไว้ตรงๆ ว่า CancelOrderHandler และ state machine ของ​สถานะ​ออเดอร์ (PlacedCancelled → คืน​เงิน​สำเร็จ) “ยัง​ไม่​ถูก​สร้าง​ใน​คอร์ส​นี้” — และ​สัญญา​ไว้​ว่า “คอร์ส​ถัด​ไป​ใน​ซีรีส์​จะ​กลับ​มาสร้าง handler ตัว​นี้​เต็มๆ พร้อม compensation logic” คอร์ส​นั้น​คือ​คอร์ส​นี้​เอง และ​บท​นี้​คือ​จุด​ที่​เรา​กลับ​มา​ปิด​หนี้​ก้อน​นั้น

จนถึง​ตอน​นี้ Order ของ​เรา​มี​แค่​จุด​เกิด (Place) กับ​ยอด​รวม (Total) — ไม่มี​สถานะ​เลย​ด้วย​ซ้ำ ใน​โลก​จริง​ออเดอร์​หนึ่ง​ใบ​เดิน​ทางผ่าน​หลาย​สถานะ: ร้าน​ยืนยัน​หรือ​ปฏิเสธ, เริ่ม​ทำ​อาหาร, ไร​เด​อร์มา​รับ, ส่ง​ถึง​ลูกค้า — และ​บาง​จุด​ลูกค้า​ก็​ยกเลิก​ได้ แต่​ไม่ใช่​ทุก​จุด บท​นี้​จะ​เติม​สถานะ​เหล่า​นี้​เข้าไป​ใน Order ให้​ถูก​วิธี พร้อม​กฎ “ถ้า​ร้าน​ไม่​ยืนยัน​ใน 5 นาที ให้​ยกเลิก​อัตโนมัติ” ที่​เป็น​ตัวอย่าง​ของ PolicyPolicyกฎ​เชิง​ปฏิกิริยา 'เมื่อ​เกิด X ให้​ทำ Y' ใน domain (สติกเกอร์​สี​ม่วง​ใน Event Storming) เช่น เมื่อ​ร้าน​ไม่​ยืนยัน​ภายใน 5 นาที ให้​ยกเลิก​ออเดอร์​และ​คืน​เงิน​อัตโนมัติProcess และ seam ที่​ต่อ​ไป​ยัง​การ​คืน​เงิน

📦 code ตัวอย่าง

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

ถ้า​ไม่​ได้​คิด​เรื่อง​นี้​ให้​ดี​ตั้งแต่​แรก วิธี​ที่​สามัญสำนึก​ทั่วไป​มัก​เลือก​คือ​เก็บ​สถานะ​เป็น string แล้ว​ให้​แต่ละ handler เช็ก​เอา​เอง:

❌ version ดิบ — Status เป็น string เดา​ทาง​ไม่​ได้
// ❌ 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:

FoodOrdering.Domain/Orders/Order.cs
// (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 -> Confirmed
order.Confirm(); // throws: "ยืนยันออเดอร์ไม่ได้ — สถานะปัจจุบันคือ Confirmed ไม่ใช่ Placed"

จุด​ต่าง​จาก version ดิบ​ชัดเจน: ไม่มี​ใคร​ยืนยัน​ออเดอร์​ที่​ยืนยัน​ไป​แล้ว​ซ้ำ​ได้​อีก เพราะ Order เอง​เป็น​คน​เช็ก ไม่ใช่​ปล่อย​ให้ handler แต่ละ​ตัว​เช็ก​เอา​เอง และ​ออเดอร์​ที่​ส่ง​ถึง​ลูกค้า​ไป​แล้ว​ก็​ยกเลิก​ไม่​ได้​อีก​ต่อ​ไป:

// สมมติออเดอร์นี้เดินทางมาถึง Status = Delivered แล้ว
delivered.Cancel(); // throws: "ยกเลิกออเดอร์ไม่ได้ — สถานะปัจจุบันคือ Delivered เลยจุดที่ยกเลิกได้แล้ว"

ทุก method transition มี​รูปร่าง​เดียวกัน: เช็กว่า​สถานะ​ปัจจุบัน​อนุญาต​ให้​เปลี่ยน​ไป​ยัง​สถานะ​เป้าหมาย​หรือ​ไม่​ก่อน (invariant ⑤/⑥) ถ้า​ไม่​ผ่าน​ก็​โยน exception ทันที ถ้า​ผ่าน​ก็​เปลี่ยน​สถานะ​แล้ว​ปล่อย domain event — เส้นทาง​ทั้งหมด​ถูก​รักษา​ไว้​อยู่​ที่​จุด​เดียว​ใน Order ไม่​กระจัดกระจาย​เหมือน version ดิบ

ทีนี้​มา​ดู​กฎ​ที่​ซับซ้อน​กว่า​นั้น​อีก​ขั้น: “ถ้า​ร้าน​ยัง​ไม่​ยืนยัน​ออเดอร์​ภายใน 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 นี้​เลย

ออเดอร์​ที่​ถูก​ยกเลิก​หลัง​จากร้าน​ยืนยัน​แล้ว มัก​หมายความ​ว่า​เงิน​ถูก​ตัด​จาก​ลูกค้า​ไป​แล้ว​ด้วย (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

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