จ่ายค่าอาหาร (escrow)
บทที่แล้วเราเติมเงินเข้า wallet ของลูกค้าผ่าน PromptPay จนมียอดพร้อมใช้ ตอนนี้ลูกค้ากดสั่งอาหารจริง domain Order ที่เราปั้นมาตลอดไตรภาคก็ทำงานของมัน — ตรวจของในตะกร้า คิดราคา แล้วยืนยันออเดอร์ พอออเดอร์ถูกยืนยัน Ordering context ก็ประกาศออกมาว่า “ออเดอร์นี้ยืนยันแล้ว” คำถามของบทนี้คือ ฝั่งเงินต้องขยับยังไงต่อ — เงินค่าอาหารในกระเป๋าลูกค้าควรไปอยู่ที่ไหนระหว่างที่ร้านกำลังทำอาหารและไรเดอร์กำลังวิ่งไปส่ง คำตอบคือยังไม่จ่ายใครทั้งนั้น แต่กันเงินไว้ในบัญชีพักที่เรียกว่า escrow ก่อน
code เต็มของคอร์สนี้อยู่ที่ repo kaen-food-ordering (กำลังจัดทำ) — สแนปเพ็ตในบทนี้คือแกนของ handler ฝั่ง money context ที่ฟัง OrderConfirmed แล้วกันเงินเข้า escrow ตัว value object Money ยกมาจากไตรภาคเดิม ส่วนรูปทรง Account / JournalEntry / LedgerEntry ปั้นไว้แล้วในบทที่ 2
OrderConfirmed มาถึงประตู money context
หัวข้อที่มีชื่อว่า “OrderConfirmed มาถึงประตู money context”จุดเริ่มของทั้งบทคือเหตุการณ์เดียว: Ordering context ยืนยันออเดอร์เสร็จ แล้วประกาศ OrderConfirmed ออกมา สิ่งนี้คือ Integration EventIntegration Eventเหตุการณ์ที่สื่อสารข้าม Bounded Context หรือข้าม service ผ่าน message broker เช่น PaymentCaptured, OrderDelivered ที่ Payment context ต้องรับฟังจาก Ordering context เพื่อรู้ว่าเมื่อไหร่ควรปล่อยเงินจาก escrow — ต่างจาก Domain Event ที่ทำงานเฉพาะภายใน context เดียว (in-process)Architecture — เหตุการณ์ที่เดินทางข้าม bounded context ผ่าน message broker ไม่ใช่ domain event ที่วิ่งอยู่ใน process เดียว เราออกแบบ contract ของมันตามหลักที่วางไว้ในคอร์ส Bounded Contexts บทที่ 4 เรื่อง integration event: มีแต่ ID กับ primitive เท่านั้น ไม่มี type ของ domain Ordering รั่วข้ามมาให้ money context ต้องรู้จัก
มีจุดต้องระวังหนึ่งข้อที่คอร์ส Bounded Contexts บทที่ 6 เรื่อง outbox เตือนไว้: event ข้าม context ส่งแบบ at-least-once — มันอาจถูกส่งซ้ำได้เมื่อ broker retry ฉะนั้นตัวรับต้องเป็น Idempotent ConsumerIdempotent Consumerตัวรับ event หรือ message ที่ประมวลผลข้อความเดิมซ้ำกี่ครั้งก็ได้ผลลัพธ์เหมือนเดิม ไม่สร้างผลข้างเคียงซ้ำ เช่น รับ PaymentCaptured ซ้ำสองครั้งจาก message broker ต้องไม่บันทึก journal entry สองรอบ — ตรวจด้วย Idempotency Key หรือเช็คว่าเคยประมวลผลรายการนี้ไปแล้วหรือยังArchitecture คือรับ OrderConfirmed ตัวเดิมซ้ำกี่ครั้งก็ต้องกันเงินเข้า escrow แค่ครั้งเดียว เราไม่ต้องสอนเรื่อง messaging ซ้ำในบทนี้ — แค่ยืมกลไกเดิมมาใช้: แนบ Idempotency KeyIdempotency Keyรหัสเฉพาะที่แนบไปกับคำขอทำธุรกรรม (เช่น เรียกเก็บเงินผ่าน PromptPay) เพื่อให้ระบบรู้ว่าคำขอที่ถูกส่งซ้ำ (retry) คือคำขอเดิม ไม่ใช่ธุรกรรมใหม่ — กันไม่ให้ลูกค้าโดนหักเงินซ้ำเวลาเครือข่ายมีปัญหาแล้ว client ยิงคำขอเดิมซ้ำArchitecture ที่ผูกกับออเดอร์ไปกับทุกการ post ลง ledger แล้ว dedupe ด้วย key นั้น
ทวนรูปทรง ledger จากบทที่ 2
หัวข้อที่มีชื่อว่า “ทวนรูปทรง ledger จากบทที่ 2”ก่อนลงมือกันเงิน ขอทวนเครื่องมือที่บทที่ 2 ปั้นไว้ให้แล้ว เพราะทั้งบทนี้จะวนอยู่กับมัน ทุกการเคลื่อนของเงินบันทึกเป็น Journal EntryJournal Entryหน่วยที่เล็กที่สุดของการบันทึกใน ledger หนึ่งรายการมีอย่างน้อยสองบรรทัด (เดบิตกับเครดิต) ที่ยอดรวมเท่ากัน เช่น ตอนลูกค้าจ่ายค่าอาหาร เกิด journal entry เดียวที่กระทบทั้งบัญชีเงินสดและบัญชี escrow — เขียนแล้วห้ามแก้ ถ้าผิดต้องเขียน Reversing Entry มาหักล้างแทนTactical Design หนึ่งรายการ ซึ่งประกอบด้วยหลาย LedgerEntry (บรรทัดเดบิต/เครดิต) ที่อ้างถึง AccountAccountหน่วยที่ใช้แยกแยะว่า 'เงินอยู่ตรงไหน' ในระบบ เช่น บัญชีเงินสด platform, บัญชีลูกหนี้ร้านอาหาร, บัญชี wallet ของลูกค้า, บัญชี escrow ของออเดอร์ — ทุก Journal Entry ต้องระบุว่าเดบิต/เครดิตบัญชีไหนTactical Design ปลายทาง และยอดคงเหลือของทุกบัญชีคือ Balance as a FoldBalance as a Foldแนวคิดที่ว่ายอดคงเหลือของบัญชีไม่ใช่ตัวเลขที่เก็บไว้ตรง ๆ แต่เป็นผลลัพธ์จากการ 'พับ' (fold) ไล่บวก-ลบทุก Journal Entry ของบัญชีนั้นตั้งแต่ต้นจนถึงปัจจุบัน คล้ายการ replay event ใน Event Sourcing — บัญชีจึงตรวจสอบย้อนกลับได้เสมอว่ายอดเงินมาจากไหนบ้างArchitecture — ไล่พับผลรวมทุกบรรทัดของบัญชีนั้น ไม่ใช่ column ที่เก็บไว้ตรงๆ
// บทที่ 2 ปั้นรูปทรงหลักของ ledger ไว้แล้ว — ทวนย่อก่อนใช้public readonly record struct AccountId(Guid Value);public readonly record struct JournalEntryId(Guid Value);
public enum AccountType{ CustomerWallet, // เงินในกระเป๋าลูกค้า — platform เป็นหนี้ลูกค้า RestaurantPayable, // เจ้าหนี้ร้านอาหาร RiderPayable, // เจ้าหนี้ไรเดอร์ PlatformRevenue, // รายได้ platform GatewayClearing, // บัญชีพักตอนรับเงินเข้าจาก gateway Escrow, // บัญชีพักเงินค่าอาหารระหว่างออเดอร์กำลังส่ง}
public enum Direction { Debit, Credit }
// หนึ่งบรรทัดใน ledger — immutable ตลอดกาลpublic sealed record LedgerEntry( AccountId AccountId, Direction Direction, Money Amount, JournalEntryId JournalEntryId);หัวใจของทั้งระบบคือ invariant ข้อเดียวที่ JournalEntry ต้องรักษาเสมอ — ผลรวมเดบิตต้องเท่ากับผลรวมเครดิตพอดี ถ้าไม่เท่า สร้างรายการไม่ได้ตั้งแต่แรก และแต่ละรายการพก idempotency key กับ “เหตุผล” ติดตัวไว้เพื่อให้ตรวจย้อนหลังได้ว่าเกิดจากอะไร
// journal entry = ชุด LedgerEntry ที่สมดุล + idempotency key + เหตุผลpublic sealed class JournalEntry{ public JournalEntryId Id { get; } public string IdempotencyKey { get; } public string Reason { get; } public IReadOnlyList<LedgerEntry> Entries { get; }
public JournalEntry( JournalEntryId id, string idempotencyKey, string reason, IReadOnlyList<LedgerEntry> entries) { var debit = Total(entries, Direction.Debit); var credit = Total(entries, Direction.Credit); if (debit != credit) // ★ invariant ที่ต้องรักษาเสมอ throw new InvalidOperationException( $"journal entry ไม่สมดุล: เดบิตรวม {debit.Amount} ไม่เท่ากับเครดิตรวม {credit.Amount}");
Id = id; IdempotencyKey = idempotencyKey; Reason = reason; Entries = entries; }
static Money Total(IReadOnlyList<LedgerEntry> entries, Direction side) => entries.Where(l => l.Direction == side) .Aggregate(Money.Thb(0), (sum, l) => sum + l.Amount);}Money ตัวที่ LedgerEntry.Amount อ้างถึงคือ value object ตัวเดิมจากคอร์ส DDD-in-Code (คอร์ส B — หนึ่งคอร์สในไตรภาค ไม่ใช่ทั้งไตรภาค) — ห่อจำนวนกับสกุลเงินไว้ด้วยกัน บวกกันได้และเทียบเท่ากันได้ตรงๆ กฎ “เดบิตรวม = เครดิตรวม” จึงเขียนสั้นๆ ได้ด้วย MoneyMoneyValue Object ที่แทนจำนวนเงินพร้อมสกุลเงิน (เช่น จำนวน + THB) เปรียบเทียบกันด้วยค่า ไม่ใช่ identity, บวก/ลบกันได้แต่ต้องเป็นสกุลเดียวกัน, immutable — ห้ามใช้ decimal เปล่า ๆ แทนเงินเพราะจะทำให้หน่วยเงินและกฎการปัดเศษหล่นหายไป (Primitive Obsession)Tactical Design ตัวนี้
// FoodOrdering.Domain/Orders/Money.cs — ยกมาจากไตรภาคเดิมทั้งดุ้น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); }}สุดท้ายคือฝั่งที่พาเราไปคุยกับ LedgerLedgerแหล่งความจริงเดียวที่รักษาประวัติ Journal Entry ทุกรายการของระบบไว้เรียงตามเวลา แบบ append-only ไม่มีการแก้ของเก่า ยอดเงินของบัญชีไหนก็ตามคำนวณได้จากการไล่ผลรวมรายการใน ledger เท่านั้น ไม่ใช่ตัวเลขที่เก็บแยกไว้ต่างหากTactical Design จริง กับตัวช่วยหาว่าแต่ละออเดอร์ผูกกับบัญชีไหน — ประกาศไว้เป็น interface สั้นๆ พอให้ handler ทำงานได้:
public interface ILedger{ Money BalanceOf(AccountId account); // ยอด = fold ของทุก LedgerEntry ของบัญชีนั้น void Post(JournalEntry entry); // idempotent ตาม entry.IdempotencyKey (post ซ้ำ = no-op)}
public interface IAccounts{ AccountId WalletOf(Guid customerId); AccountId EscrowOf(Guid orderId); AccountId PayableOf(Guid orderId); // บัญชีเจ้าหนี้ร้านอาหารของออเดอร์}
public sealed class InsufficientWalletBalance(Guid orderId, Money have, Money need) : Exception($"wallet มี {have.Amount} แต่ต้องกัน {need.Amount} บาท สำหรับออเดอร์ {orderId}"){ }
// contract แบบ ID/primitive ล้วน สไตล์เดียวกับ integration event ในบท 4public sealed record OrderConfirmed(Guid OrderId, Guid CustomerId, decimal Total);จ่ายร้านทันทีเลยไม่ได้เหรอ
หัวข้อที่มีชื่อว่า “จ่ายร้านทันทีเลยไม่ได้เหรอ”สัญชาตญาณแรกคือ พอออเดอร์ยืนยันก็โอนเงินจาก wallet ลูกค้าเข้าบัญชีเจ้าหนี้ร้านเลยสิ จบในทีเดียว รายการก็สมดุลดี — เดบิต wallet เครดิตเจ้าหนี้ร้าน เท่ากันเป๊ะ post ผ่านสบาย
// ❌ version ดิบ — พอ OrderConfirmed มาถึง ก็เครดิตเจ้าหนี้ร้านทันทีpublic sealed class PayRestaurantOnConfirm(ILedger ledger, IAccounts accounts){ public void Handle(OrderConfirmed evt) { var wallet = accounts.WalletOf(evt.CustomerId); var payable = accounts.PayableOf(evt.OrderId); // บัญชีเจ้าหนี้ร้านอาหาร var amount = Money.Thb(evt.Total); var jid = new JournalEntryId(Guid.NewGuid());
ledger.Post(new JournalEntry(jid, $"pay:{evt.OrderId}", "จ่ายค่าอาหารให้ร้านทันที", [ new LedgerEntry(wallet, Direction.Debit, amount, jid), // wallet ลูกค้า ลด new LedgerEntry(payable, Direction.Credit, amount, jid), // เจ้าหนี้ร้าน เพิ่ม ])); // 💥 ออเดอร์นี้ยังไม่ถูกส่ง ถ้าลูกค้ายกเลิกหรือร้านกดปฏิเสธ // เงินเข้ากระเป๋าเจ้าหนี้ร้านไปแล้ว ต้องไล่ดึงคืน — ทวงยากและเสี่ยงเงินหลุด }}รายการนี้สมดุลและ post ได้จริง ปัญหาไม่ได้อยู่ที่บัญชี แต่อยู่ที่ จังหวะเวลา — เราปล่อยเงินให้ปลายทางตัวจริงก่อนที่เงื่อนไข “อาหารถูกส่งสำเร็จ” จะเกิดขึ้น พอออเดอร์ถูกยกเลิกกลางคัน (ลูกค้ากดยกเลิก ร้านปฏิเสธ ไรเดอร์หาคนรับไม่ได้) เงินอยู่ในกระเป๋าเจ้าหนี้ร้านไปแล้ว การจะดึงคืนกลายเป็นการไล่ทวงย้อนหลังที่ยุ่งและเสี่ยงเงินหลุดออกนอกระบบ ทางที่ถูกคือกันเงินไว้ตรงกลางก่อน แล้วค่อยตัดสินใจปลายทางเมื่อเงื่อนไขชัดเจน
กันเงินเข้า escrow คือ journal entry สมดุลหนึ่งรายการ
หัวข้อที่มีชื่อว่า “กันเงินเข้า escrow คือ journal entry สมดุลหนึ่งรายการ”นี่คือหน้าที่ของ Escrow AccountEscrow Accountบัญชีที่กันเงินไว้รอเงื่อนไขก่อนปล่อยให้ผู้รับตัวจริง เช่น เงินค่าอาหารที่ลูกค้าจ่ายจะพักอยู่ในบัญชี escrow จนกว่าออเดอร์จะถูกส่งมอบสำเร็จ ค่อยปล่อยเงินให้ร้านอาหารกับไรเดอร์ — รักษาสิทธิ์ทั้งสองฝ่ายไว้จนกว่าเงื่อนไขจะสำเร็จ ถ้าออเดอร์ถูกยกเลิกก่อนส่งมอบ เงินใน escrow จะถูกคืนแทนที่จะจ่ายออกไปTactical Design — บัญชีที่กันเงินไว้รอเงื่อนไขก่อนปล่อยให้ผู้รับตัวจริง เงินค่าอาหารจะพักอยู่ใน escrow จนกว่าออเดอร์จะถูกส่งมอบสำเร็จ ค่อยปล่อยให้ร้านและไรเดอร์ ถ้าออเดอร์ถูกยกเลิกก่อนส่ง เงินใน escrow ก็คืนกลับ wallet ลูกค้าได้ตรงๆ โดยไม่ต้องไปทวงจากใคร
ทั้ง wallet ลูกค้าและ escrow เป็นบัญชี ฝั่งหนี้สิน — เป็นเงินที่ platform ถือไว้แทนคนอื่น ตามกฎ Debit and CreditDebit and Creditสองด้านของทุก Journal Entry ในระบบ double-entry — เดบิตกับเครดิตไม่ได้แปลว่า 'บวก' หรือ 'ลบ' ตรง ๆ แต่ขึ้นกับประเภทบัญชี สิ่งที่ต้องจริงเสมอคือผลรวมเดบิตของรายการหนึ่งต้องเท่ากับผลรวมเครดิตพอดีTactical Design ที่บทที่ 1 วางไว้: หนี้สินนั้นเครดิตทำให้ยอดเพิ่ม เดบิตทำให้ยอดลด การย้ายเงินจาก wallet ไป escrow จึงเป็น journal entry เดียวที่ เดบิต wallet (หนี้สินฝั่งลูกค้าลดลง) และ เครดิต escrow (หนี้สินฝั่งพักเงินเพิ่มขึ้น) ยอดหนี้สินรวมของ platform ไม่เปลี่ยน แค่ย้ายกระเป๋า และเพราะเดบิตรวมเท่ากับเครดิตรวม เงินจึงไม่มีทางหายหรือโผล่มาเอง
// ✅ ฟัง OrderConfirmed แล้วกันเงินเข้า escrow — idempotent consumerpublic sealed class HoldFundsOnOrderConfirmed(ILedger ledger, IAccounts accounts){ public void Handle(OrderConfirmed evt) { var wallet = accounts.WalletOf(evt.CustomerId); var escrow = accounts.EscrowOf(evt.OrderId); var amount = Money.Thb(evt.Total); // primitive จาก contract แปลงเป็น Money ที่ขอบ
// ★ อ่านยอด (fold) แล้วตัดสินใจหัก ต้องอยู่ในจังหวะ strongly-consistent เดียวกัน var balance = ledger.BalanceOf(wallet); if (balance.Amount < amount.Amount) throw new InsufficientWalletBalance(evt.OrderId, balance, amount); // reject
var jid = new JournalEntryId(Guid.NewGuid()); var hold = new JournalEntry( jid, idempotencyKey: $"escrow-hold:{evt.OrderId}", // จุดชนวนเดิมมาซ้ำก็ไม่หักซ้ำ reason: $"กันเงินค่าอาหารเข้าพัก escrow ของออเดอร์ {evt.OrderId}", entries: [ new LedgerEntry(wallet, Direction.Debit, amount, jid), // wallet (หนี้สิน) ลด new LedgerEntry(escrow, Direction.Credit, amount, jid), // escrow (หนี้สิน) เพิ่ม ]);
ledger.Post(hold); // idempotent ตาม idempotencyKey }}flowchart TB EV["integration event: OrderConfirmed<br/>orderId · customerId · total 200 บาท"] EV --> JE["journal entry เดียว — กันเงินเข้า escrow<br/>idempotency key: escrow-hold:orderId"] JE --> DR["ขาเดบิต — wallet ลูกค้า<br/>เดบิต 200 บาท: หนี้สินลด ยอด wallet ลดลง 200"] JE --> CR["ขาเครดิต — escrow ออเดอร์<br/>เครดิต 200 บาท: หนี้สินเพิ่ม ยอด escrow เพิ่มขึ้น 200"] DR --- BAL["เดบิตรวม 200 = เครดิตรวม 200 — สมดุล"] CR --- BAL BAL --> HELD["เงิน 200 บาทถูกพักไว้ใน escrow<br/>รอจนส่งอาหารสำเร็จค่อยปล่อยให้ร้านและไรเดอร์"] classDef ev fill:#1e3a8a,stroke:#1e40af,color:#f8fafc; classDef dr fill:#16a34a,stroke:#065f46,color:#f8fafc; classDef cr fill:#dc2626,stroke:#7f1d1d,color:#f8fafc; classDef bal fill:#f59e0b,stroke:#92400e,color:#1c1917; classDef held fill:#7c3aed,stroke:#4c1d95,color:#f8fafc; class EV ev; class JE ev; class DR dr; class CR cr; class BAL bal; class HELD held;
คำบรรยายภาพ: เหตุการณ์ OrderConfirmed (น้ำเงิน) จุดชนวนให้เกิด journal entry เดียวที่แตกเป็นสองขา ขาเดบิต (เขียว) หัก wallet ลูกค้า 200 บาท — หนี้สินฝั่งลูกค้าลดลง ขาเครดิต (แดง) เพิ่ม escrow 200 บาท — หนี้สินฝั่งพักเงินเพิ่มขึ้นเท่ากัน จุดสีส้มคือกฎเหล็ก เดบิตรวมต้องเท่ากับเครดิตรวม รายการจึงบันทึกได้ ผลลัพธ์ (ม่วง) คือเงิน 200 บาทถูกพักไว้ใน escrow ยังไม่ถึงมือใคร รอจนส่งอาหารสำเร็จค่อยปล่อยในขั้น settlement บทหน้า
ถ้าเงินใน wallet ไม่พอ
หัวข้อที่มีชื่อว่า “ถ้าเงินใน wallet ไม่พอ”สังเกตบรรทัด if (balance.Amount < amount.Amount) ใน handler — ก่อนจะกันเงิน เราอ่านยอด wallet (ซึ่งเป็น fold ของทุกรายการ ไม่ใช่ column) มาเทียบกับจำนวนที่ต้องกัน ถ้าไม่พอจะทำยังไงต่อ นี่คือ การตัดสินใจเชิง domain ไม่ใช่รายละเอียดทางเทคนิค มีสองทางหลัก:
- reject — ปฏิเสธการกันเงินไปเลย โยน
InsufficientWalletBalanceโดยไม่ post อะไรลง ledger สักบรรทัด แล้วแจ้งกลับ Ordering ว่ากันเงินไม่สำเร็จ ออเดอร์เดินต่อไม่ได้ ข้อดีคือ ledger สะอาด ไม่มีรายการค้าง และ invariant “เดบิตรวม = เครดิตรวม” ไม่เคยถูกแตะ - hold แล้วเติมให้พอ — กันเท่าที่มีไว้ก่อน แล้วไปดึงเงินส่วนที่ขาดจาก payment gateway เช่น เรียกเก็บผ่าน PromptPay เพิ่ม (ยืมรูป
IPaymentGatewayแบบ ACL จากคอร์ส Bounded Contexts มาเป็นภาพประกอบ ไม่ใช่ integration จริงในคอร์สนี้) แล้วค่อยกันครบ
คอร์สนี้เลือกทาง reject เพราะมันตรงไปตรงมาและรักษา ledger ให้สะอาดที่สุด สิ่งสำคัญไม่ใช่ว่าเลือกทางไหน แต่คือการตระหนักว่าคำถาม “เงินไม่พอแล้วยังไงต่อ” เป็นคำถามที่ธุรกิจต้องตอบ ไม่ใช่คำถามที่โยนให้ระบบตัดสินเอง และไม่ว่าเลือกทางไหน กฎเดียวที่ห้ามละเมิดคือ ห้าม post รายการที่ไม่สมดุลหรือปล่อยให้บัญชีติดลบเงียบๆ
strong vs eventual consistency — การตัดสินใจว่าหักได้ต้อง synchronous
หัวข้อที่มีชื่อว่า “strong vs eventual consistency — การตัดสินใจว่าหักได้ต้อง synchronous”ในบทที่ 6 ของคอร์ส Bounded Contexts เราคุยกันแล้วว่าเมื่อ context สื่อสารข้ามกันด้วย event ระบบจะเป็น eventual consistency — OrderConfirmed เดินทางมาถึง money context ช้าเร็วต่างกันได้ อาจมาซ้ำ และเราออกแบบให้ทน at-least-once ด้วย idempotent consumer แล้ว นั่นคือ “ข่าวสารว่าออเดอร์ยืนยันแล้ว” ที่ยอมให้ถึงช้าหน่อยได้
แต่ การตัดสินใจว่าหักเงินได้หรือไม่ เป็นคนละเรื่องกันสิ้นเชิง — มันต้องเป็น strong consistency คือ อ่านยอด wallet แล้วตัดสินใจกันเงินภายในธุรกรรมเดียวที่ล็อกกันชัดเจน (synchronous) เหตุผลคือ ถ้าปล่อยให้ขั้นตอน “เช็คยอด” กับ “หักเงิน” หลุดจากกันแบบ eventual สองออเดอร์ที่เข้ามาพร้อมกันอาจต่างอ่านยอดเดิมมาเห็นว่าเงินพอทั้งคู่ แล้วต่างกันเงินก้อนเดียวกันไปคนละที่ — เกิด double-spend เงินโผล่มาจากอากาศ นี่คือปัญหา lost update เดียวกับที่บทที่ 1 เตือนไว้ เพียงแต่คราวนี้อยู่ระหว่างสองออเดอร์
เส้นแบ่งจึงชัด: การ รับรู้ ว่าออเดอร์ยืนยันแล้วเป็น eventual ได้ แต่การ ตัดสินใจและบันทึก การหักเงินต้อง strong และ atomic อยู่ภายในขอบเขตของ ledger aggregate เดียว การรักษายอด wallet ไม่ให้ติดลบเป็น invariant ที่ต้องบังคับ ณ จุด post พอดี ไม่ใช่ไล่ตรวจทีหลัง — ซึ่งเป็นอีกเหตุผลที่ยอดต้องคำนวณจาก fold สดๆ ในธุรกรรมนั้น ไม่ใช่อ่านจาก column ที่อาจล้าสมัย
แก้ที่ผิดด้วย reversing entry ไม่ใช่ลบ
หัวข้อที่มีชื่อว่า “แก้ที่ผิดด้วย reversing entry ไม่ใช่ลบ”ปิดท้ายด้วยกฎที่จะติดตัวไปทุกบทถัดจากนี้: ถ้ากันเงินผิด — กันเกิน กันผิดออเดอร์ หรือทีหลังต้องคืนเพราะยกเลิก — เราจะ ไม่มีวันลบหรือแก้ journal entry เดิม แต่เขียน Reversing EntryReversing EntryJournal Entry ใหม่ที่เขียนขึ้นเพื่อหักล้างรายการเดิมทั้งหมดหรือบางส่วน โดยสลับด้านเดบิต/เครดิตของรายการเดิม เช่น ตอนคืนเงินลูกค้า (refund) แทนที่จะลบรายการจ่ายเงินเดิม ระบบเขียน reversing entry ใหม่ทับ — ประวัติเดิมยังอยู่ครบ ตรวจสอบย้อนหลังได้ว่าเกิดอะไรขึ้นบ้างTactical Design รายการใหม่ที่สลับด้านเดบิต/เครดิตมาหักล้างแทน ประวัติเดิมยังอยู่ครบทุกบรรทัด ตรวจย้อนหลังได้เสมอว่าเกิดอะไรขึ้นบ้าง ด้วยเหตุนี้ Post จึงเป็น idempotent (ยิงซ้ำด้วย key เดิม = no-op) แต่ไม่มี Update หรือ Delete ให้เรียกเลย
เงินที่พักอยู่ใน escrow ตอนนี้กำลังรอเหตุการณ์ถัดไป OrderDelivered เมื่ออาหารถึงมือลูกค้า บทหน้าเราจะเปิดขั้น SettlementSettlementขั้นตอนที่เงินซึ่งพักอยู่ (เช่นใน escrow หรือ clearing) ถูก 'ปิดจบ' อย่างเป็นทางการ — กระจายไปยังบัญชีปลายทางจริงตามสัดส่วนที่ตกลงไว้ เช่น ตัดค่าคอมมิชชัน platform แล้วโอนส่วนที่เหลือเข้าบัญชีร้านอาหารและไรเดอร์ มักเกิดเป็นรอบ (batch) ไม่ใช่ทันทีทุกออเดอร์Process — ปล่อยเงินจาก escrow ตัดค่าคอมมิชชันเข้ารายได้ platform แล้วกระจายส่วนที่เหลือให้เจ้าหนี้ร้านและเจ้าหนี้ไรเดอร์ ทั้งหมดยังเป็น journal entry สมดุลบน ledger เดิมทุกบาท
เจาะลึกแนวคิดในบทนี้ต่อได้ที่คลังอ้างอิง DevIQ:
- Aggregate — ทำไม ledger ถึงเป็นขอบเขต consistency ที่บังคับ invariant (เดบิตรวม = เครดิตรวม, wallet ไม่ติดลบ) แบบ strong ภายในหนึ่งธุรกรรม ขณะที่การสื่อสารข้าม context เป็น eventual
- Guard Clause — การเช็คยอดไม่พอแล้ว reject ตั้งแต่ต้น handler ก่อนจะ post อะไรลง ledger คือ guard ที่รักษา invariant ไว้ไม่ให้ถูกละเมิด
เช็กความเข้าใจ — บทที่ 5
ข้อ 1 / 3ทำไมเงินค่าอาหารต้องพักใน escrow ก่อน แทนที่จะเครดิตบัญชีเจ้าหนี้ร้านทันทีตอน OrderConfirmed?