ย้ายเงินแบบ idempotent
สองบทที่แล้วเราวางรากของระบบเสร็จ — บทที่ 1 บอกว่าทำไมเงินต้องบันทึกเป็นสองขาที่สมดุล บทที่ 2 ปั้น LedgerLedgerแหล่งความจริงเดียวที่รักษาประวัติ Journal Entry ทุกรายการของระบบไว้เรียงตามเวลา แบบ append-only ไม่มีการแก้ของเก่า ยอดเงินของบัญชีไหนก็ตามคำนวณได้จากการไล่ผลรวมรายการใน ledger เท่านั้น ไม่ใช่ตัวเลขที่เก็บแยกไว้ต่างหากTactical Design กับ Journal EntryJournal Entryหน่วยที่เล็กที่สุดของการบันทึกใน ledger หนึ่งรายการมีอย่างน้อยสองบรรทัด (เดบิตกับเครดิต) ที่ยอดรวมเท่ากัน เช่น ตอนลูกค้าจ่ายค่าอาหาร เกิด journal entry เดียวที่กระทบทั้งบัญชีเงินสดและบัญชี escrow — เขียนแล้วห้ามแก้ ถ้าผิดต้องเขียน Reversing Entry มาหักล้างแทนTactical Design ให้เป็น aggregate ที่รักษากฎ “เดบิตรวม = เครดิตรวม” ไว้ในตัวเอง แต่ที่ผ่านมาเงินยัง “นิ่ง” อยู่ บทนี้คือบทที่เงินเริ่ม เคลื่อน จริงๆ — เราจะย้ายเงินจากบัญชีหนึ่งไปอีกบัญชีหนึ่ง เช่น กันเงินค่าอาหารจาก wallet ลูกค้าเข้าบัญชีพักระหว่างส่ง โจทย์ที่แท้จริงของบทนี้ไม่ใช่ “ย้ายยังไง” (ก็แค่ post journal entry สองขา) แต่คือ “ย้ายยังไงให้ปลอดภัยเมื่อคำสั่งเดิมถูกยิงซ้ำ”
เงินขยับในระบบเราไม่ได้เกิดลอยๆ แต่ถูกขับด้วย Integration EventIntegration Eventเหตุการณ์ที่สื่อสารข้าม Bounded Context หรือข้าม service ผ่าน message broker เช่น PaymentCaptured, OrderDelivered ที่ Payment context ต้องรับฟังจาก Ordering context เพื่อรู้ว่าเมื่อไหร่ควรปล่อยเงินจาก escrow — ต่างจาก Domain Event ที่ทำงานเฉพาะภายใน context เดียว (in-process)Architecture ที่ไหลมาจากคอร์ส Bounded Contexts (#8) — เช่น OrderConfirmed (ลูกค้ายืนยันออเดอร์แล้ว) และ OrderDelivered (ส่งอาหารถึงแล้ว) สัญญาของ event พวกนี้มีแต่ ID กับค่าพื้นฐาน ไม่มี object domain ของ Ordering หลุดข้ามมา และเพราะมันวิ่งผ่าน message broker แบบ at-least-once event เดิมจึงมาถึงซ้ำได้เสมอ นั่นคือที่มาของโจทย์ทั้งบท
code เต็มของคอร์สนี้อยู่ที่ repo kaen-food-ordering (กำลังจัดทำ) — สนิปเป็ตในบทนี้เป็นโครง model จริงที่บทถัดๆ ไปจะต่อยอด แต่ตัว repo ให้ตามยังทยอยจัดทำอยู่ value object Money ยกมาจากคอร์ส DDD-in-Code ทั้งดุ้น ส่วน gateway/PromptPay ในบทนี้เป็นแค่ภาพประกอบ ไม่ใช่การต่อระบบชำระเงินจริง
ย้ายเงินคือ journal entry สองขาที่สมดุล
หัวข้อที่มีชื่อว่า “ย้ายเงินคือ journal entry สองขาที่สมดุล”ก่อนพูดเรื่อง idempotency ขอปูรูปทรงของ model ให้ครบก่อน เพราะทั้งบทจะอ้างถึงมันตลอด เริ่มจากของเล็กสุด — ทุกอย่างที่เป็น “ตัวระบุ” เราห่อเป็น value object ไม่ปล่อยเป็น Guid เปล่าๆ และเงินทุกจำนวนคือ MoneyMoneyValue Object ที่แทนจำนวนเงินพร้อมสกุลเงิน (เช่น จำนวน + THB) เปรียบเทียบกันด้วยค่า ไม่ใช่ identity, บวก/ลบกันได้แต่ต้องเป็นสกุลเดียวกัน, immutable — ห้ามใช้ decimal เปล่า ๆ แทนเงินเพราะจะทำให้หน่วยเงินและกฎการปัดเศษหล่นหายไป (Primitive Obsession)Tactical Design ตัวเดิมจากคอร์ส DDD-in-Code:
// FoodOrdering.Domain/Orders/Money.cs — ยกมาจาก DDD-in-Code ทั้งดุ้นpublic sealed record Money(decimal Amount, string Currency){ public Money { if (Amount < 0) throw new ArgumentException("จำนวนเงินต้องไม่ติดลบ", nameof(Amount)); if (Currency != "THB") throw new ArgumentException("ตอนนี้รองรับเฉพาะสกุลเงิน THB", nameof(Currency)); }
public static Money Thb(decimal amount) => new(amount, "THB");
public static Money operator +(Money a, Money b) { if (a.Currency != b.Currency) throw new InvalidOperationException("บวกเงินต่างสกุลกันไม่ได้"); return new Money(a.Amount + b.Amount, a.Currency); }}
// ตัวระบุห่อเป็น value object ทั้งคู่ — กัน AccountId กับ JournalEntryId สลับกันไม่ได้public sealed record AccountId(Guid Value);public sealed record JournalEntryId(Guid Value);ต่อมาคือ AccountAccountหน่วยที่ใช้แยกแยะว่า 'เงินอยู่ตรงไหน' ในระบบ เช่น บัญชีเงินสด platform, บัญชีลูกหนี้ร้านอาหาร, บัญชี wallet ของลูกค้า, บัญชี escrow ของออเดอร์ — ทุก Journal Entry ต้องระบุว่าเดบิต/เครดิตบัญชีไหนTactical Design — หน่วยที่บอกว่า “เงินอยู่ตรงไหน” ในระบบฟู้ดเดลิเวอรีของเรามีบัญชีอยู่หกประเภท และสิ่งที่ต้องย้ำคือ Account ไม่มี column Balance ยอดคงเหลือของบัญชีไม่ใช่ตัวเลขที่เก็บไว้ แต่เป็นผลของการไล่รวมทุกรายการของบัญชีนั้น — แนวคิด Balance as a FoldBalance as a Foldแนวคิดที่ว่ายอดคงเหลือของบัญชีไม่ใช่ตัวเลขที่เก็บไว้ตรง ๆ แต่เป็นผลลัพธ์จากการ 'พับ' (fold) ไล่บวก-ลบทุก Journal Entry ของบัญชีนั้นตั้งแต่ต้นจนถึงปัจจุบัน คล้ายการ replay event ใน Event Sourcing — บัญชีจึงตรวจสอบย้อนกลับได้เสมอว่ายอดเงินมาจากไหนบ้างArchitecture ที่บทที่ 1 วางไว้:
public enum AccountType{ CustomerWallet, // wallet ลูกค้า — หนี้สิน (platform ติดค้างลูกค้า) RestaurantPayable, // เจ้าหนี้ร้านอาหาร — หนี้สิน RiderPayable, // เจ้าหนี้ไรเดอร์ — หนี้สิน PlatformRevenue, // รายได้ platform (ค่าคอมมิชชัน) GatewayClearing, // บัญชีพักรับเงินเข้าจาก gateway Escrow // บัญชีพักเงินระหว่างอาหารกำลังส่ง}
// สังเกต: ไม่มี property Balance — ยอดคงเหลือคือ fold ของทุก LedgerEntry ของบัญชีนี้public sealed record Account(AccountId Id, AccountType Type);ทีนี้ถึงหัวใจของบทที่ 2 ที่เราจะใช้งานจริงในบทนี้ — บรรทัดเดียวใน ledger คือ LedgerEntry ที่ immutableImmutabilityคุณสมบัติที่ข้อมูลเปลี่ยนแปลงไม่ได้หลังถูกบันทึก — Journal Entry ที่เขียนลง ledger แล้วต้อง immutable ตลอดไป ถ้าธุรกรรมผิดพลาดก็เขียนรายการใหม่มาหักล้าง ไม่ใช่แก้ของเดิม เพราะการแก้ของเดิมทำให้ตรวจสอบ (audit) เชื่อถือไม่ได้อีกต่อไปTactical Design ตลอดกาล และ journal entry คือ เซ็ตของ LedgerEntry ที่สมดุล พร้อมกับ idempotency key และเหตุผลกำกับ:
public enum Direction { Debit, Credit }
// ledger entry หนึ่งบรรทัด — เขียนแล้วห้ามแก้ ห้ามลบpublic sealed record LedgerEntry( AccountId AccountId, Direction Direction, // Debit หรือ Credit Money Amount, JournalEntryId JournalEntryId);
public sealed class JournalEntry{ public JournalEntryId Id { get; } public string IdempotencyKey { get; } // ★ กัน post ซ้ำ public string Reason { get; } public IReadOnlyList<LedgerEntry> Entries { get; }
public JournalEntry( JournalEntryId id, string idempotencyKey, string reason, IReadOnlyList<LedgerEntry> entries) { if (string.IsNullOrWhiteSpace(idempotencyKey)) throw new ArgumentException("journal entry ต้องมี idempotency key", nameof(idempotencyKey)); if (entries.Count < 2) throw new ArgumentException("journal entry ต้องมีอย่างน้อยสองขา", nameof(entries));
var debits = entries.Where(l => l.Direction == Direction.Debit) .Select(l => l.Amount) .Aggregate(Money.Thb(0), (sum, m) => sum + m); var credits = entries.Where(l => l.Direction == Direction.Credit) .Select(l => l.Amount) .Aggregate(Money.Thb(0), (sum, m) => sum + m);
// ★ invariant: เดบิตรวมต้องเท่ากับเครดิตรวมพอดี ไม่งั้นสร้างไม่ได้ตั้งแต่แรก if (debits != credits) throw new InvalidOperationException( $"journal entry ไม่สมดุล: เดบิต {debits.Amount} ≠ เครดิต {credits.Amount}");
Id = id; IdempotencyKey = idempotencyKey; Reason = reason; Entries = entries; }
// ย้ายเงิน = เดบิตบัญชีหนึ่ง เครดิตอีกบัญชี ด้วยจำนวนเท่ากัน = สมดุลเสมอ public static JournalEntry BalancedTransfer( AccountId debitAccount, AccountId creditAccount, Money amount, string idempotencyKey, string reason) { var id = new JournalEntryId(Guid.NewGuid()); var entries = new[] { new LedgerEntry(debitAccount, Direction.Debit, amount, id), new LedgerEntry(creditAccount, Direction.Credit, amount, id), }; return new JournalEntry(id, idempotencyKey, reason, entries); }}การย้ายเงินจึงเป็นแค่ journal entry สองขาที่สมดุลโดยธรรมชาติ — ขาหนึ่งเดบิต อีกขาหนึ่งเครดิต ด้วยจำนวนเท่ากัน เดบิตกับเครดิตDebit and Creditสองด้านของทุก Journal Entry ในระบบ double-entry — เดบิตกับเครดิตไม่ได้แปลว่า 'บวก' หรือ 'ลบ' ตรง ๆ แต่ขึ้นกับประเภทบัญชี สิ่งที่ต้องจริงเสมอคือผลรวมเดบิตของรายการหนึ่งต้องเท่ากับผลรวมเครดิตพอดีTactical Design ข้างไหนขึ้นกับประเภทบัญชี (ทวนได้ที่บทที่ 1) เช่น กันเงินค่าอาหารออกจาก wallet ลูกค้าเข้า escrow: wallet เป็นหนี้สินที่ลดลง (เดบิต) ส่วน escrow เป็นหนี้สินที่เพิ่มขึ้น (เครดิต) เท่ากันเป๊ะ รายการจึงสมดุลและบันทึกได้ ทีนี้ปัญหาที่แท้จริงอยู่ตรงจังหวะ “บันทึก” นั่นเอง
request เดิมที่ถูกยิงซ้ำ — โจทย์ที่ idempotency key แก้
หัวข้อที่มีชื่อว่า “request เดิมที่ถูกยิงซ้ำ — โจทย์ที่ idempotency key แก้”ลองนึกภาพจริง: OrderConfirmed วิ่งเข้ามาทาง message broker เราประมวลผลเสร็จ post journal entry ย้ายเงินเรียบร้อย แต่ตอนจะ ack กลับไป เครือข่ายกระตุก broker ไม่ได้รับ ack มันเลยส่ง OrderConfirmed ตัวเดิม มาอีกรอบ นี่ไม่ใช่เคสหายาก แต่เป็นพฤติกรรมปกติของการส่งแบบ at-least-once — broker การันตีแค่ว่า “ส่งอย่างน้อยหนึ่งครั้ง” ไม่เคยการันตีว่า “ครั้งเดียวเป๊ะ” เรื่องเดียวกันเกิดกับ gateway ด้วย: ตอนเรียกเก็บเงินผ่าน PromptPay แล้ว timeout ฝั่ง client มักยิงคำขอเดิมซ้ำ
version ที่เผลอเขียนกันบ่อยสุดคือย้ายเงินด้วยการ UPDATE ยอดที่เก็บเป็น column — และมันพังหลายชั้นพร้อมกัน:
// ❌ version ดิบ — ไม่มี idempotency key และเขียนทับยอดตรง ๆpublic async Task Transfer(Guid fromId, Guid toId, decimal amount){ var from = await accounts.Get(fromId); var to = await accounts.Get(toId);
from.Balance -= amount; // อ่าน → ลบ → เขียนทับ to.Balance += amount; // อ่าน → บวก → เขียนทับ
await accounts.Save(from); await accounts.Save(to);}code นี้พังสามแกนที่แก้ทีหลังไม่ได้:
- ยิงซ้ำ = ย้ายเงินซ้ำ — ไม่มีอะไรจำได้ว่าคำขอนี้เคยทำไปแล้ว พอ
OrderConfirmedเดิมมาอีกรอบ เงินก็ถูกย้ายอีกก้อน ลูกค้าโดนกันเงินสองเท่า - เขียนทับกันเอง (lost update) —
from.Balance -= amountคือลำดับ อ่าน → ลบ → เขียนทับ 2 request พร้อมกันอ่านยอดเดิมมาเท่ากันแล้วต่างคนต่างเขียนทับ เงินโผล่มาจากอากาศ - ไม่มีประวัติให้ตรวจ (no audit trail) — พอเขียนทับ ยอดเก่าหายเกลี้ยง ตอบไม่ได้ว่าเงินขยับตอนไหน จากคำสั่งไหน
ทางแก้ทั้งสามแกนคืออย่างเดียวกัน: แนบ Idempotency KeyIdempotency Keyรหัสเฉพาะที่แนบไปกับคำขอทำธุรกรรม (เช่น เรียกเก็บเงินผ่าน PromptPay) เพื่อให้ระบบรู้ว่าคำขอที่ถูกส่งซ้ำ (retry) คือคำขอเดิม ไม่ใช่ธุรกรรมใหม่ — กันไม่ให้ลูกค้าโดนหักเงินซ้ำเวลาเครือข่ายมีปัญหาแล้ว client ยิงคำขอเดิมซ้ำArchitecture ไปกับทุกการย้ายเงิน แล้วทำให้ตัว post เป็น Idempotent ConsumerIdempotent Consumerตัวรับ event หรือ message ที่ประมวลผลข้อความเดิมซ้ำกี่ครั้งก็ได้ผลลัพธ์เหมือนเดิม ไม่สร้างผลข้างเคียงซ้ำ เช่น รับ PaymentCaptured ซ้ำสองครั้งจาก message broker ต้องไม่บันทึก journal entry สองรอบ — ตรวจด้วย Idempotency Key หรือเช็คว่าเคยประมวลผลรายการนี้ไปแล้วหรือยังArchitecture — ประมวลผลคำขอเดิมกี่รอบก็ได้ผลลัพธ์เดียว โดยเช็ก key ก่อนว่าเคย post ไปแล้วหรือยัง กลไกนี้ยกมาจากคอร์ส #8 ตรงๆ เราไม่สอน messaging ซ้ำ แค่เอา idempotency key มาผูกกับ event ต้นทาง:
public interface ILedger{ // idempotent: post entry ที่ idempotency key เดิมซ้ำ = ไม่เกิดอะไรขึ้น Task Post(JournalEntry entry, CancellationToken ct);}
public sealed class Ledger : ILedger{ private readonly IJournalStore store; public Ledger(IJournalStore store) => this.store = store;
public async Task Post(JournalEntry entry, CancellationToken ct) { // ★ หัวใจ idempotency: เคยเห็น key นี้แล้วก็จบ ไม่ post ซ้ำ if (await store.Exists(entry.IdempotencyKey, ct)) return;
// append-only: ต่อรายการใหม่เท่านั้น ไม่เคย UPDATE ยอดของบัญชีไหน await store.Append(entry, ct); }}
public interface IJournalStore{ Task<bool> Exists(string idempotencyKey, CancellationToken ct); Task Append(JournalEntry entry, CancellationToken ct); // เขียนต่อท้ายเท่านั้น}จุดสำคัญคือ idempotency key ต้องผูกกับเหตุการณ์ต้นทาง ไม่ใช่สุ่มใหม่ทุกครั้ง เพราะถ้าสุ่มใหม่ retry จะได้ key คนละตัวแล้วกันซ้ำไม่ได้เลย เรา derive key จาก OrderId ของ event ตรงๆ — event เดิมยิงกี่รอบ key ก็เท่าเดิมทุกรอบ ตัว handler ที่รับ OrderConfirmed จึงหน้าตาแบบนี้:
// OrderConfirmed = integration event จากคอร์ส #8 — สัญญามีแต่ ID กับค่าพื้นฐานpublic sealed record OrderConfirmed(Guid OrderId, Guid CustomerId, decimal AmountThb);
public interface IAccountDirectory{ AccountId WalletOf(Guid customerId); AccountId EscrowOf(Guid orderId);}
public sealed class OrderConfirmedHandler{ private readonly ILedger ledger; private readonly IAccountDirectory accounts;
public OrderConfirmedHandler(ILedger ledger, IAccountDirectory accounts) { this.ledger = ledger; this.accounts = accounts; }
public async Task Handle(OrderConfirmed evt, CancellationToken ct) { var wallet = accounts.WalletOf(evt.CustomerId); var escrow = accounts.EscrowOf(evt.OrderId); var amount = Money.Thb(evt.AmountThb);
// idempotency key ผูกกับ event — retry กี่รอบก็ key เดิม var key = $"order-confirmed:{evt.OrderId}";
var posting = JournalEntry.BalancedTransfer( debitAccount: wallet, // wallet ลูกค้า (หนี้สิน) ลดลง = เดบิต creditAccount: escrow, // escrow ออเดอร์ (หนี้สิน) เพิ่มขึ้น = เครดิต amount: amount, idempotencyKey: key, reason: $"กันเงินค่าอาหารออเดอร์ {evt.OrderId} เข้า escrow");
await ledger.Post(posting, ct); // ยิงซ้ำได้ปลอดภัย post จริงครั้งเดียว }}OrderConfirmed มาถึงกี่ครั้งก็ตาม key จะเท่ากันทุกครั้ง Post เห็นว่าเคย post key นี้แล้วรอบสองก็ทำ no-op เงินจึงถูกย้ายครั้งเดียวเป๊ะ:
flowchart TB
R1["OrderConfirmed ครั้งที่ 1<br/>idem key = order-confirmed:ORDER-42"]
R2["OrderConfirmed ครั้งที่ 2 — retry<br/>idem key = order-confirmed:ORDER-42 (เดิม)"]
R1 --> CHK{"ledger เคยเห็น key นี้แล้วหรือยัง?"}
R2 --> CHK
CHK -->|ยังไม่เคย| POST["post journal entry สองขา<br/>เดบิต wallet ลูกค้า 200 / เครดิต escrow 200<br/>เดบิตรวม = เครดิตรวม — สมดุล"]
CHK -->|เคยแล้ว| SKIP["ข้าม — no-op ไม่ post ซ้ำ"]
POST --> ONE["ledger มี posting เดียว<br/>เงินถูกย้ายครั้งเดียวเป๊ะ"]
SKIP --> ONE
classDef evt fill:#1e3a8a,stroke:#1e40af,color:#f8fafc;
classDef dec fill:#f59e0b,stroke:#92400e,color:#1c1917;
classDef ok fill:#16a34a,stroke:#065f46,color:#f8fafc;
classDef skip fill:#6b7280,stroke:#374151,color:#f8fafc;
class R1,R2 evt;
class CHK dec;
class POST,ONE ok;
class SKIP skip;
คำบรรยายภาพ: OrderConfirmed ตัวเดิมถูกส่งมาสองครั้ง (น้ำเงิน) เพราะ broker การันตีแบบ at-least-once ทั้งสองครั้งพก idempotency key เดียวกัน (order-confirmed:ORDER-42) เพราะ key derive มาจาก OrderId ที่ไม่เปลี่ยน จุดตัดสินสีส้มถามว่า ledger เคยเห็น key นี้หรือยัง — ครั้งแรกยังไม่เคย จึง post journal entry สองขาที่สมดุลจริง (เขียว) ครั้งที่สองเคยแล้ว จึงข้ามแบบ no-op (เทา) ปลายทางบรรจบกันที่ ledger มี posting เดียว เงินถูกย้ายครั้งเดียวเป๊ะ ไม่ว่า event จะซ้ำกี่รอบ
ย้ายเงินคือ “ต่อรายการ” ไม่ใช่ “เขียนทับยอด”
หัวข้อที่มีชื่อว่า “ย้ายเงินคือ “ต่อรายการ” ไม่ใช่ “เขียนทับยอด””สังเกตให้ดีว่า Post ทำแค่ store.Append(entry) — มันไม่เคยแตะยอดของบัญชีไหนเลย นี่คือหลักที่ทำให้ทุกอย่างข้างบนทำงานได้ ledger เป็น Append-only LogAppend-only Logโครงสร้างการเก็บข้อมูลที่ 'เขียนเพิ่มได้อย่างเดียว' ห้าม UPDATE หรือ DELETE รายการเก่า — ledger ของระบบการเงินต้องเป็นแบบนี้เสมอ เพราะประวัติเงินต้องตรวจสอบย้อนหลังได้ทุกบรรทัด ต่างจากตารางทั่วไปที่แก้ทับค่าเดิมได้Architecture เขียนเพิ่มได้อย่างเดียว การย้ายเงินจึงเป็นการ ต่อ journal entry ใหม่ ต่อท้าย ไม่ใช่การไปหายอดเก่ามาบวก-ลบแล้วเขียนทับ ผลพลอยได้คือ lost-update race หายไปเอง เพราะไม่มี column ยอดให้2 request แย่งกันเขียนทับตั้งแต่แรก และประวัติทุกบาทอยู่ครบ ตรวจย้อนหลังได้ว่าเงินขยับจากคำสั่งไหน เมื่อไหร่ ด้วยเหตุผลอะไร
แล้วถ้า post ผิดล่ะ เช่น กันเงินผิดออเดอร์ กฎเหล็กคือ ห้ามลบ ห้ามแก้รายการเดิม เพราะ LedgerEntry immutable ตลอดกาล วิธีแก้คือเขียน Reversing EntryReversing EntryJournal Entry ใหม่ที่เขียนขึ้นเพื่อหักล้างรายการเดิมทั้งหมดหรือบางส่วน โดยสลับด้านเดบิต/เครดิตของรายการเดิม เช่น ตอนคืนเงินลูกค้า (refund) แทนที่จะลบรายการจ่ายเงินเดิม ระบบเขียน reversing entry ใหม่ทับ — ประวัติเดิมยังอยู่ครบ ตรวจสอบย้อนหลังได้ว่าเกิดอะไรขึ้นบ้างTactical Design — journal entry ใหม่ที่สลับด้านเดบิต/เครดิตของรายการเดิมมาหักล้าง ประวัติเดิมยังอยู่ครบ คนตรวจสอบเห็นทั้งรายการที่ผิดและรายการที่แก้ (เรื่องนี้จะลงลึกตอนทำ refund ในบทที่ 7) รวมแล้วกฎของบทนี้มีสามข้อที่พึ่งพากันเป็นชุด: ย้ายเงินคือ post journal entry สองขาที่สมดุล, การ post ทำแบบ idempotent ด้วย key ที่ผูกกับ event และทุกอย่างเป็นการ append ไม่มีวัน update หรือ delete
สิ่งที่จะทำต่อ
หัวข้อที่มีชื่อว่า “สิ่งที่จะทำต่อ”ตอนนี้เราย้ายเงินระหว่างบัญชีได้อย่างปลอดภัยแม้คำสั่งจะถูกยิงซ้ำ บทที่ 4 จะเอากลไก idempotent อันเดิมนี้ไปรับเงินก้อนแรกที่ไหลเข้าระบบจริง — ลูกค้าเติมเงินเข้า wallet ผ่าน PromptPay/QR โดย gateway ยืนยันกลับมาแบบ async ผ่าน webhook ที่มาซ้ำได้ เราจึงต้องทำ webhook ให้ idempotent ด้วย key เหมือนที่ทำกับ event ในบทนี้ (เดบิตบัญชีพัก GatewayClearing เครดิต wallet ลูกค้า) ส่วนการพักเงินค่าอาหารไว้ใน escrow ระหว่างส่ง แล้วรอ OrderDelivered ค่อยปล่อยเงินออก เป็นจังหวะที่เงินก้อนเดียวเดินทางผ่านหลายสถานะ จะไปว่ากันในบทที่ 5 กับบทที่ 6
เจาะลึกแนวคิดในบทนี้ต่อได้ที่คลังอ้างอิง DevIQ:
- Aggregate —
JournalEntryเป็น aggregate ที่รักษา invariant “เดบิตรวม = เครดิตรวม” ไว้ในตัว สร้างรายการที่ไม่สมดุลไม่ได้ตั้งแต่แรก - Value Object —
AccountId,JournalEntryIdและMoneyเป็น value object ที่ห่อค่าไว้ไม่ให้ปนกัน แทนการส่งGuid/decimalเปล่าๆ ไปทั้งระบบ
เช็กความเข้าใจ — บทที่ 3
ข้อ 1 / 3ทำไม journal entry ที่ย้ายเงินต้องแนบ idempotency key ที่ผูกกับ event ต้นทาง?