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

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

code เต็ม​ของ​คอร์ส​นี้​อยู่​ที่ repo kaen-food-ordering (กำลัง​จัด​ทำ) — บท​นี้​เพิ่ม folder SharedContracts/Integration (สัญญา​กลาง) กับ Host/Integration (bus ทำ​มือ) และ​ให้ Kitchen/Delivery แต่ละ module ลง​ทะเบียน handler ของ​ตัวเอง code ใน​บท​เป็น version ย่อ​ให้​เห็น​แนวคิด ตัว​จริง​ใน repo จะ​ครบ​กว่า​นี้

ใน 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 ได้ตรงๆ:

domain event เดิม​จาก DDD-in-Code บท 6 — อยู่​ใน Ordering เท่านั้น
// FoodOrdering.Ordering/Orders/OrderConfirmed.cs
public 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 — เท่านั้น​เส้น​ขอบ​ที่​เรา​อุตส่าห์​ลาก​ไว้​ก็​รั่ว​ทันที

นี่​คือ​เส้น​แบ่ง​ที่​ต้อง​จำ​ให้​ขึ้นใจ​ทั้ง​คอร์ส Integration EventIntegration Eventevent ที่​สื่อสาร 'ข้าม' Bounded Context เป็น contract แบบ serializable ที่​มี​แต่ id/ค่า​พื้นฐาน (ไม่มี domain object) เช่น OrderConfirmedIntegrationEvent(Guid OrderId, DateTimeOffset ConfirmedAt) ต่าง​จาก Domain Event ที่​อยู่​ใน​โพรเซส​เดียวArchitecture ไม่ใช่ domain event version ไกล​บ้าน มัน​เป็น​ของ​คนละ​ชนิด​ที่​ทำ​คนละ​งาน:

Domain EventIntegration 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 ของ​ฉัน

วิธี​ที่​ดู​เร็ว​ที่สุด​คือ​ทำ integration event ให้​แบก Order ทั้ง​ก้อน​ไป​เลย ผู้รับ​จะ​ได้​ข้อมูล​ครบ ไม่​ต้อง​ถาม​ซ้ำ — แต่​มัน​คือ​การ​ลาก AggregateAggregateกลุ่ม​ของ Entity และ Value Object ที่​ถือ​เป็น​หนึ่ง​หน่วย​ความ​สอดคล้อง มี root เดียว​เป็น​ประตู​เข้า เช่น Order ใน Ordering, Ticket ใน Kitchen, Assignment ใน Delivery — แต่ละ context มี aggregate ของ​ตัวเอง​ที่​รักษา​กฎ​ของ​ตัวเอง ไม่ share aggregate ข้าม contextTactical Design ของ Ordering ข้าม​เส้น​ขอบ​ไป​วาง​ใน​มือ Kitchen ตรงๆ:

❌ version ดิบ: integration event ที่​แบก domain Order ข้าม context
// ❌ สัญญาที่ยัด aggregate ทั้งก้อนข้ามขอบเขต context
public 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 ต้อง​รักษา​เอง​เท่านั้น

ทาง​ที่​ถูก​คือ​ให้ integration event เป็น Message ContractMessage Contractรูปแบบ​ข้อมูล (schema) ของ integration event ที่​ตกลง​กัน​ระหว่าง​ผู้​ส่ง​และ​ผู้รับ ต้อง stable และ​มี​แต่ id/ค่า​พื้นฐาน เพราะ​เปลี่ยน​แล้วกระทบ​ทุก context ที่ subscribe อยู่ — ใน​ทาง​ปฏิบัติ​ของ code คือ Published Language นั่นเองArchitecture ที่​แบน​ราบ serialize ได้ มี​แต่ id กับ primitive — ไม่มี domain object แม้แต่​ตัว​เดียว วาง​ไว้​ใน folder กลาง​ที่​ทั้ง​ผู้​ส่ง​และ​ผู้รับ​อ้าง​ได้​โดย​ไม่​ต้อง​รู้จัก model ภายใน​ของ​กัน​และ​กัน:

สัญญา​กลาง: serializable, id-only
// 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 เลย

สัญญา​มี​แล้ว ต่อ​ไป​คือ​ช่อง​ทาง​ส่ง เรา​ไม่​หยิบ MediatR หรือ message library มา​ใส่​ตอน​นี้ เพราะ​อยาก​ให้​เห็น​ว่ากลไกจริงๆ มัน​เล็ก​แค่​ไหน — port 1 method กับ implementation ที่ fan-out ด้วย foreach:

port + in-process fan-out ทำ​มือ​ล้วน ๆ
// FoodOrdering.SharedContracts/Integration/IIntegrationEventBus.cs
public 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 มี model ของ​ตัวเอง​ล้วนๆ มัน​มองออเดอร์​เป็น “ตั๋ว​คิว​ทำ​อาหาร” ไม่ใช่​ตะกร้า​ราคา — เก็บ​แค่ Guid OrderId ไว้​อ้าง​กลับ ที่​เหลือ​เป็น​เรื่อง​ของ​ครัว​ทั้งหมด:

model ของ Kitchen — ไม่รู้จัก Order ของ Ordering เลย
// FoodOrdering.Kitchen/Tickets/Ticket.cs
public 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 ของ​มัน:

subscriber ของ Kitchen — รับ Guid ล้วน สร้าง model ของ​ตัวเอง
// FoodOrdering.Kitchen/Integration/KitchenIntegrationHandlers.cs
public 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 คนละ​แบบ — มัน​มองออเดอร์​เป็น “งาน​รับ-ส่ง” ที่​ยัง​ไม่​มี​ไร​เด​อร์:

subscriber ของ Delivery — สัญญา​เดียวกัน คนละ model
// FoodOrdering.Delivery/Assignments/Assignment.cs
public 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.cs
public 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 เพื่อ​รักษา​กฎ​ของ​ตัวเอง

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

คำถาม​สุดท้าย — publish เกิด​ตรง​ไหน? ตรง​ชั้น application ของ Ordering หลัง Confirm() และ​บันทึก​เสร็จ นี่​คือ​จุด​เดียว​ที่ domain type ถูก​แปลง​เป็น Guid ก่อน​ออก​นอก​บ้าน:

ConfirmOrderHandler (ย่อ) — จุด​แปลง domain -> integration ตรง​ขอบ
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

เจาะ​ลึก​แนวคิด​ใน​บท​นี้​ต่อ​ได้ที่​คลัง​อ้างอิง 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 / 3

Domain event กับ integration event ต่างกันอย่างไร?