Event Sourcing
ระบบส่วนใหญ่ทุกวันนี้เก็บแค่ สถานะปัจจุบัน ของ Domain ModelDomain ModelModel ของ Domain ที่ฝังกฎและกระบวนการทางธุรกิจไว้ใน code จริง หัวใจของ DDD คือการพัฒนา domain model ที่ “เข้าใจธุรกิจอย่างลึกซึ้ง”Strategic Design เอาไว้ — ตารางหนึ่งแถวบอกว่า “ตอนนี้ยอดเงินเหลือเท่าไร” แต่ไม่เคยบอกว่า มันมาถึงสถานะนี้ได้อย่างไร Event SourcingEvent Sourcingเก็บทุกการเปลี่ยนสถานะเป็น event ที่เปลี่ยนไม่ได้ แล้วสร้างสถานะปัจจุบันด้วยการเล่น event ซ้ำ มัก “ต้องใช้คู่กับ CQRS” (Greg Young) มี event stream ต่อ1 AggregateArchitecture พลิกความคิดนี้กลับด้าน: แทนที่จะเก็บ snapshot ของสถานะ เราเก็บ ทุกเหตุการณ์ที่มีนัยสำคัญ เรียงตามเวลา แล้วคำนวณสถานะปัจจุบันด้วยการ “เล่นเหตุการณ์ซ้ำ” (replay)
บทนี้จะพาคุณเข้าใจตั้งแต่ปัญหาที่ event sourcing แก้ ไปจนถึงวิธีสร้าง AggregateAggregateกลุ่มของ Entity และ Value Object ที่ถูกมองเป็นหนึ่งหน่วยเดียวเพื่อรักษาความถูกต้องของข้อมูล มีขอบเขตชัดเจน และเป็นหน่วยของ transactionTactical Design แบบ event-oriented, สร้าง Event StoreEvent Storeพื้นที่จัดเก็บ event แบบ append-only ที่ใช้เป็น source of truth ของ aggregate รองรับการสร้าง stream, append event และโหลด stream กลับมา replay สร้างเองบน SQL/document DB หรือใช้เครื่องมือ purpose-built อย่าง Greg Young's Event Store ก็ได้Architecture ของตัวเอง, ใช้ Event Store สำเร็จรูปของ Greg Young และเหตุผลที่ event sourcing เข้ากันได้ดีอย่างน่าทึ่งกับ CQRSCQRSCommand Query Responsibility Segregation — แยก model สำหรับ “เขียน/แก้ไข” (command) ออกจาก model สำหรับ “อ่าน” (query) เหมาะกับ domain ซับซ้อน แต่เพิ่มความซับซ้อน ใช้อย่างระมัดระวังArchitecture — พร้อมข้อแลกเปลี่ยนที่ต้องชั่งน้ำหนักก่อนตัดสินใจใช้
ข้อจำกัดของการเก็บสถานะเป็น snapshot
หัวข้อที่มีชื่อว่า “ข้อจำกัดของการเก็บสถานะเป็น snapshot”ถ้าคุณเก็บแค่สถานะปัจจุบัน คุณจะไม่มีทาง “ย้อนดู” ว่าระบบมาถึงจุดนี้ได้อย่างไร และนั่นทำให้การวิเคราะห์กระบวนการธุรกิจ (business process) ในอดีตหรือการสืบหา bug แทบเป็นไปไม่ได้ หนังสือยกตัวอย่างผู้ให้บริการมือถือแบบ เติมเงิน (pay-as-you-go) ลูกค้าโทรเข้ามาร้องเรียนว่าเพิ่งเติมเงินไป แต่โทรไม่กี่นาทีแล้วยอดกลับเป็นศูนย์
เมื่อเจ้าหน้าที่เปิดดูฐานข้อมูล ทั้งหมดที่เห็นคือ:
| Customer ID | Allowance (นาที) |
|---|---|
| 123456789 | 0 |
เจ้าหน้าที่ทำได้แค่ “เห็นด้วย” ว่ายอดเป็นศูนย์จริง — แต่พิสูจน์ไม่ได้เลยว่าเกิดอะไรขึ้น เพราะฐานข้อมูลไม่ได้เก็บประวัติ ลูกค้าต้องเอาใบเสร็จและ log โทรศัพท์มาเองเพื่อยืนยันว่าควรจะเหลือเครดิต ทั้งที่ความจริงคือ ระบบมี bug ที่หักนาทีเกิน แต่ธุรกิจพิสูจน์ไม่ได้เพราะข้อมูลบอกแค่ “ตอนนี้” ไม่ได้บอก “ที่ผ่านมา”
นอกจากจะ debug ยากแล้ว การเก็บแค่สถานะปัจจุบันยังทำให้ธุรกิจ เสียโอกาส ในการวิเคราะห์กระบวนการธุรกิจ (business process) ของลูกค้า — จับคู่กับแคมเปญการตลาดไม่ได้ มองหา pattern เพื่อสร้างรายได้ใหม่ก็ไม่ได้ เพราะประวัติทั้งหมดถูกเขียนทับหายไปทุกครั้งที่อัปเดต
ได้เปรียบการแข่งขันด้วยการเก็บสถานะเป็น “สายของ event”
หัวข้อที่มีชื่อว่า “ได้เปรียบการแข่งขันด้วยการเก็บสถานะเป็น “สายของ event””ทางออกคือเก็บทุก event ที่สำคัญพร้อม timestamp เรียงตามลำดับเวลา แล้ว คำนวณสถานะปัจจุบันด้วยการ replay เมื่อทำเช่นนี้ คุณจะทำได้มากกว่าแค่หาสถานะปัจจุบัน — คุณสามารถ replay เฉพาะช่วงย่อยของ event เพื่อหาสถานะ ณ จุดใดก็ได้ในอดีต ความสามารถนี้เรียกว่า Temporal QueryTemporal Queryการ query หาสถานะของ domain model ณ จุดใด ๆ ในอดีต ด้วยการ replay เฉพาะช่วงย่อยของ event จนถึงจุดนั้น — เหมือน “กรอเวลา” ย้อนกลับ เป็นความสามารถเด่นของ event sourcing ที่ระบบเก็บแค่ snapshot ทำไม่ได้Architecture
Temporal query — เหมือนเดินทางย้อนเวลา
หัวข้อที่มีชื่อว่า “Temporal query — เหมือนเดินทางย้อนเวลา”Temporal QueryTemporal Queryการ query หาสถานะของ domain model ณ จุดใด ๆ ในอดีต ด้วยการ replay เฉพาะช่วงย่อยของ event จนถึงจุดนั้น — เหมือน “กรอเวลา” ย้อนกลับ เป็นความสามารถเด่นของ event sourcing ที่ระบบเก็บแค่ snapshot ทำไม่ได้Architecture ก็เหมือนการ “กรอเทปย้อนกลับ” ของ domain model ไปยังจุดใดจุดหนึ่งในอดีต ในกรณีร้องเรียนข้างต้น แผนก Customer Service สามารถ replay event ทีละช่วงจนเจอ event ที่หักนาทีผิดพลาด แล้วชี้ bug ได้อย่างแม่นยำ

แนวคิดนี้ไม่ได้แปลกใหม่ — คุณเจอมันมาตลอดชีวิตนักพัฒนา ทั้งบัญชีธนาคาร (statement คือสาย event) และ version control อย่าง Git หรือ Subversion ที่ย้อนสถานะไปจุดไหนก็ได้ด้วยการ replay commit ก่อนหน้าทั้งหมด
Projection — รวม event จากหลายสาย
หัวข้อที่มีชื่อว่า “Projection — รวม event จากหลายสาย”ข้อจำกัดของ event store บางตัวคือการ query แบบ ad hoc ข้ามหลายสายพร้อมกัน (สิ่งที่ใน SQL ทำได้ง่ายด้วย JOIN) ทางแก้คือ ProjectionProjectionfunction ที่รับ event stream หลายสายเป็น input แล้ว map/คัดเลือก event ออกเป็น stream ใหม่หนึ่งสายหรือมากกว่า ใช้รวมข้อมูลข้ามสาย (คล้าย JOIN ใน SQL) และเป็นพื้นฐานในการสร้าง view cache สำหรับ CQRSArchitecture — function ที่รับ event stream หลายสายเป็น input แล้ว map ออกเป็น stream ใหม่หนึ่งสายหรือมากกว่า

ด้วย projection ผู้ให้บริการมือถือถามคำถามที่เคยตอบไม่ได้ได้ทันที เช่น “กลุ่มอายุนี้ใช้กี่นาทีรวมในวันที่มี event กีฬาใหญ่” หรือ “โปรโมชันพิเศษทำให้การใช้งานของกลุ่มเป้าหมายเพิ่มหรือลด” — คำตอบที่ขับเคลื่อนการตัดสินใจทางการตลาดและการพัฒนาสินค้าด้วยข้อมูลเชิงปริมาณ
Snapshot — ทางลัดเพื่อประสิทธิภาพ
หัวข้อที่มีชื่อว่า “Snapshot — ทางลัดเพื่อประสิทธิภาพ”ผลข้างเคียงของการเก็บสถานะเป็น event คือ stream อาจ ยาวขึ้นเรื่อยๆ จนเวลาที่ใช้ replay พุ่งสูง ทางแก้คือ SnapshotSnapshotสถานะ ณ จุดกลางทางของ event stream ที่เทียบเท่ากับการ replay event ทั้งหมดก่อนหน้า ใช้เป็นทางลัดด้านประสิทธิภาพ เพื่อให้โหลด aggregate ได้โดย replay เฉพาะ event ที่เกิดหลัง snapshot ล่าสุดArchitecture — สถานะ ณ จุดกลางทางที่เทียบเท่ากับการ replay event ทั้งหมดก่อนหน้านั้น

อย่าเก็บแค่ “ตอนนี้เป็นอย่างไร” แต่จงเก็บ “มันเป็นมาอย่างไร” — บันทึกทุก event ที่เปลี่ยนสถานะไว้เป็นข้อมูลที่เปลี่ยนแปลงไม่ได้ (immutable) แล้วสร้างสถานะปัจจุบันขึ้นใหม่ด้วยการ replay สิ่งที่ได้กลับมาคือ temporal query, projection สำหรับการวิเคราะห์ และพลังการ debug ที่ระบบเก็บ snapshot ไม่มีวันให้ได้
Aggregate แบบ event-sourced
หัวข้อที่มีชื่อว่า “Aggregate แบบ event-sourced”เพื่อให้เข้ากับ event sourcing AggregateAggregateกลุ่มของ Entity และ Value Object ที่ถูกมองเป็นหนึ่งหน่วยเดียวเพื่อรักษาความถูกต้องของข้อมูล มีขอบเขตชัดเจน และเป็นหน่วยของ transactionTactical Design ต้อง คิดแบบ event คือต้องคำนวณสถานะของตัวเองได้จากการ apply ลำดับ event ผลพลอยได้ที่ผู้ปฏิบัติ DDD ชอบมากคือ aggregate กลายเป็น behavior-oriented มากขึ้น สื่อเจตนาทางธุรกิจชัดเจนขึ้น และยังผูกติดกับ persistence แบบหลวมๆ ลง
มี 3 รายละเอียดสำคัญที่ aggregate แบบนี้ต้องมี: (1) apply Domain EventDomain Eventสิ่งที่ “เกิดขึ้นแล้ว” ใน domain ซึ่งส่วนอื่นสนใจ แทนด้วยอ็อบเจ็กต์ที่เปลี่ยนแปลงไม่ได้ ตั้งชื่อเป็นอดีต เช่น CargoWasRouted ใช้สื่อสารข้าม AggregateTactical Design แล้วอัปเดตสถานะตามกฎธุรกิจ (2) เก็บรายการ event ที่ยังไม่ commit เพื่อรอบันทึกลง Event Store และ (3) จด version ของตัวเองไว้ พร้อมความสามารถสร้าง/ฟื้นจาก snapshot
public abstract class EventSourcedAggregate : Entity{ // event ที่ยังไม่ถูกบันทึก รอ persist ลง Event Store public List<DomainEvent> Changes { get; private set; } // เลข version = ลำดับของ event ตัวล่าสุดที่ถูก apply public int Version { get; protected set; }
public EventSourcedAggregate() { Changes = new List<DomainEvent>(); }
// aggregate ลูกต้อง apply event แล้วเปลี่ยนสถานะตามกฎธุรกิจ public abstract void Apply(DomainEvent change);}เขียนกฎธุรกิจแบบเกือบ “ประกาศ”
หัวข้อที่มีชื่อว่า “เขียนกฎธุรกิจแบบเกือบ “ประกาศ””หัวใจของ aggregate ลูกคือ method When() แต่ละ overload อ่านได้เกือบเหมือนประโยคภาษาคน: “เมื่อ event นี้เกิด ให้ เปลี่ยนสถานะแบบนี้” ส่วน Apply() แค่ส่ง event ไปยัง When() ที่ตรงชนิดแล้วเลื่อน version
public override void Apply(DomainEvent @event){ When((dynamic)@event); // เลือก When() ที่ตรงชนิด event Version++;}
// เมื่อมีการเติมเครดิต -> บวกเครดิตprivate void When(CreditAdded e) => _credit = _credit.Add(e.Credit);
// เมื่อมีการคิดค่าโทร -> หักเครดิต และหักนาทีฟรีถ้ามีprivate void When(PhoneCallCharged e){ _credit = _credit.Subtract(e.CostOfCall); _freeCallAllowance?.Subtract(e.CoveredByAllowance);}API ที่ยังพูดภาษา domain
หัวข้อที่มีชื่อว่า “API ที่ยังพูดภาษา domain”Apply() เป็นเพียงรายละเอียดภายใน (เหตุผลที่มันเป็น public คือเพื่อให้ repository นำมา “rehydrate” ได้) จากภายนอก service ยังคุยกับ aggregate ผ่าน API ที่สื่อภาษาธุรกิจ เช่น TopUp() โดย ไม่รู้เลย ว่าเบื้องหลังใช้ event sourcing เคล็ดลับคือ method Causes() ที่ทั้งเก็บ event ลง Changes และ feed เข้า Apply() ในคราวเดียว
public void TopUp(Money credit, IClock clock){ if (_inclusiveMinutesOffer.IsSatisfiedBy(credit)) Causes(new CreditSatisfiesFreeCallAllowanceOffer( this.Id, clock.Time(), _inclusiveMinutesOffer.FreeMinutes)); Causes(new CreditAdded(this.Id, credit));}
private void Causes(DomainEvent @event){ Changes.Add(@event); // เข้าคิวรอ persist Apply(@event); // เปลี่ยนสถานะทันที}ลองนึกถึง ShoppingCart แบบ event-sourced แทนที่จะเก็บ “ตอนนี้ในตะกร้ามีอะไรบ้าง” เราเก็บสายของ event: ItemAdded, ItemRemoved, QuantityChanged, CouponApplied สถานะตะกร้าปัจจุบันคือผลของการ replay event เหล่านี้ทั้งหมด ข้อดีคือเราตอบได้ทันทีว่า “ลูกค้าหยิบสินค้าใส่แล้วเอาออกกี่ครั้ง” — ข้อมูลกระบวนการธุรกิจ (business process) ที่ระบบเก็บแค่ snapshot ทิ้งไปจนหมด
Persist และ rehydrate ผ่าน Repository
หัวข้อที่มีชื่อว่า “Persist และ rehydrate ผ่าน Repository”การ persist aggregate แบบ event-sourced คือแค่เก็บ Changes ลง event store ส่วนการโหลด (เรียกว่า rehydrating) คือดึง event ทั้งหมดกลับมา replay โดยเลือกใช้ snapshot เป็นทางลัดได้ RepositoryRepositoryตัวที่ทำให้เราเข้าถึง Aggregate ราวกับเป็น collection ในหน่วยความจำ ซ่อนรายละเอียดฐานข้อมูล มี1 repository ต่อ1 Aggregate RootTactical Design จึงมี 3 งานหลัก: สร้าง stream, append ลง stream และโหลด stream
public PayAsYouGoAccount FindBy(Guid id){ var streamName = StreamNameFor(id); var stream = _eventStore.GetStream(streamName, 0, int.MaxValue); var account = new PayAsYouGoAccount(); foreach (var @event in stream) account.Apply(@event); // ที่เดียวที่ service ภายนอกควรเรียก Apply() return account;}
// 1 stream ต่อ1 aggregate: {AggregateType}-{AggregateId}private string StreamNameFor(Guid id) => $"{typeof(PayAsYouGoAccount).Name}-{id}";การตั้งชื่อ stream เป็น {AggregateType}-{AggregateId} ทำให้ตอนโหลด aggregate ตัวหนึ่ง เรา replay เฉพาะ event ของมันเท่านั้น — ไม่ใช่ของทุก aggregate ชนิดเดียวกัน นี่คือกุญแจสำคัญของ performance และ scalability และยังทำให้ระบบ snapshot ง่ายขึ้นมาก
Optimistic concurrency
หัวข้อที่มีชื่อว่า “Optimistic concurrency”เมื่อหลายผู้ใช้แก้ aggregate เดียวกันพร้อมกัน บางครั้งเราต้องการให้การบันทึก ล้มเหลว หากมีคนอื่นเปลี่ยนข้อมูลไปก่อนแล้ว นี่คือ Optimistic ConcurrencyOptimistic Concurrencyกลไกจัดการการแก้ข้อมูลพร้อมกันโดยไม่ล็อก: จด version ตั้งต้นตอนโหลด aggregate แล้วก่อน append event ใหม่ ตรวจว่า version ล่าสุดใน stream ยังตรงกันอยู่ ถ้าไม่ตรงแปลว่ามีคนแก้ไปก่อน ให้ยกเลิก transactionArchitecture ทำได้สองขั้น: (1) ตอนโหลด aggregate ให้จำ version ตั้งต้นไว้ (InitialVersion) และ (2) ก่อน append event ใหม่ ให้ตรวจว่า version ล่าสุดใน stream ยังตรงกับ version ตั้งต้นนั้นอยู่ ถ้าไม่ตรง แสดงว่ามีคนแทรกการเปลี่ยนแปลงเข้ามาก่อนแล้ว จึงโยน exception เพื่อยกเลิกการบันทึก
สร้าง Event Store ของตัวเอง
หัวข้อที่มีชื่อว่า “สร้าง Event Store ของตัวเอง”คุณจะใช้ event store สำเร็จรูปหรือสร้างเองก็ได้ — การสร้างเองจริงๆ แล้วคือการเอาเครื่องมือที่มีอยู่ (เช่น document database หรือ SQL) มาใช้ในมุมใหม่ และเป็นวิธีที่ดีในการเข้าใจแนวคิดแกนกลาง interface ที่ repository ใช้คือ IEventStore:
public interface IEventStore{ void CreateNewStream(string streamName, IEnumerable<DomainEvent> events); void AppendEventsToStream(string streamName, IEnumerable<DomainEvent> events, int? expectedVersion); IEnumerable<DomainEvent> GetStream(string streamName, int from, int to); void AddSnapshot<T>(string streamName, T snapshot); T GetLatestSnapshot<T>(string streamName) where T : class;}หนังสือสาธิตการ implement บน RavenDB (document DB) โดยใช้ 3 document type: EventStream (เก็บ meta เช่น Id และ Version), EventWrapper (ห่อ domain event หนึ่งตัวพร้อม meta บอกว่าเป็นของ stream ไหน ลำดับที่เท่าไร) และ SnapshotWrapper แต่ละ event เก็บเป็น document แยก แล้วใช้ EventStreamId ผูกกลับเข้า stream — “stream” จึงเป็นแนวคิดเชิงตรรกะ ไม่ใช่ตารางจริง
อีกทางคือใช้ SQL Server ซึ่งคำแนะนำคือ ออกแบบ schema ให้ generic และ domain-agnostic เก็บ event เป็น blob ของ JSON/XML (ดู project อย่าง Ncqrs และ NEventStore เป็นตัวอย่างอ้างอิง):
Table Events: Id, TimeStamp, Name, Version, EventSourceId, Sequence, Data (nvarchar max)Table EventSources: Id, Type, VersionTable Snapshots: EventSourceId, Version, TimeStamp, Type, DataIEventStore ให้ความยืดหยุ่นในการสลับเทคโนโลยี แต่ขณะเดียวกันก็ บีบ ให้คุณยึดตาม interface ที่ตายตัว ซึ่งอาจปิดกั้น feature ขั้นสูงของเทคโนโลยีแต่ละตัว abstraction นี้ยัง “รั่ว” (leaky) เล็กน้อยด้วย เช่น กติกาการตั้งชื่อ stream ของ Event Store ที่อิง hyphen ตัวสุดท้าย — ไม่มีอะไรรับประกันว่าการสลับ provider จะราบรื่นเต็มร้อย
ในยุคแรกๆ ของ event sourcing ไม่มีเครื่องมือเชิงพาณิชย์ นักพัฒนาต้องสร้างเองบน SQL/document DB ซึ่ง ทำได้จริง แต่พอขยับไปสู่เรื่องขั้นสูงอย่าง projection, temporal query ที่ซับซ้อน หรือ scalability คุณอาจเสียเวลาเบนออกจากการสร้างคุณค่าทางธุรกิจ จึงน่าพิจารณาเครื่องมือที่ออกแบบมาเพื่องานนี้โดยเฉพาะ (purpose-built)
ใช้ Event Store สำเร็จรูปของ Greg Young
หัวข้อที่มีชื่อว่า “ใช้ Event Store สำเร็จรูปของ Greg Young”การเลือก event store สำเร็จรูปอย่าง Greg Young’s Event Store ลดงานตั้งต้นและให้ feature ขั้นสูงมาในกล่อง ทั้ง projection และ multinode clustering สิ่งที่เห็นได้ชัดคือมันรองรับแนวคิด “stream” แบบ native (ไม่ใช่จำลองด้วย query ซับซ้อนเหมือน RavenDB) ดังนั้นทั้งการ query event และ optimistic concurrency จึงเขียนสั้นกว่ามาก — แค่ส่งชื่อ stream กับ version ที่คาดหวังเข้าไป ตัว Event Store จัดการตรวจ concurrency ให้เอง
Greg Young เลือกใช้ JavaScript เป็นเครื่องมือเขียน query และ projection ตัวอย่าง temporal query หาว่าลูกค้าหนึ่งคนใช้ไปกี่นาทีในวันที่กำหนด:
fromStream('PayAsYouGoAccount-5b3415c8...') .when({ $init: function (s, e) { return { minutes: 0 }; }, // สถานะตั้งต้น "PhoneCallCharged": function (s, e) { if (e.data.PhoneCall.StartTime.substring(0, 10) === "2014-06-04") s.minutes += e.data.PhoneCall.Minutes.Number; } });ผลของ query คือ object JavaScript ที่เรียกว่า state ซึ่งถูกส่งเข้า when() พร้อมทุก event แล้วอัปเดตไปเรื่อยๆ ตามตรรกะที่เราเขียน หากต้องการรวมข้ามหลายสาย (คล้าย JOIN) ก็เปลี่ยนจาก fromStream() เป็น fromCategory('PayAsYouGoAccount') ซึ่งรวมทุก stream ที่ชื่อขึ้นต้นเหมือนกัน
Projection จริงๆ คือการสร้าง stream ใหม่
หัวข้อที่มีชื่อว่า “Projection จริงๆ คือการสร้าง stream ใหม่”บางครั้งเราไม่ได้แค่อยากคำนวณ state แต่อยาก หยิบ event บางส่วนมาสร้าง stream ใหม่ ในระบบที่มี event เป็นพันล้านตัว การ query ทุกตัวย่อมไม่คุ้ม projection แก้ปัญหานี้ด้วยการคัดเฉพาะ event ที่สนใจไปไว้ใน stream ใหม่ แล้วค่อย query stream นั้น เครื่องมือหลักคือ linkTo():
fromCategory('PayAsYouGoAccount') .when({ "CreditAdded": function (s, event) { linkTo('AllTopUps', event); // คัด CreditAdded ทุกตัวไปไว้ใน stream เดียว } });projection ตั้งเป็น One-Time (รันครั้งเดียว) หรือ Continuous (ใช้กับ event ใหม่แบบ near-real-time) ก็ได้ — แบบ continuous นี่เองที่เป็นพื้นฐานของ CQRS
CQRS ผสานกับ Event Sourcing
หัวข้อที่มีชื่อว่า “CQRS ผสานกับ Event Sourcing”ปัญหาเกิดเมื่อหลายหน้าเว็บต่างรัน query ของตัวเองใส่ event stream เดียวกัน เมื่อ load เพิ่มขึ้น stream เดียวนั้นรับภาระหนัก จนหน้าที่ไม่สำคัญอาจฉุดประสิทธิภาพของหน้าที่สำคัญ

ทางออกคือ CQRSCQRSCommand Query Responsibility Segregation — แยก model สำหรับ “เขียน/แก้ไข” (command) ออกจาก model สำหรับ “อ่าน” (query) เหมาะกับ domain ซับซ้อน แต่เพิ่มความซับซ้อน ใช้อย่างระมัดระวังArchitecture: ฝั่ง command (เขียน) ไหลผ่าน domain model ลง event stream ส่วนฝั่ง query (อ่าน) ยิงใส่ Materialized ViewMaterialized Viewมุมมองข้อมูลที่คำนวณและ denormalize ไว้ล่วงหน้า (เรียกอีกชื่อว่า view cache) สำหรับฝั่งอ่านของ CQRS สร้างจาก event stream ด้วย projection เพื่อให้ query เร็วและลดภาระบน event stream กลางArchitecture (หรือ view cache) ที่คำนวณไว้ล่วงหน้าสำหรับแต่ละ use case โดยเฉพาะ การแย่งทรัพยากรบน stream เดียวจึงลดลงมาก

วิธีสร้าง view cache เหล่านี้ก็คือ projection ที่เพิ่งเรียนไปนั่นเอง ถ้าใช้เครื่องมือ purpose-built คุณมีความสามารถนี้พร้อมอยู่แล้ว
ลองนึกถึง การติดตามสินค้าTracking IDรหัสติดตามที่ไม่ซ้ำของ Cargo ในตัวอย่างการขนส่ง ใช้เป็น identity ของ Cargo ตั้งแต่ตอนจองจนส่งมอบTactical Design: ฝั่งเขียนบันทึก Handling EventHandling Eventการบันทึกการจัดการสินค้าจริง (LOAD, UNLOAD, RECEIVE, CLAIM) เป็น Aggregate “แยกต่างหาก” จาก Cargo เพราะมีปริมาณมากและต้องประมวลผลแบบ asyncTactical Design (Loaded, Unloaded, Customs, Claimed) ลง event stream ของ Cargo แต่ละชิ้น ส่วนหน้าจอลูกค้าที่ถามว่า “ตอนนี้ของอยู่ที่ไหน” อ่านจาก materialized view ที่ projection อัปเดตแบบ continuous ทุกครั้งที่มี handling event ใหม่ — ฝั่งเขียนกับฝั่งอ่านแยกกันชัดเจน ต่างฝ่ายต่าง scale ได้อย่างอิสระ
พลังเสริมที่เกิดจากการรวมกัน
หัวข้อที่มีชื่อว่า “พลังเสริมที่เกิดจากการรวมกัน”นอกจากสร้าง view cache ได้ง่าย การรวม CQRS กับ event sourcing ยังให้ประโยชน์อื่น:
- Event stream เป็นคิวในตัว — Event Store เปิด stream เป็น Atom feed ได้ จึงไม่ต้องพึ่ง message bus แยกต่างหาก
- ไม่ต้องมี two-phase commit — เพราะ Event Store เป็นทั้งแหล่งข้อมูลหลัก เครื่องมือทำ projection และคิว อยู่ในที่เดียว เมื่อ event เข้า stream แล้วถือว่า “เข้าคิวสำเร็จ” และ “บันทึกสำเร็จ” ไปพร้อมกัน — ไม่ต้องกังวลเรื่อง distributed transaction ที่ต้อง rollback ข้ามหลายระบบ
โดยพื้นฐานแล้ว CQRS คือเรื่องของการ denormalization แต่พลังเสริม (synergy) ที่เกิดขึ้นเมื่อรวมกับ event sourcing นั้นแรงจนก่อให้เกิดทั้งคอมมูนิตีรอบๆ การรวมสองสิ่งนี้เข้าด้วยกัน
— Scott Millett & Nick Tune
ชั่งน้ำหนัก: ข้อดีและต้นทุน
หัวข้อที่มีชื่อว่า “ชั่งน้ำหนัก: ข้อดีและต้นทุน”ข้อดีหลัก: ได้เปรียบการแข่งขัน จากการวิเคราะห์ประวัติกระบวนการธุรกิจ (business process) ได้ทั้งหมด, aggregate ที่สื่อความหมายชัด (When-event อ่านได้เหมือนประโยค), persistence ที่ง่ายขึ้น (ไม่มี impedance mismatch แบบ ORM) และ debug เหนือชั้น เพราะ replay ลำดับ event ช่วงใดก็ได้เพื่อหาต้นตอของ bug
แต่ต้องสมจริงกับ ต้นทุน ด้วย — แม้แต่ผู้ขาย event store เองก็เตือนให้คิดให้ดีว่าจะใช้ตรงไหน:
- Versioning — เมื่อเรียนรู้ domain มากขึ้น คุณอาจต้องเปลี่ยนโครงสร้าง event แต่ stream เก่ายังเต็มไปด้วย event รูปแบบเดิม มีทางแก้ แต่ถ้าไม่ใส่ใจ versioning จะกลายเป็นปัญหาใหญ่
- แนวคิดและทักษะใหม่ — projection, temporal query, snapshot ล้วนใหม่สำหรับทีมที่เพิ่งเริ่ม ความเร็วในระยะสั้นจะช้าลง ต้องเผื่อเวลาเรียนรู้
- เทคโนโลยีใหม่ที่ต้อง master — การนำ event store ขึ้น production จริง (รัน, monitor, ดู resource) กินชั่วโมงคนไม่น้อย
- พื้นที่เก็บข้อมูลมากขึ้น — เก็บประวัติทั้งหมดย่อมกิน disk มากกว่าเก็บแค่สถานะ (แม้วันนี้ storage จะถูก แต่ก็ควรเฝ้าระวัง)
event sourcing ไม่ใช่กระสุนเงิน (silver bullet) — บ่อยครั้งมัน “ลงแรงเยอะแต่ผลตอบแทนไม่คุ้ม” อย่านำมาใช้โดยไม่พิจารณาให้รอบคอบ มันจะเปล่งประกายก็ต่อเมื่อใช้กับ domain ที่ประวัติของกระบวนการธุรกิจ (business process) มีคุณค่าทางธุรกิจจริง ไม่ใช่กับ CRUD ธรรมดา
เจาะลึกแนวคิดในบทนี้ต่อได้ที่คลังอ้างอิง DevIQ:
- Aggregate — หลักการออกแบบ aggregate โดยทั่วไป ที่ event-sourced aggregate ในบทนี้ยังต้องยึดตาม (consistency boundary, invariant) แม้จะเปลี่ยนวิธีคำนวณสถานะเป็นการ replay event
- Domain Events — รูปแบบ event ที่ aggregate ปล่อยออกมาเมื่อสถานะเปลี่ยน ซึ่งเป็นหน่วยพื้นฐานที่ event sourcing เก็บลง stream แทน snapshot
- CQRS — อธิบายการแยกฝั่งเขียน/อ่านโดยละเอียด เสริมส่วน “CQRS ผสานกับ Event Sourcing” ของบทนี้ ว่าทำไม materialized view ถึงช่วยลดการแย่งทรัพยากรบน event stream
- Repository — รูปแบบที่บทนี้ใช้ห่อการ persist/rehydrate aggregate จาก Event Store ให้ service ภายนอกไม่ต้องรู้เรื่อง event stream เลย
เช็กความเข้าใจ — บทที่ 22
ข้อ 1 / 4แก่นของ event sourcing ต่างจากการเก็บ snapshot อย่างไร?