Domain Events ใน code
ใน Course A บทที่ 2 เราเห็น Order.Place(...) แอบทำอะไรบางอย่างทิ้งท้ายไว้บรรทัดเดียว: order._domainEvents.Add(new OrderPlaced(...)) บทนั้นบอกไว้สั้นๆ ว่า event นี้ “ยังไม่ dispatch ทันที รอให้ชั้นที่บันทึกข้อมูลเป็นคนดึงไปส่งต่อทีหลัง” แล้วก็เก็บกลไกที่เหลือไว้ให้ “บทที่ว่าด้วย Application layer” — นั่นคือบทนี้เอง เราจะแกะ OrderPlaced ออกมาดูให้เต็มๆ ว่า raise → collect → dispatch ทำงานยังไง ใครเรียกใครตอนไหน และทำไม Order context ถึงไม่โทรหา Payment context ตรงๆ ทุกครั้งไป
code เต็มของบทนี้อยู่ที่ repo kaen-food-ordering (กำลังจัดทำ) — path: FoodOrdering.Domain/Orders/, FoodOrdering.Application/Orders/, FoodOrdering.Infrastructure/Events/
ทวนสิ่งที่ Course A ทิ้งไว้
หัวข้อที่มีชื่อว่า “ทวนสิ่งที่ Course A ทิ้งไว้”ก่อนไปต่อ ทวน type ที่ Order ประกอบขึ้นจากให้ครบก่อน เพราะ constructor ด้านล่างต้องอ่าน .UnitPrice และ .Quantity.Value จาก OrderLine แต่ละบรรทัดตอนคำนวณ Total — id กับ Quantity เหมือน Course A บทที่ 2 ทุกตัวอักษร ส่วน OrderLine เป็นรูป entity ที่ยกระดับไว้ในบทที่ 3:
// FoodOrdering.Domain/Orders/ (จาก Course A บทที่ 2)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"// OrderLine — ยกระดับเป็น entity เทียบด้วย Id ในบทที่ 3 แล้ว ไม่ใช่ record เทียบด้วยค่าแบบบทที่ 1–2public 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; }}Order ตัวจริงจาก Course A บทที่ 2 คุม _domainEvents เป็น list ภายใน ที่ Place(...) เติมเข้าไปทันทีหลัง invariant ผ่าน:
// FoodOrdering.Domain/Orders/Order.cs (Course A บทที่ 2) — _domainEvents ยังเป็น List<object> หลวม ๆ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<object> _domainEvents = new(); 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); }
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; }}
// FoodOrdering.Domain/Orders/OrderPlaced.cs (Course A บทที่ 2)public sealed record OrderPlaced(OrderId OrderId, Money Total, DateTimeOffset OccurredOn);สังเกตสองอย่าง: (1) DomainEvents เป็น IReadOnlyCollection<object> — หลวมมาก ใครก็ Add อะไรลงไปก็ได้ ไม่มีสัญญาว่า event ต้องมีหน้าตายังไง และ (2) ไม่มีใครใน Course A เรียก order.DomainEvents ไปทำอะไรต่อเลยสักที่ — มันแค่ “นอนอยู่” ในตัว order เฉยๆ หลัง Place(...) คืนค่ากลับมา บทนี้จะแก้ทั้งสองเรื่อง
Domain Event ต้องมีหน้าตาแบบไหน
หัวข้อที่มีชื่อว่า “Domain Event ต้องมีหน้าตาแบบไหน”หลักการตั้งชื่อ Domain EventDomain Eventสิ่งที่ 'เกิดขึ้นแล้ว' ใน domain ซึ่งส่วนอื่นสนใจ แทนด้วยอ็อบเจ็กต์ immutable ตั้งชื่อเป็นอดีต เช่น OrderPlaced, OrderCancelled — ใช้สื่อสารข้าม aggregate/contextTactical Design ที่ Course A พูดไว้แล้วสั้นๆ คือ “ชื่อเขียนเป็นอดีตกาล เพราะมันคือสิ่งที่เกิดขึ้นแล้ว” — Vaughn Vernon เน้นย้ำเรื่องนี้ใน Implementing Domain-Driven Design และ repo ตัวอย่าง IDDD_Samples ของเขา: event ทุกตัวควร immutableImmutableเปลี่ยนแปลงไม่ได้หลังสร้าง ถ้าจะ 'เปลี่ยนค่า' ต้องสร้างอ็อบเจ็กต์ใหม่แทน หลักการสำคัญของ Value Object เพื่อเลี่ยง bug จากการใช้อ้างอิงร่วมกันTactical Design (สร้างเสร็จแล้วแก้ไม่ได้ เพราะมันคือบันทึกของสิ่งที่ผ่านไปแล้ว จะย้อนไปแก้ประวัติศาสตร์ไม่ได้) และควรมีอย่างน้อย timestamp ว่าเกิดขึ้นเมื่อไหร่ ผ่าน property ชื่อ OccurredOn — น่าสนใจว่าเรื่องนี้ Eric Evans เองยอมรับว่าไม่ได้ใส่ Domain Event ไว้ในหนังสือต้นฉบับปี 2003 ด้วยซ้ำ มันคือ pattern ที่ practitioner รุ่นหลังอย่าง Vernon ผลักดันจนกลายเป็นมาตรฐานที่ทุกทีมใช้กันทุกวันนี้
เราจึงยกระดับ _domainEvents ให้ type-safe ขึ้น ด้วย marker interface ที่บังคับสัญญานี้:
// FoodOrdering.Domain/Common/IDomainEvent.cs — ใหม่ในบทนี้public interface IDomainEvent{ DateTimeOffset OccurredOn { get; }}OrderPlaced ก็แค่เติม : IDomainEvent เข้าไป — ไม่ต้องแก้ constructor หรือ field เดิมเลยสักตัว เพราะ OccurredOn ที่มันมีอยู่แล้วตรงกับ signature ของ interface พอดี:
// FoodOrdering.Domain/Orders/OrderPlaced.cs — เติม : IDomainEventpublic sealed record OrderPlaced(OrderId OrderId, Money Total, DateTimeOffset OccurredOn) : IDomainEvent;บทที่แล้ว (Order เป็น state machine) เราเพิ่ม method transition Confirm(), Reject(reason), Cancel() ให้ Order ไปแล้ว แต่ละตัวปล่อย event ของตัวเองตามรอย Place(...) ทุกกระเบียดนิ้ว — แค่ตอนนั้น _domainEvents ยังเป็น List<object> หลวมๆ เหมือนที่ Course A ทิ้งไว้ ยังไม่มี IDomainEvent ให้เกาะ ตอนนี้เราเติม : IDomainEvent ให้ทั้งสามตัวเหมือนที่เพิ่งทำกับ OrderPlaced — ไม่แก้ field หรือ constructor เดิมเลยสักตัว:
// FoodOrdering.Domain/Orders/OrderConfirmed.cs, OrderRejected.cs, OrderCancelled.cs (จากบทที่ 5 — เติม : IDomainEvent)public sealed record OrderConfirmed(OrderId OrderId, DateTimeOffset OccurredOn) : IDomainEvent;public sealed record OrderRejected(OrderId OrderId, string Reason, DateTimeOffset OccurredOn) : IDomainEvent;public sealed record OrderCancelled(OrderId OrderId, Money RefundAmount, DateTimeOffset OccurredOn) : IDomainEvent;Order ของเราเป็น sealed class เดี่ยวๆ ไม่มี base class เลย เลยเขียน _domainEvents.Add(...) ตรงๆ ใน method แต่ถ้า domain หนึ่งมีหลาย aggregate ที่ต้อง raise event เหมือนกันหมด pattern ที่ Vernon ใช้ใน IDDD_Samples คือแยกกลไก “เก็บ event ไว้รอ” นี้ไปไว้ใน base class กลาง กัน copy-paste:
public abstract class AggregateRoot{ private readonly List<IDomainEvent> _domainEvents = new(); public IReadOnlyCollection<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();
protected void Raise(IDomainEvent domainEvent) => _domainEvents.Add(domainEvent);}aggregate ที่สืบทอดจากนี้จะเรียกแค่ Raise(new OrderPlaced(...)) แทนที่จะยุ่งกับ list เอง — Order ของเราไม่ได้สืบทอดจากมัน (code ที่ชิปไปแล้วเราไม่แก้โครงสร้าง) แต่กลไกภายในเหมือนกันเป๊ะ แค่ Order เขียน _domainEvents.Add ตรงๆ แทน
ทีนี้พอมี IDomainEvent แล้ว เราก็รัดชนิดของ _domainEvents ใน Order ให้แน่นขึ้นจาก List<object> เป็น List<IDomainEvent> — ทุก method ที่บทที่แล้วปั้นไว้ (Place, Confirm, Reject, Cancel) เหมือนเดิมทุกบรรทัด แค่ field เก็บ event เปลี่ยนชนิด ทวน OrderStatus จากบทที่แล้วสั้นๆ ก่อน เพราะ method transition ทุกตัวเช็กมันก่อนเปลี่ยนสถานะ:
// FoodOrdering.Domain/Orders/OrderStatus.cs (จากบทที่ 5)public enum OrderStatus{ Placed, Confirmed, Preparing, PickedUp, Delivered, Rejected, Cancelled,}// FoodOrdering.Domain/Orders/Order.cs — ต่อยอดจากบทที่ 5: _domainEvents รัดชนิดเป็น List<IDomainEvent>public sealed class Order{ private readonly List<OrderLine> _lines = new(); private readonly List<IDomainEvent> _domainEvents = new(); // เดิมเป็น List<object>
public OrderId Id { get; } public IReadOnlyCollection<OrderLine> Lines => _lines.AsReadOnly(); public Money Total { get; private set; } public OrderStatus Status { get; private set; } public IReadOnlyCollection<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly(); // เดิมเป็น IReadOnlyCollection<object>
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; }
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; }
public void Confirm() { if (Status != OrderStatus.Placed) throw new InvalidOperationException( $"ยืนยันออเดอร์ไม่ได้ — สถานะปัจจุบันคือ {Status} ไม่ใช่ Placed");
Status = OrderStatus.Confirmed; _domainEvents.Add(new OrderConfirmed(Id, DateTimeOffset.UtcNow)); }
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)); }
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 ไหนเปลี่ยน logic แม้แต่บรรทัดเดียว — invariant เดิม ข้อความ error เดิม เส้นทาง state machine เดิมจากบทที่แล้วทุกเส้น แค่ตอนนี้ compiler รับประกันแล้วว่าของที่อยู่ใน DomainEvents ต้องมี OccurredOn เสมอ ไม่ใช่ object อะไรก็ได้แบบเมื่อก่อน
dispatch ตอนไหน — และทำไมต้อง “หลัง” บันทึกเสมอ
หัวข้อที่มีชื่อว่า “dispatch ตอนไหน — และทำไมต้อง “หลัง” บันทึกเสมอ”นี่คือกฎที่สำคัญที่สุดของบทนี้: event ถูกเก็บไว้ระหว่าง transaction แต่ dispatch หลังจาก aggregate ถูกบันทึกสำเร็จแล้วเท่านั้น เหตุผลตรงไปตรงมา — ถ้า dispatch ก่อนบันทึก แล้ว handler ตัวไหนสักตัวไปทำ side effect ที่ย้อนกลับไม่ได้ (เช่นส่งอีเมลแจ้งร้านอาหาร) แต่ปรากฏว่าการบันทึก Order ลงฐานข้อมูลกลับ fail ไปทีหลัง เราจะได้ระบบที่บอกร้านอาหารว่า “มีออเดอร์ใหม่” ทั้งที่ออเดอร์นั้นไม่เคยถูกบันทึกอยู่จริงเลย
ฝั่ง Application เป็นคนดึง order.DomainEvents ไปส่งต่อ ไม่ใช่ Order เอง (Domain ไม่รู้จักการ “ส่ง” อะไรทั้งนั้น) เราประกาศ contract ของขั้นตอนนี้เป็น port แบบเดียวกับ IOrderRepository:
// FoodOrdering.Application/Common/IDomainEventDispatcher.cs — ใหม่ในบทนี้public interface IDomainEventDispatcher{ Task DispatchAsync(IEnumerable<IDomainEvent> events, CancellationToken ct);}ก่อนไปต่อ ทวน port กับ command ที่ PlaceOrderHandler ใช้อยู่แล้วจาก Course A บทที่ 3 ให้ครบ — เพราะ Handle(...) ด้านล่างเรียกสมาชิกของแต่ละตัวตรงๆ:
// Ports จาก Course A บทที่ 3 (Domain/Application ประกาศ, Infrastructure เป็นคนอิมพลีเมนต์จริง)public interface IMenuCatalog{ Task<Money> GetPriceAsync(ProductId productId, CancellationToken ct);}
public interface IOrderRepository{ Task SaveAsync(Order order, CancellationToken ct); Task<Order?> FindAsync(OrderId id, CancellationToken ct);}
public interface IPaymentGateway{ Task ChargeAsync(Money amount, CancellationToken ct); Task RefundAsync(Money amount, CancellationToken ct);}// FoodOrdering.Application/Orders/PlaceOrderCommand.cs (Course A บทที่ 3)public sealed record PlaceOrderCommand(IReadOnlyList<PlaceOrderItem> Items) : IRequest<OrderId>;public sealed record PlaceOrderItem(ProductId ProductId, int Quantity);แล้วอัปเดต PlaceOrderHandler จาก Course A บทที่ 3 ให้รับ dispatcher นี้เข้ามาเป็น dependency ตัวใหม่ และเรียกมัน หลัง SaveAsync เท่านั้น:
// FoodOrdering.Application/Orders/PlaceOrderHandler.cs — ทบทวนจาก Course A บทที่ 3 + เพิ่ม dispatcherpublic sealed class PlaceOrderHandler : IRequestHandler<PlaceOrderCommand, OrderId>{ private readonly IMenuCatalog _menu; private readonly IOrderRepository _orders; private readonly IPaymentGateway _payments; private readonly IDomainEventDispatcher _dispatcher;
public PlaceOrderHandler( IMenuCatalog menu, IOrderRepository orders, IPaymentGateway payments, IDomainEventDispatcher dispatcher) { _menu = menu; _orders = orders; _payments = payments; _dispatcher = dispatcher; }
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);
await _payments.ChargeAsync(order.Total, ct); await _orders.SaveAsync(order, ct); // ① transaction ปิดตรงนี้ await _dispatcher.DispatchAsync(order.DomainEvents, ct); // ② dispatch หลังบันทึกสำเร็จเท่านั้น
return order.Id; }}ฝั่ง Infrastructure ทำ IDomainEventDispatcher ให้เป็นสะพานไปหา MediatR ที่เราใช้อยู่แล้วสำหรับ command/query — แทนที่จะให้ OrderPlaced (domain ล้วนๆ) ต้อง reference package MediatR โดยตรง เราห่อมันด้วย notification กลางๆ ตัวหนึ่ง:
// FoodOrdering.Infrastructure/Events/DomainEventNotification.cs — สะพานเชื่อม IDomainEvent เข้ากับ MediatR INotificationpublic sealed class DomainEventNotification<TEvent> : INotification where TEvent : IDomainEvent{ public TEvent DomainEvent { get; } public DomainEventNotification(TEvent domainEvent) => DomainEvent = domainEvent;}public sealed class MediatorDomainEventDispatcher : IDomainEventDispatcher{ private readonly IMediator _mediator; public MediatorDomainEventDispatcher(IMediator mediator) => _mediator = mediator;
public async Task DispatchAsync(IEnumerable<IDomainEvent> events, CancellationToken ct) { foreach (var domainEvent in events) { switch (domainEvent) { case OrderPlaced e: await _mediator.Publish(new DomainEventNotification<OrderPlaced>(e), ct); break; case OrderConfirmed e: await _mediator.Publish(new DomainEventNotification<OrderConfirmed>(e), ct); break; case OrderRejected e: await _mediator.Publish(new DomainEventNotification<OrderRejected>(e), ct); break; case OrderCancelled e: await _mediator.Publish(new DomainEventNotification<OrderCancelled>(e), ct); break; } } }}พอเป็น INotification ของ MediatR แล้ว handler กี่ตัวก็สมัครฟัง event เดียวกันได้พร้อมกัน โดย PlaceOrderHandler ไม่รู้จักและไม่แคร์ว่ามีใครฟังอยู่บ้างเลย:
sequenceDiagram
participant H as PlaceOrderHandler
participant DOM as Order
participant REPO as IOrderRepository
participant DISP as IDomainEventDispatcher
participant EMAIL as ตัวจัดการส่งอีเมลยืนยันลูกค้า
participant NOTIFY as ตัวจัดการแจ้งร้านอาหาร
H->>DOM: Order.Place(id, lines)
DOM->>DOM: _domainEvents.Add(OrderPlaced)
DOM-->>H: order (event ค้างอยู่ใน DomainEvents)
H->>REPO: SaveAsync(order)
REPO-->>H: บันทึกสำเร็จ — transaction ปิด
H->>DISP: DispatchAsync(order.DomainEvents)
DISP->>EMAIL: Publish(OrderPlaced)
DISP->>NOTIFY: Publish(OrderPlaced)
Note over EMAIL,NOTIFY: ทำงานหลัง transaction ปิดแล้ว — eventual consistency
คำบรรยายภาพ: Order.Place เก็บ event ไว้ในตัวเองก่อน ไม่ยิงอะไรออกไปทันที รอจน SaveAsync สำเร็จแล้ว Dispatcher ถึงค่อยกระจาย event ไปหา handler แต่ละตัว — ถ้า SaveAsync fail ก่อนถึงบรรทัด dispatch เลย ก็จะไม่มี handler ไหนถูกเรียกเลยสักตัว
Eventual Consistency คืออะไร — และทำไมไม่ใช้กับทุกอย่าง
หัวข้อที่มีชื่อว่า “Eventual Consistency คืออะไร — และทำไมไม่ใช้กับทุกอย่าง”สังเกตว่าใน Handle(...) ข้างบน _payments.ChargeAsync(order.Total, ct) ยังถูกเรียก ตรงๆ อยู่ในบรรทัดเดียวกับที่สร้าง Order ไม่ได้ผ่าน event เลย — นี่ไม่ใช่ความผิดพลาด เป็นการตัดสินใจ นี่คือจุดที่ ความสอดคล้องแบบตามมาทีหลังEventual Consistencyความถูกต้องที่ 'ตามมาทีหลัง' ไม่ใช่ทันที ใช้กับการอัปเดตข้าม Aggregate ผ่าน Domain Event แทนการบังคับให้ตรงกันในทันทีภายใน transaction เดียวTactical Design กับ strong consistency (ความสอดคล้องแบบทันที) ต้องเลือกให้ถูกจุด:
- Strong consistency — สิ่งที่ต้องรู้ผลก่อนตัดสินใจขั้นต่อไป เช่นการตัดเงิน: ถ้าตัดเงินไม่สำเร็จ เราไม่อยากให้ออเดอร์ถูกยืนยัน ฉะนั้นเรียก
IPaymentGateway.ChargeAsyncตรงๆ อยู่ใน use case เดียวกัน ถ้ามันล้มเหลว ทั้ง use case ก็ล้มเหลวไปด้วย ไม่มีสถานะครึ่งๆ กลางๆ ค้างอยู่ - Eventual consistency — สิ่งที่ “รอได้” และไม่ควรทำให้ transaction หลักช้าลงหรือพังไปด้วย เช่นแจ้งเตือนร้านอาหารว่ามีออเดอร์ใหม่ — ถ้าการแจ้งเตือน delay ไปสองสามวินาที หรือแม้แต่ล้มเหลวแล้ว retry ทีหลัง ก็ไม่กระทบธุรกิจโดยตรง จึงเหมาะจะทำผ่าน domain event แบบที่เห็นข้างบน — dispatch หลัง transaction ของ
Orderปิดไปแล้ว
กฎทั่วไปคือ: 1 transaction ต่อ1 aggregate เท่านั้น — PlaceOrderHandler แก้ไข/บันทึกแค่ Order ตัวเดียวต่อครั้ง ไม่แตะ aggregate อื่นใน transaction เดียวกันเด็ดขาด ถ้า Payment หรือ Dispatch context ต้องรู้เรื่องอะไรที่เกิดกับ Order พวกมันต้อง “ตามมาทีหลัง” ผ่าน event เสมอ ไม่ใช่ถูกดึงเข้ามาอยู่ใน transaction เดียวกัน — นี่คือสิ่งที่ทำให้แต่ละ Bounded ContextBounded Contextขอบเขตที่ ubiquitous language และ model ชุดหนึ่งมีความหมายคงเส้นคงวา เช่น Ordering, Payment, Dispatch ใน domain ฟู้ดเดลิเวอรีStrategic Design เป็นอิสระจากกันจริงๆ ไม่ใช่แค่แยก folder กันเฉยๆ
ตัวอย่าง: OrderCancelled → คืนเงิน ข้าม context โดยไม่เรียกตรง
หัวข้อที่มีชื่อว่า “ตัวอย่าง: OrderCancelled → คืนเงิน ข้าม context โดยไม่เรียกตรง”บทที่แล้วเราร่าง OrderCancelledRefundHandler ไว้เป็น “ภาพรวม” พร้อมบอกไว้ตรงๆ ว่ากลไก dispatch จริงที่ทำให้มันถูกเรียกยัง “รอบทที่ 6” — นี่คือจุดที่เราต่อสายให้มันทำงานได้จริง โดยให้มัน implement INotificationHandler<DomainEventNotification<OrderCancelled>> แทนที่จะมี Handle(OrderCancelled, CancellationToken) เดี่ยวๆ ลอยๆ ที่ยังไม่มีใครเรียก — field และ constructor เดิมจากบทที่แล้วไม่เปลี่ยนเลย:
// FoodOrdering.Application/Orders/OrderCancelledRefundHandler.cs — ต่อสายจากบทที่ 5 ให้ MediatR เรียกได้จริงpublic sealed class OrderCancelledRefundHandler : INotificationHandler<DomainEventNotification<OrderCancelled>>{ private readonly IPaymentGateway _paymentGateway;
public OrderCancelledRefundHandler(IPaymentGateway paymentGateway) => _paymentGateway = paymentGateway;
public Task Handle(DomainEventNotification<OrderCancelled> notification, CancellationToken ct) => _paymentGateway.RefundAsync(notification.DomainEvent.RefundAmount, ct);}และตัวอย่างที่ตรงกับ “handler ฝั่ง Notify” ในไดอะแกรมข้างบน — ตัวที่ฟัง OrderPlaced แล้วแจ้งร้านอาหาร:
// FoodOrdering.Infrastructure/Events/Handlers/NotifyRestaurantOnOrderPlacedHandler.cs — ใหม่ในบทนี้public sealed class NotifyRestaurantOnOrderPlacedHandler : INotificationHandler<DomainEventNotification<OrderPlaced>>{ public Task Handle(DomainEventNotification<OrderPlaced> notification, CancellationToken ct) { // notification.DomainEvent คือ OrderPlaced ตัวจริง — ส่งการแจ้งเตือนไปร้านอาหารผ่านช่องทางของ // Infrastructure (push, SMS, ฯลฯ) รายละเอียดช่องทางไม่ใช่ประเด็นของบทนี้ return Task.CompletedTask; }}จุดสำคัญ: Ordering context ไม่มีบรรทัดไหนเรียก IPaymentGateway.RefundAsync ตรงๆ เลยตอนยกเลิกออเดอร์ มันแค่ raise OrderCancelled แล้วปล่อยให้ Payment context จัดการเรื่องคืนเงินของตัวเอง — สองฝั่งไม่ผูกกันแน่น (loose coupling) และ Ordering context ไม่จำเป็นต้องรู้ด้วยซ้ำว่ามี handler ตัวไหนกำลังฟัง OrderCancelled อยู่บ้าง
eventual consistency ไม่ใช่ยาวิเศษที่แทนที่ strong consistency ได้ทุกกรณี ถ้า use case ต้องการคำตอบ “สำเร็จหรือไม่สำเร็จ” ก่อนจะตอบกลับผู้ใช้ (เช่นตัดเงินก่อนยืนยันออเดอร์) ให้เรียก port ตรงๆ ใน transaction เดียวกันเหมือนที่ PlaceOrderHandler ทำกับ ChargeAsync — สงวน domain event ไว้กับ side effect ที่รอได้จริงๆ เท่านั้น
OrderPlaced ที่ Course A แอบชิปไว้ตอนนี้มีกลไกเต็มรูปแล้ว: IDomainEvent เป็นสัญญากลางที่ทุก event ต้องเคารพ (immutable, ตั้งชื่ออดีตกาล, มี OccurredOn), Order เก็บ event ไว้ใน _domainEvents ระหว่าง transaction โดยไม่ dispatch ทันที, Application layer เป็นคนดึง order.DomainEvents ไป dispatch หลัง SaveAsync สำเร็จเท่านั้นผ่านสะพาน DomainEventNotification<TEvent> ไปหา MediatR และ context อื่นอย่าง Payment กับ Dispatch ก็ตามมา “ทีหลัง” ผ่าน event แทนที่จะถูกดึงเข้ามาอยู่ใน transaction เดียวกับ Order — นี่คือ eventual consistency ตัวจริงที่ทำงานอยู่เบื้องหลังระบบข้าม bounded context
บทถัดไปเราจะกลับไปแกะ PromotionEngine ที่ Course A แอบโชว์ไว้ตอนท้าย ให้เห็น Domain Service กับ Specification เต็มรูปแบบ
เจาะลึกแนวคิดในบทนี้ต่อได้ที่คลังอ้างอิง DevIQ และคอร์สอื่นในเว็บนี้:
- Domain Events — pattern เต็มรูปใน DevIQ: publish/subscribe, internal vs external event
- Domain Events (ddd-patterns บทที่ 18) — internal/external event, handler ใน domain vs service layer, ทางเลือกในการ implement
- Domain Events (ddd-distilled บทที่ 6) — มุมมอง Vernon เรื่อง causal consistency และการตั้งชื่อ event
เช็กความเข้าใจ — บทที่ 6
ข้อ 1 / 3Domain Event ควรมีชื่อและคุณสมบัติแบบไหน?