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

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 เสมอ

ผู้​ร่วม​งาน​หลัก​มี​สี่​ฝ่าย:

  1. Aggregate — จุด​ชนวน event เมื่อ​สถานะ​ภายใน​เปลี่ยน (เช่น Order.Place()) โดย​ไม่รู้​ว่า​ใคร​จะ​รับ​ไป​ทำ​อะไร​ต่อ
  2. Event object — data class เล็ก ๆ ที่​เก็บ​ข้อมูล​ว่า​เกิด​อะไร​ขึ้น (immutable)
  3. Dispatcher / Mediator — กลไก​กลาง​ที่ map event ไปหา handler ที่ subscribe ไว้ (ใน​ตัวอย่าง​นี้​ใช้ dispatcher แบบ​ง่าย แต่ project จริง​มัก​ใช้ Mediator เช่น library MediatR)
  4. 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 อื่น​กลางคัน

// 1) สัญญากลางของ domain event — immutable และมีเวลาที่เกิดเสมอ
public interface IDomainEvent
{
DateTime OccurredOn { get; }
}
// 2) event จริง ตั้งชื่อเป็นกริยาอดีตตามภาษา domain
public 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 ไว้ก่อน ยังไม่ dispatch
public 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 ทุก​อย่าง)
ด้านข้อดีข้อ​เสีย
Couplingaggregate ไม่รู้จัก handler เลย เพิ่ม/ลบ handler ได้​อิสระผู้​อ่าน code อาจ​ไม่​เห็น​ทันที​ว่า event หนึ่ง​มี​ใคร​ฟัง​อยู่​บ้าง ต้อง​พึ่ง​การ​ค้นหา/เอกสาร
ความ​สามารถ​ขยายเพิ่ม​กติกา​ใหม่​โดย​ไม่​แตะ code เดิม (OCP)จำนวน handler ที่​มาก​ขึ้น​ทำให้​ระบบ​ซับซ้อน​ขึ้น​โดย​รวม
ความ​สอดคล้อง​ของ​ข้อมูลรองรับ​ทั้ง transaction เดียว​และ eventual consistency ได้​ตาม​ต้องการถ้า​เลือก post-persistence ต้อง​ออกแบบ compensating action เอง​เมื่อ handler ล้มเหลว
Testabilityทดสอบ aggregate กับ handler แยก​จาก​กัน​ได้ทดสอบ end-to-end (ครบ​ทุก handler) ยาก​ขึ้น​เพราะ flow กระจาย​ตัว