Domain Events
เหตุการณ์ที่บ่งบอกการเปลี่ยนสถานะใน domain
เมื่อทำตาม Aggregate 1 transaction ควรแตะ aggregate เดียว แต่ในโลกจริงมักมีกติกาทางธุรกิจที่ต้องขยายผลข้ามหลาย aggregate เช่น “เมื่อสั่งซื้อสำเร็จ ต้องตัดสต็อก แจ้งอีเมลลูกค้า และบันทึก audit log” ถ้าเขียนตรรกะเหล่านี้ไว้ใน method เดียวกับที่สั่งซื้อ code จะพองและผูกกันแน่นขึ้นเรื่อย ๆ ทุกครั้งที่มีกติกาใหม่ ก็ต้องแก้ file เดิมซ้ำ — ขัดกับทั้ง Open/Closed Principle และ Single Responsibility Principle
Domain Events คือ เหตุการณ์ที่บ่งบอกการเปลี่ยนสถานะใน domain เป็นกลไกสื่อสารระหว่างส่วนต่าง ๆ ของ domain โดยไม่ผูกกันแน่น แนวคิดคือแยก “สิ่งที่เกิดขึ้น” (event) ออกจาก “สิ่งที่ต้องทำต่อ” (handler) — aggregate แค่ประกาศว่ามีเหตุการณ์เกิดขึ้น ส่วนใครจะฟังและทำอะไรต่อเป็นเรื่องของ handler แยกต่างหาก ทำให้เพิ่มพฤติกรรมใหม่ได้โดยไม่แตะ code เดิม นอกจาก decouple ภายใน domain แล้ว ยังใช้ (ร่วมกับ integration event) สื่อสารกับส่วนอื่นของ app (เช่น UI) หรือระบบอื่นได้
ต้องแยกให้ชัดระหว่าง pre-persistence (จุดชนวนและแก้ทันที) กับ post-persistence events (เข้าคิวบน entity ที่จุดชนวน แล้ว dispatch เมื่อ entity ถูกบันทึกแล้ว) มักเกิดที่ระดับ aggregate และเป็นการประยุกต์ Observer ในระดับ domain — ผู้เชี่ยวชาญ domain อย่าง Eric Evans และ Vaughn Vernon แนะนำให้ใช้ eventual consistency ข้าม aggregate ผ่าน domain event แทนการยัดหลาย aggregate ไว้ใน transaction เดียว
โครงสร้าง
หัวข้อที่มีชื่อว่า “โครงสร้าง”classDiagram
class AggregateRoot {
+RaiseDomainEvent()
+GetDomainEvents()
+ClearDomainEvents()
}
class Order {
+Place()
}
class IDomainEvent {
+OccurredOn
}
class OrderPlaced {
+OrderId
+OccurredOn
}
class IDomainEventHandler {
+Handle()
}
class EmailHandler {
+Handle()
}
class InventoryHandler {
+Handle()
}
class DomainEventDispatcher {
+Dispatch()
}
AggregateRoot <|-- Order
AggregateRoot o-- IDomainEvent
IDomainEvent <|.. OrderPlaced
IDomainEventHandler <|.. EmailHandler
IDomainEventHandler <|.. InventoryHandler
DomainEventDispatcher --> IDomainEvent
DomainEventDispatcher --> IDomainEventHandler
AggregateRoot เก็บรายการ event ที่เกิดขึ้นระหว่างการทำงาน (o-- เพราะเป็น composition ชั่วคราว รอ dispatch) — IDomainEvent เป็น immutable data class ตั้งชื่อเป็นกริยาอดีต ส่วน IDomainEventHandler มีได้หลายตัวต่อ1 event โดยไม่รู้จักกันเอง
ชื่อ class event ควรตั้งเป็นกริยารูปอดีต ตามภาษาของ domain (Ubiquitous Language) เช่น OrderPlaced, OrderShipped เพราะ event คือสิ่งที่ “เกิดขึ้นไปแล้ว” — ไม่มีทางแก้ไขย้อนหลัง จึงควรเป็น immutable object เสมอ
การทำงาน
หัวข้อที่มีชื่อว่า “การทำงาน”ผู้ร่วมงานหลักมีสี่ฝ่าย:
- Aggregate — จุดชนวน event เมื่อสถานะภายในเปลี่ยน (เช่น
Order.Place()) โดยไม่รู้ว่าใครจะรับไปทำอะไรต่อ - Event object — data class เล็ก ๆ ที่เก็บข้อมูลว่าเกิดอะไรขึ้น (immutable)
- Dispatcher / Mediator — กลไกกลางที่ map event ไปหา handler ที่ subscribe ไว้ (ในตัวอย่างนี้ใช้ dispatcher แบบง่าย แต่ project จริงมักใช้ Mediator เช่น library MediatR)
- Handler — ตรรกะที่ทำงานเมื่อได้รับ event หนึ่งตัวอาจมี handler ได้หลายตัว และแต่ละตัวไม่รู้จักกัน
ประเด็นสำคัญที่สุดคือ เมื่อไหร่ dispatch: ถ้า dispatch ก่อน commit transaction (pre-persistence / “before”) ผลข้างเคียงทั้งหมดจะอยู่ใน transaction เดียวกับ aggregate ต้นทาง — ถ้า handler ล้มเหลว ทุกอย่างจะ rollback พร้อมกัน เขียนง่ายแต่ทำให้ transaction กว้างขึ้น ถ้า dispatch หลัง commit (post-persistence / “after”) แต่ละ handler จะอยู่คนละ transaction ต้องยอมรับ eventual consistency และอาจต้องมี compensating action หาก handler ล้มเหลวทีหลัง
sequenceDiagram
participant Cmd as CommandHandler
participant Ord as OrderAggregate
participant Repo as Repository
participant Disp as EventDispatcher
participant H as EventHandlers
Cmd->>Ord: Place
Ord->>Ord: RaiseDomainEvent OrderPlaced
Cmd->>Repo: SaveChanges
Repo->>Disp: Dispatch pending events
Disp->>H: Handle OrderPlaced
H-->>Disp: เสร็จสิ้น
ลำดับนี้คือแบบ deferred: Order.Place() แค่ เก็บ event ไว้ใน list ของตัวมันเอง ยังไม่ dispatch ทันที — เพิ่งไป dispatch จริงตอนที่ repository/unit of work บันทึกลง database แล้ว วิธีนี้ทำให้ debug ง่ายกว่าการ dispatch ทันทีตอน raise เพราะเวลาไล่ code Place() จะไม่ถูกกระโดดไป handler อื่นกลางคัน
ตัวอย่าง code
หัวข้อที่มีชื่อว่า “ตัวอย่าง code”// 1) สัญญากลางของ domain event — immutable และมีเวลาที่เกิดเสมอpublic interface IDomainEvent{ DateTime OccurredOn { get; }}
// 2) event จริง ตั้งชื่อเป็นกริยาอดีตตามภาษา domainpublic sealed class OrderPlaced : IDomainEvent{ public Guid OrderId { get; } public decimal TotalAmount { get; } public DateTime OccurredOn { get; }
public OrderPlaced(Guid orderId, decimal totalAmount) { OrderId = orderId; TotalAmount = totalAmount; OccurredOn = DateTime.UtcNow; }}
// 3) base class ให้ aggregate ทุกตัวสะสม event ไว้ก่อน ยังไม่ dispatchpublic abstract class AggregateRoot{ private readonly List<IDomainEvent> _domainEvents = new(); public IReadOnlyCollection<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();
protected void RaiseDomainEvent(IDomainEvent domainEvent) => _domainEvents.Add(domainEvent);
public void ClearDomainEvents() => _domainEvents.Clear();}
// 4) aggregate เปลี่ยนสถานะภายใน แล้ว "ประกาศ" event — ไม่รู้จัก handler ใด ๆ เลยpublic class Order : AggregateRoot{ public Guid Id { get; } public decimal TotalAmount { get; private set; } public OrderStatus Status { get; private set; }
public void Place(decimal totalAmount) { if (totalAmount <= 0) throw new InvalidOperationException("ยอดสั่งซื้อต้องมากกว่า 0");
TotalAmount = totalAmount; Status = OrderStatus.Placed;
RaiseDomainEvent(new OrderPlaced(Id, TotalAmount)); // handler ต่าง ๆ จะรับไปทำต่อ }}
// 5) handler แยกอิสระต่อกัน — เพิ่ม handler ใหม่ได้โดยไม่แตะ Order เลยpublic interface IDomainEventHandler<in TEvent> where TEvent : IDomainEvent{ Task Handle(TEvent domainEvent, CancellationToken ct = default);}
public class SendOrderConfirmationEmail : IDomainEventHandler<OrderPlaced>{ private readonly IEmailSender _emailSender; public SendOrderConfirmationEmail(IEmailSender emailSender) => _emailSender = emailSender;
public Task Handle(OrderPlaced domainEvent, CancellationToken ct = default) => _emailSender.SendOrderConfirmationAsync(domainEvent.OrderId, ct);}
public class DecrementInventoryOnOrderPlaced : IDomainEventHandler<OrderPlaced>{ private readonly IInventoryService _inventory; public DecrementInventoryOnOrderPlaced(IInventoryService inventory) => _inventory = inventory;
public Task Handle(OrderPlaced domainEvent, CancellationToken ct = default) => _inventory.ReserveForOrderAsync(domainEvent.OrderId, ct);}
// 6) dispatch หลัง SaveChanges สำเร็จ (post-persistence, deferred approach)public class OrderingDbContext : DbContext, IUnitOfWork{ private readonly IDomainEventDispatcher _dispatcher;
public async Task<bool> SaveEntitiesAsync(CancellationToken ct = default) { var result = await base.SaveChangesAsync(ct) > 0;
var aggregatesWithEvents = ChangeTracker.Entries<AggregateRoot>() .Select(e => e.Entity) .Where(a => a.DomainEvents.Any()) .ToList();
foreach (var aggregate in aggregatesWithEvents) { var events = aggregate.DomainEvents.ToList(); aggregate.ClearDomainEvents(); foreach (var domainEvent in events) await _dispatcher.DispatchAsync(domainEvent, ct); }
return result; }}เมื่อไหร่ควรใช้
หัวข้อที่มีชื่อว่า “เมื่อไหร่ควรใช้”- ต้องการให้กติกาข้าม aggregate หลายตัว “ชัดเจน” อยู่ในชื่อ event/handler แทนที่จะฝังใน method ยาว ๆ
- คาดว่าจำนวนผลข้างเคียง (side effect) จะเพิ่มขึ้นเรื่อย ๆ ตามเวลา และอยากเพิ่มโดยไม่แก้ code เดิม (เปิดรับ OCP)
- ต้องการแยก concern ระหว่างตรรกะหลักของ aggregate กับ effect รอง เช่น ส่งอีเมล บันทึก log, sync read model
- ใช้เป็นสะพานไป integration event เพื่อกระจายสถานะข้าม bounded context หรือ microservice
- ทำ CQRS แล้วต้องการ sync read-model จาก write-model แบบ eventual consistency
เมื่อไหร่ไม่ควรใช้
หัวข้อที่มีชื่อว่า “เมื่อไหร่ไม่ควรใช้”- domain เล็ก มีกติกาน้อย การเรียก method ตรง ๆ ยังอ่านง่ายกว่าและ debug ง่ายกว่า — อย่าใช้เพราะ “ดูดี” ตาม YAGNI
- ทีมยังไม่คุ้นกับการไล่ debug ผ่าน handler หลายชั้น อาจทำให้ตามรอย flow ยากขึ้นโดยไม่จำเป็น
- ผลข้างเคียงต้องเกิดพร้อมกันแบบ atomic เสมอ (ต้อง fail ด้วยกันทั้งหมด) และ domain ไม่ยอมรับ eventual consistency เลย — กรณีนี้เรียก method ตรง ๆ ใน transaction เดียวอาจตรงไปตรงมากว่า
- ใช้ event เป็นทางลัดส่ง DTO ข้าม layer แทนการออกแบบ domain model ให้ถูกต้อง (จะกลายเป็น anemic model ที่พึ่งพา event ทุกอย่าง)
ข้อดีและข้อเสีย
หัวข้อที่มีชื่อว่า “ข้อดีและข้อเสีย”| ด้าน | ข้อดี | ข้อเสีย |
|---|---|---|
| Coupling | aggregate ไม่รู้จัก handler เลย เพิ่ม/ลบ handler ได้อิสระ | ผู้อ่าน code อาจไม่เห็นทันทีว่า event หนึ่งมีใครฟังอยู่บ้าง ต้องพึ่งการค้นหา/เอกสาร |
| ความสามารถขยาย | เพิ่มกติกาใหม่โดยไม่แตะ code เดิม (OCP) | จำนวน handler ที่มากขึ้นทำให้ระบบซับซ้อนขึ้นโดยรวม |
| ความสอดคล้องของข้อมูล | รองรับทั้ง transaction เดียวและ eventual consistency ได้ตามต้องการ | ถ้าเลือก post-persistence ต้องออกแบบ compensating action เองเมื่อ handler ล้มเหลว |
| Testability | ทดสอบ aggregate กับ handler แยกจากกันได้ | ทดสอบ end-to-end (ครบทุก handler) ยากขึ้นเพราะ flow กระจายตัว |
ที่เกี่ยวข้อง
หัวข้อที่มีชื่อว่า “ที่เกี่ยวข้อง”แหล่งอ้างอิง
หัวข้อที่มีชื่อว่า “แหล่งอ้างอิง”- ที่มา · deviq.com/design-patterns/domain-events-pattern
- Domain Event — Martin Fowler
- Domain events: Design and implementation — Microsoft Learn (.NET Microservices Architecture)
- A better domain events pattern — Jimmy Bogard
- Effective Aggregate Design, Part II: Making Aggregates Work Together — Vaughn Vernon