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

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

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

ก่อน​ไป​ต่อ ทวน 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–2
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;
}
}

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 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 — เติม : IDomainEvent
public 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;
ถ้า​จะ​ออกแบบ​ใหม่​ตั้งแต่​ต้น — base class ที่​รวม Raise() ไว้​ที่​เดียว

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 อะไร​ก็ได้​แบบ​เมื่อ​ก่อน

นี่​คือ​กฎ​ที่​สำคัญ​ที่สุด​ของ​บท​นี้: 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 + เพิ่ม dispatcher
public 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 INotification
public sealed class DomainEventNotification<TEvent> : INotification where TEvent : IDomainEvent
{
public TEvent DomainEvent { get; }
public DomainEventNotification(TEvent domainEvent) => DomainEvent = domainEvent;
}
FoodOrdering.Infrastructure/Events/MediatorDomainEventDispatcher.cs
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 อยู่​บ้าง

สิ่ง​ที่ event ทำ​แทน​ไม่​ได้

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

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

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

ข้อ 1 / 3

Domain Event ควรมีชื่อและคุณสมบัติแบบไหน?