Integration Event ใน process เดียว
สามบทที่ผ่านมาเราแยกออเดอร์ก้อนเดียวออกเป็นสี่ Bounded ContextBounded Contextขอบเขตที่ ubiquitous language และ model หนึ่งชุดมีความหมายคงเส้นคงวา นอกขอบเขตนี้คำเดียวกันอาจหมายถึงคนละอย่าง เช่น 'order' ใน Ordering กับ 'ticket' ใน Kitchen คือมุมมองของสิ่งเดียวกันคนละ modelStrategic Design ที่ต่างเป็นเจ้าของ model ของตัวเอง แล้ววาด context map ว่าใครเชื่อมกับใคร แต่ยังไม่ได้ตอบคำถามที่ค้างไว้: ตอน Ordering ยืนยันออเดอร์เสร็จ Kitchen กับ Delivery จะรู้ได้ยังไงว่าถึงคิวลงมือ — โดยไม่ให้ context หนึ่งเอื้อมเข้าไปใน model ของอีก context? บทนี้ตอบด้วยของชิ้นเดียว: integration event และเราจะทำ event bus ด้วยมือล้วนๆ ไม่พึ่ง framework ให้เห็นกลไกทุกซี่ฟันเฟือง
code เต็มของคอร์สนี้อยู่ที่ repo kaen-food-ordering (กำลังจัดทำ) — บทนี้เพิ่ม folder SharedContracts/Integration (สัญญากลาง) กับ Host/Integration (bus ทำมือ) และให้ Kitchen/Delivery แต่ละ module ลงทะเบียน handler ของตัวเอง code ในบทเป็น version ย่อให้เห็นแนวคิด ตัวจริงใน repo จะครบกว่านี้
ทวน domain event ก่อน — มันไม่เคยตั้งใจจะข้ามขอบเขต
หัวข้อที่มีชื่อว่า “ทวน domain event ก่อน — มันไม่เคยตั้งใจจะข้ามขอบเขต”ใน DDD-in-Code บทที่ 6 เราปั้น Domain EventDomain Eventสิ่งที่เกิดขึ้นแล้วภายใน context เดียว ทำงาน in-process และอ้างอิง domain object ได้ตรง ๆ เช่น OrderConfirmed ภายใน Ordering — เมื่อจะส่งข้ามขอบเขต context ต้องแปลงเป็น Integration Event ก่อนเสมอ ห้ามส่ง domain event ข้ามไปตรง ๆTactical Design ขึ้นมาเต็มรูป — Order.Confirm() raise OrderConfirmed เข้า list ภายใน aggregate แล้วชั้น application ดึงไป dispatch ผ่าน MediatR ทั้งหมดนี้เกิด ในโพรเซสเดียว ภายใน Ordering context เดียว และตัว event อ้าง domain type ได้ตรงๆ:
// FoodOrdering.Ordering/Orders/OrderConfirmed.cspublic sealed record OrderConfirmed(OrderId OrderId, DateTimeOffset OccurredOn) : IDomainEvent;
// ...ถูก raise ตอน Order.Confirm() ผ่านเข้า list ภายใน aggregate_domainEvents.Add(new OrderConfirmed(Id, DateTimeOffset.UtcNow));สังเกตว่า OrderConfirmed ถือ OrderId ซึ่งเป็น record value object ของ Ordering ไม่ใช่ Guid ดิบ และ marker IDomainEvent ก็เป็นชนิดที่นิยามอยู่ใน Ordering เอง — ทั้งหมดนี้ โอเคมาก ตราบใดที่ผู้รับอยู่ในบ้านเดียวกัน MediatR handler ที่ฟัง OrderConfirmed ก็เป็น code ของ Ordering ด้วยกัน มันเห็น OrderId เป็นเรื่องธรรมชาติ
ปัญหาโผล่ตรงที่ Kitchen กับ Delivery ไม่ได้อยู่ในบ้านเดียวกัน ในสถาปัตยกรรม Modular MonolithModular Monolithสถาปัตยกรรมที่ทุก context เป็น module/assembly แยกกันชัดเจนแต่รันในโพรเซสเดียวกัน แต่ละ module เป็นเจ้าของ model/schema ของตัวเอง ห้ามอ้างอิง domain type ข้าม module ตรง ๆ เชื่อมได้เฉพาะผ่าน integration event หรือ query ที่แปลแล้วArchitecture ของคอร์สนี้ แต่ละ context เป็นคนละ module/assembly ที่ ห้ามอ้าง domain type ข้ามกันตรงๆ ถ้าจะให้ Kitchen ฟัง OrderConfirmed โดยตรง Kitchen ก็ต้อง reference OrderId และ IDomainEvent ของ Ordering — เท่านั้นเส้นขอบที่เราอุตส่าห์ลากไว้ก็รั่วทันที
Domain event กับ integration event — คนละงาน
หัวข้อที่มีชื่อว่า “Domain event กับ integration event — คนละงาน”นี่คือเส้นแบ่งที่ต้องจำให้ขึ้นใจทั้งคอร์ส Integration EventIntegration Eventevent ที่สื่อสาร 'ข้าม' Bounded Context เป็น contract แบบ serializable ที่มีแต่ id/ค่าพื้นฐาน (ไม่มี domain object) เช่น OrderConfirmedIntegrationEvent(Guid OrderId, DateTimeOffset ConfirmedAt) ต่างจาก Domain Event ที่อยู่ในโพรเซสเดียวArchitecture ไม่ใช่ domain event version ไกลบ้าน มันเป็นของคนละชนิดที่ทำคนละงาน:
| Domain Event | Integration Event | |
|---|---|---|
| ขอบเขต | ภายใน context เดียว | ข้าม context |
| ทำงาน | in-process (โพรเซสเดียว) | ข้าม module วันนี้ยังในโพรเซส วันหน้าข้ามเครื่อง |
| แบก | domain object ได้ตรงๆ (OrderId, Money) | เฉพาะ id + ค่าพื้นฐาน (Guid, DateTimeOffset) |
| เปลี่ยนบ่อยไหม | เป็นเรื่องภายใน แก้ได้อิสระ | เป็น contract สาธารณะ ต้อง stable |
| ตัวอย่าง | OrderConfirmed(OrderId, ...) | OrderConfirmedIntegrationEvent(Guid OrderId, ...) |
พูดสั้นๆ: domain event คือ “มีอะไรเกิดขึ้นในบ้านฉัน” ส่วน integration event คือ “ประกาศให้บ้านอื่นรู้ว่าเกิดอะไรขึ้น” การประกาศต้องใช้ภาษากลางที่ทุกบ้านอ่านออกโดยไม่ต้องรู้จักเฟอร์นิเจอร์ในบ้านฉัน — ภาษากลางนั้นคือ id กับค่าพื้นฐานที่ serialize ได้ ไม่ใช่ aggregate ของฉัน
❌ กับดัก: ยัด aggregate ข้ามขอบเขต
หัวข้อที่มีชื่อว่า “❌ กับดัก: ยัด aggregate ข้ามขอบเขต”วิธีที่ดูเร็วที่สุดคือทำ integration event ให้แบก Order ทั้งก้อนไปเลย ผู้รับจะได้ข้อมูลครบ ไม่ต้องถามซ้ำ — แต่มันคือการลาก AggregateAggregateกลุ่มของ Entity และ Value Object ที่ถือเป็นหนึ่งหน่วยความสอดคล้อง มี root เดียวเป็นประตูเข้า เช่น Order ใน Ordering, Ticket ใน Kitchen, Assignment ใน Delivery — แต่ละ context มี aggregate ของตัวเองที่รักษากฎของตัวเอง ไม่ share aggregate ข้าม contextTactical Design ของ Ordering ข้ามเส้นขอบไปวางในมือ Kitchen ตรงๆ:
// ❌ สัญญาที่ยัด aggregate ทั้งก้อนข้ามขอบเขต contextpublic sealed record OrderConfirmedIntegrationEvent(Order Order); // Order คือ aggregate ของ Ordering!
// Kitchen ต้อง reference ชนิด Order/OrderLine/Money ของ Ordering ถึงจะ compile handler นี้ผ่านpublic sealed class KitchenOnOrderConfirmed{ public Task HandleAsync(OrderConfirmedIntegrationEvent e, CancellationToken ct) { Order order = e.Order; // เอื้อมเข้าไปใน model ของ Ordering ตรง ๆ foreach (var line in order.Items) { /* ... */ } // ผูกกับ OrderLine ของ Ordering Money total = order.Total; // Kitchen ไม่ได้แคร์ราคาเลยด้วยซ้ำ return Task.CompletedTask; }}code นี้พังในหลายชั้นพร้อมกัน หนึ่ง — Kitchen ต้อง reference assembly ของ Ordering เพื่ออ่าน Order, OrderLine, Money เส้นขอบของ modular monolith หลุดทันที สอง — Order มี private setter และ method พฤติกรรมที่ serialize ข้ามสายจริงไม่ได้ วันที่เราแตก Kitchen เป็น service คนละเครื่อง สัญญาแบบนี้ส่งไม่ได้เลย สาม — วันไหน Ordering แก้ Order (เพิ่ม field, เปลี่ยน invariant) Kitchen พังตาม ทั้งที่ Kitchen ไม่ได้แคร์ราคาหรือรายการอาหารด้วยซ้ำ สี่ — Kitchen ได้จับ aggregate ที่มี method เปลี่ยนสถานะของ Ordering ติดมือมาด้วย ทั้งที่กฎของออเดอร์เป็นเรื่องที่ Ordering ต้องรักษาเองเท่านั้น
✅ สัญญาที่มีแต่ id กับค่าพื้นฐาน
หัวข้อที่มีชื่อว่า “✅ สัญญาที่มีแต่ id กับค่าพื้นฐาน”ทางที่ถูกคือให้ integration event เป็น Message ContractMessage Contractรูปแบบข้อมูล (schema) ของ integration event ที่ตกลงกันระหว่างผู้ส่งและผู้รับ ต้อง stable และมีแต่ id/ค่าพื้นฐาน เพราะเปลี่ยนแล้วกระทบทุก context ที่ subscribe อยู่ — ในทางปฏิบัติของ code คือ Published Language นั่นเองArchitecture ที่แบนราบ serialize ได้ มีแต่ id กับ primitive — ไม่มี domain object แม้แต่ตัวเดียว วางไว้ใน folder กลางที่ทั้งผู้ส่งและผู้รับอ้างได้โดยไม่ต้องรู้จัก model ภายในของกันและกัน:
// FoodOrdering.SharedContracts/Integration/IIntegrationEvent.cs — marker กลางpublic interface IIntegrationEvent { }
// สัญญาแต่ละใบมีแต่ Guid + ค่าพื้นฐาน อ่านได้โดยไม่ต้องรู้จัก Order/Ticket/Assignment ของใครpublic sealed record OrderConfirmedIntegrationEvent(Guid OrderId, DateTimeOffset ConfirmedAt) : IIntegrationEvent;public sealed record FoodReadyIntegrationEvent(Guid OrderId, Guid TicketId) : IIntegrationEvent;public sealed record OrderDeliveredIntegrationEvent(Guid OrderId, Guid AssignmentId) : IIntegrationEvent;เทียบกับ OrderConfirmed (domain event) จะเห็นความต่างชัดๆ ตรง OrderId: domain event ถือ OrderId ที่เป็น record value object ของ Ordering ส่วนสัญญานี้ถือ Guid ดิบ — เพราะ Guid เป็นภาษาที่ทุก context อ่านออกโดยไม่ต้อง reference อะไรของ Ordering เลย
ทำ event bus ด้วยมือ — ไม่มีเวทมนตร์
หัวข้อที่มีชื่อว่า “ทำ event bus ด้วยมือ — ไม่มีเวทมนตร์”สัญญามีแล้ว ต่อไปคือช่องทางส่ง เราไม่หยิบ MediatR หรือ message library มาใส่ตอนนี้ เพราะอยากให้เห็นว่ากลไกจริงๆ มันเล็กแค่ไหน — port 1 method กับ implementation ที่ fan-out ด้วย foreach:
// FoodOrdering.SharedContracts/Integration/IIntegrationEventBus.cspublic interface IIntegrationEventBus{ Task PublishAsync(IIntegrationEvent e, CancellationToken ct);}
// FoodOrdering.Host/Integration/InProcessIntegrationEventBus.cs — ยังรันในโพรเซสเดียวpublic sealed class InProcessIntegrationEventBus : IIntegrationEventBus{ // แต่ละ context ลงทะเบียน handler ของตัวเองไว้ตอน startup — bus ไม่รู้จักใครเป็นพิเศษ private readonly IReadOnlyList<Func<IIntegrationEvent, CancellationToken, Task>> _subscribers;
public InProcessIntegrationEventBus(IEnumerable<Func<IIntegrationEvent, CancellationToken, Task>> subscribers) => _subscribers = subscribers.ToList();
public async Task PublishAsync(IIntegrationEvent e, CancellationToken ct) { // fan-out: ยิงสัญญาใบเดียวให้ subscriber ทุกตัวในโพรเซสเดียวกัน foreach (var notify in _subscribers) await notify(e, ct); }}InProcessIntegrationEventBus ไม่รู้จัก Kitchen หรือ Delivery เลย มันแค่ถือ list ของ delegate แล้วยิงต่อ ผู้ส่ง (Ordering) ก็ไม่รู้จักผู้รับเช่นกัน — รู้จักแค่ IIntegrationEventBus นี่คือหัวใจของการ decouple: สองฝั่งรู้จักกันผ่านสัญญากลางเท่านั้น บทหลังๆ เราจะสลับ implementation ตัวนี้เป็น transactional outbox แล้วเป็น message broker จริง โดยไม่ต้องแก้ code ฝั่ง Ordering หรือ Kitchen เลยสักบรรทัด เพราะทั้งคู่เห็นแค่ port
Kitchen subscribe แล้วสร้าง Ticket ใน model ของตัวเอง
หัวข้อที่มีชื่อว่า “Kitchen subscribe แล้วสร้าง Ticket ใน model ของตัวเอง”ฝั่ง Kitchen มี model ของตัวเองล้วนๆ มันมองออเดอร์เป็น “ตั๋วคิวทำอาหาร” ไม่ใช่ตะกร้าราคา — เก็บแค่ Guid OrderId ไว้อ้างกลับ ที่เหลือเป็นเรื่องของครัวทั้งหมด:
// FoodOrdering.Kitchen/Tickets/Ticket.cspublic sealed record TicketId(Guid Value);
public enum TicketStatus { Pending, Accepted, Preparing, Ready }
public sealed class Ticket{ public TicketId Id { get; } public Guid OrderId { get; } // เก็บแค่ id ของออเดอร์ ไม่ใช่ตัว Order public TicketStatus Status { get; private set; }
private Ticket(TicketId id, Guid orderId) { Id = id; OrderId = orderId; Status = TicketStatus.Pending; }
public static Ticket Open(Guid orderId) => new(new TicketId(Guid.NewGuid()), orderId);
public void Accept() => Status = TicketStatus.Accepted; public void MarkReady() => Status = TicketStatus.Ready;}แล้ว Kitchen ลงทะเบียน subscriber ที่แปลง OrderConfirmedIntegrationEvent (มีแค่ Guid) ให้กลายเป็น Ticket ใบใหม่ใน model ของมัน:
// FoodOrdering.Kitchen/Integration/KitchenIntegrationHandlers.cspublic sealed class KitchenIntegrationHandlers{ private readonly ITicketRepository _tickets; // repository port ของ Kitchen เอง public KitchenIntegrationHandlers(ITicketRepository tickets) => _tickets = tickets;
public async Task HandleAsync(IIntegrationEvent e, CancellationToken ct) { // Kitchen สนใจ event เดียว: ออเดอร์ถูกยืนยัน = ได้เวลาลงมือทำ if (e is not OrderConfirmedIntegrationEvent confirmed) return;
// สร้าง Ticket จาก Guid ล้วน ๆ ที่สัญญาส่งมา ไม่ต้องรู้ราคาหรือรายการอาหาร var ticket = Ticket.Open(confirmed.OrderId); await _tickets.SaveAsync(ticket, ct); }}Delivery ก็ subscribe สัญญาใบเดียวกันนี้ แต่แปลเป็น model คนละแบบ — มันมองออเดอร์เป็น “งานรับ-ส่ง” ที่ยังไม่มีไรเดอร์:
// FoodOrdering.Delivery/Assignments/Assignment.cspublic sealed record AssignmentId(Guid Value);
public enum AssignmentStatus { Unassigned, Assigned, PickedUp, Delivered }
public sealed class Assignment{ public AssignmentId Id { get; } public Guid OrderId { get; } public Guid? RiderId { get; private set; } public AssignmentStatus Status { get; private set; }
private Assignment(AssignmentId id, Guid orderId) { Id = id; OrderId = orderId; Status = AssignmentStatus.Unassigned; }
public static Assignment Create(Guid orderId) => new(new AssignmentId(Guid.NewGuid()), orderId);
public void AssignRider(Guid riderId) { RiderId = riderId; Status = AssignmentStatus.Assigned; } public void MarkDelivered() => Status = AssignmentStatus.Delivered;}
// FoodOrdering.Delivery/Integration/DeliveryIntegrationHandlers.cspublic sealed class DeliveryIntegrationHandlers{ private readonly IAssignmentRepository _assignments; public DeliveryIntegrationHandlers(IAssignmentRepository assignments) => _assignments = assignments;
public async Task HandleAsync(IIntegrationEvent e, CancellationToken ct) { if (e is not OrderConfirmedIntegrationEvent confirmed) return; var assignment = Assignment.Create(confirmed.OrderId); // ยังไม่มีไรเดอร์ = Unassigned await _assignments.SaveAsync(assignment, ct); }}สัญญาใบเดียว (Guid OrderId, DateTimeOffset ConfirmedAt) ถูกอ่านสองมุม: Kitchen เห็นเป็น Ticket สถานะ Pending, Delivery เห็นเป็น Assignment สถานะ Unassigned ไม่มีใครถือ model ของอีกฝ่าย และไม่มีใครต้องขออนุญาต Ordering เพื่อรักษากฎของตัวเอง
ภาพรวม: publish หนึ่งครั้ง fan-out เข้า2 subscriber
หัวข้อที่มีชื่อว่า “ภาพรวม: publish หนึ่งครั้ง fan-out เข้า2 subscriber”flowchart TD
subgraph ORD["Ordering context"]
CONF["Order.Confirm()<br/>raise OrderConfirmed (domain event)"]
PUB["publish OrderConfirmedIntegrationEvent<br/>Guid OrderId, DateTimeOffset ConfirmedAt"]
CONF --> PUB
end
BUS{{"IIntegrationEventBus<br/>in-process fan-out"}}
PUB ==>|PublishAsync| BUS
subgraph KIT["Kitchen context"]
KH["subscriber ของ Kitchen"]
TICK["Ticket.Open orderId<br/>Status = Pending"]
KH --> TICK
end
subgraph DEL["Delivery context"]
DH["subscriber ของ Delivery"]
ASG["Assignment.Create orderId<br/>Status = Unassigned"]
DH --> ASG
end
BUS -->|OrderConfirmedIntegrationEvent| KH
BUS -->|OrderConfirmedIntegrationEvent| DH
classDef ord fill:#c7d2fe,stroke:#3730a3,color:#1a1a1f;
classDef bus fill:#fde68a,stroke:#92400e,color:#1a1a1f;
classDef sub fill:#bbf7d0,stroke:#166534,color:#1a1a1f;
class CONF,PUB ord;
class BUS bus;
class KH,TICK,DH,ASG sub;
คำบรรยายภาพ: ภายใน Ordering (ฟ้า) Order.Confirm() raise domain event OrderConfirmed ตามปกติ แต่ที่ขอบ context ชั้น application แปลมันเป็น integration event ที่มีแต่ Guid แล้ว PublishAsync เข้า bus (เหลือง) bus fan-out สัญญาใบเดียวกันให้ subscriber สองตัว (เขียว): Kitchen สร้าง Ticket สถานะ Pending, Delivery สร้าง Assignment สถานะ Unassigned ทุกอย่างยังอยู่ในโพรเซสเดียว แต่ข้ามขอบเขต module ด้วยสัญญากลาง ไม่ใช่ด้วย domain type
จุดแปลง: domain event กลายเป็น integration event ตรงขอบ context
หัวข้อที่มีชื่อว่า “จุดแปลง: domain event กลายเป็น integration event ตรงขอบ context”คำถามสุดท้าย — publish เกิดตรงไหน? ตรงชั้น application ของ Ordering หลัง Confirm() และบันทึกเสร็จ นี่คือจุดเดียวที่ domain type ถูกแปลงเป็น Guid ก่อนออกนอกบ้าน:
public async Task Handle(ConfirmOrderCommand cmd, CancellationToken ct){ var order = await _orders.FindAsync(new OrderId(cmd.OrderId), ct) ?? throw new KeyNotFoundException();
order.Confirm(); // raise OrderConfirmed (domain event) เข้า list ภายใน await _orders.SaveAsync(order, ct);
// แปลง domain event -> integration event: unwrap OrderId เป็น Guid ล้วนตรงขอบ context await _bus.PublishAsync( new OrderConfirmedIntegrationEvent(order.Id.Value, DateTimeOffset.UtcNow), ct);}order.Id.Value คือหัวใจของบทนี้ทั้งบทในบรรทัดเดียว — คลาย OrderId (domain value object) เป็น Guid ดิบตรงเส้นขอบ ข้างในบ้านใช้ภาษา domain เต็มที่ ออกนอกบ้านเหลือแต่ id
สังเกตว่า SaveAsync กับ PublishAsync เป็นสองสเต็ปที่ยังไม่ atomic — ถ้า process ล่มคั่นกลาง อาจบันทึกออเดอร์สำเร็จแต่ event ไม่ออก นั่นคือ Dual-write problemDual-write Problemปัญหาที่เกิดเมื่อ code ต้องเขียนลงสองที่ให้สำเร็จพร้อมกัน (บันทึกลง DB + ส่ง event) แต่ทั้งสองไม่ได้อยู่ใน transaction เดียวกัน — ถ้าล่มระหว่างกลาง อาจบันทึกสำเร็จแต่ event หาย หรือ event ออกไปแต่ DB ไม่บันทึกArchitecture ที่เราจะแก้ด้วย Transactional OutboxTransactional Outboxpattern แก้ dual-write problem: เขียน event ลงตาราง outbox ใน transaction เดียวกับข้อมูลจริง แล้วให้ relay แยกต่างหากส่ง event ออกไปหลัง commit เท่านั้น — WolverineFx ทำสิ่งนี้ให้บน EF Core ผ่าน IDbContextOutbox<T>Architecture ในบท 6 บทนี้โฟกัสแค่ รูปร่างของสัญญา ให้ถูกก่อน เพราะสัญญาที่ id-only และ serializable คือเงื่อนไขที่ทำให้ outbox และ message broker ในบทหลังๆ ทำงานได้จริง ถ้าสัญญายังแบก aggregate อยู่ ไม่ว่าจะวาง outbox ดีแค่ไหนก็ส่งข้ามเครื่องไม่ได้
เจาะลึกแนวคิดในบทนี้ต่อได้ที่คลังอ้างอิง DevIQ:
- Domain Events — event ภายใน context เดียวที่บทนี้ยกมาเทียบกับ integration event
- Event Aggregator — pattern เบื้องหลัง bus ทำมือที่ fan-out event ใบเดียวให้หลาย subscriber
- Bounded Context — เส้นขอบที่ integration event ต้องข้าม และเป็นเหตุผลว่าทำไมห้ามแบก domain type ข้ามไป
- Domain Events ใน code (DDD-in-Code บทที่ 6) — ที่มาของ
OrderConfirmedที่บทนี้แปลงเป็น integration event
เช็กความเข้าใจ — บทที่ 4
ข้อ 1 / 3Domain event กับ integration event ต่างกันอย่างไร?