Top-up ด้วย PromptPay/QR
สามบทแรกเราปั้นแกนของระบบเงินไว้ครบแล้ว — LedgerLedgerแหล่งความจริงเดียวที่รักษาประวัติ Journal Entry ทุกรายการของระบบไว้เรียงตามเวลา แบบ append-only ไม่มีการแก้ของเก่า ยอดเงินของบัญชีไหนก็ตามคำนวณได้จากการไล่ผลรวมรายการใน ledger เท่านั้น ไม่ใช่ตัวเลขที่เก็บแยกไว้ต่างหากTactical Design ที่เป็นแหล่งความจริงเดียว ทุกการเคลื่อนของเงินลงเป็นรายการที่สมดุล และยอดคงเหลือคำนวณจากประวัติ ไม่ใช่ column ที่เขียนทับ ถึงตรงนี้ ledger ยังว่างเปล่า — ยังไม่มีเงินก้อนแรกไหลเข้ามาเลย บทนี้คือจังหวะนั้น: ลูกค้าเติมเงินเข้า wallet ผ่าน PromptPay QR เป็นเงินก้อนแรกที่เข้าระบบจริง
แต่การเติมเงินไม่ได้ง่ายเหมือนเรียก method แล้วเงินเข้าทันที เพราะคนที่ “จับเงิน” จริงๆ คือ payment gateway ภายนอก (PromptPay) ที่อยู่นอกบ้านเรา เราสั่งให้มันสร้าง QR ได้ แต่จังหวะที่ลูกค้าจ่ายเงินสำเร็จนั้นเกิด ทีหลัง คนละ request — gateway จะยิงกลับมาบอกเราเองผ่าน webhook บทนี้คือการรับมือกับความ async นั้นให้เงินทุกบาทลง ledger อย่างถูกต้องและไม่ซ้ำ
code เต็มของคอร์สนี้อยู่ที่ repo kaen-food-ordering (กำลังจัดทำ) — บทนี้เริ่มมี code domain จริงให้ตามแล้ว ทั้งโครง ledger จากบทก่อน (Account, JournalEntry, LedgerEntry) และ handler ที่รับ webhook เติมเงิน snippet ในบทวางเรียงให้ประกาศชนิดก่อนใช้เสมอ อ่านไล่จากบนลงล่างได้ตรงๆ
ทวนโครง ledger ที่รายการเติมเงินจะลงไป
หัวข้อที่มีชื่อว่า “ทวนโครง ledger ที่รายการเติมเงินจะลงไป”ก่อนจะรับ webhook มาลงบัญชี ขอทวนของที่บทที่ 2 ปั้นไว้ให้ครบก่อน เพราะรายการเติมเงินจะประกอบขึ้นจากชิ้นส่วนพวกนี้ทั้งหมด
ค่าเงินทุกจำนวนคือ value object MoneyMoneyValue Object ที่แทนจำนวนเงินพร้อมสกุลเงิน (เช่น จำนวน + THB) เปรียบเทียบกันด้วยค่า ไม่ใช่ identity, บวก/ลบกันได้แต่ต้องเป็นสกุลเดียวกัน, immutable — ห้ามใช้ decimal เปล่า ๆ แทนเงินเพราะจะทำให้หน่วยเงินและกฎการปัดเศษหล่นหายไป (Primitive Obsession)Tactical Design ตัวเดิมจากคอร์ส DDD-in-Code — ห่อจำนวนกับสกุลเงินไว้ด้วยกัน และรักษากฎของตัวเองผ่าน primary constructor body เรายกมาใช้ทั้งดุ้นโดยไม่แก้:
// 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); }
public static Money operator *(Money unitPrice, int quantity) => new(unitPrice.Amount * quantity, unitPrice.Currency);}ถัดมาคือตัวระบุของแต่ละหน่วยในระบบ ledger — ห่อเป็น value object ทั้งคู่ ไม่ปล่อยเป็น Guid เปล่าๆ กัน AccountId กับ JournalEntryId สลับกันไม่ได้:
public sealed record AccountId(Guid Value);public sealed record JournalEntryId(Guid Value);ต่อมาคือหน่วยบันทึก — Journal EntryJournal Entryหน่วยที่เล็กที่สุดของการบันทึกใน ledger หนึ่งรายการมีอย่างน้อยสองบรรทัด (เดบิตกับเครดิต) ที่ยอดรวมเท่ากัน เช่น ตอนลูกค้าจ่ายค่าอาหาร เกิด journal entry เดียวที่กระทบทั้งบัญชีเงินสดและบัญชี escrow — เขียนแล้วห้ามแก้ ถ้าผิดต้องเขียน Reversing Entry มาหักล้างแทนTactical Design แต่ละรายการประกอบจาก LedgerEntry หลายบรรทัด (แต่ละบรรทัดคือหนึ่งขา เดบิตหรือเครดิต บนบัญชีหนึ่ง) LedgerEntry เขียนแล้ว immutable แก้ไม่ได้ และ JournalEntry รักษา invariant เหล็กของทั้งระบบไว้ใน constructor — เดบิตรวมต้องเท่ากับเครดิตรวม (★) ไม่งั้นสร้างไม่ขึ้นตั้งแต่แรก:
public enum Direction { Debit, Credit }
// หนึ่งขาของรายการ — immutable แก้ไม่ได้หลังบันทึกpublic sealed record LedgerEntry( AccountId AccountId, Direction Direction, Money Amount, JournalEntryId JournalEntryId);
public sealed class JournalEntry{ public JournalEntryId Id { get; } public string IdempotencyKey { get; } // กันบันทึกซ้ำเมื่อคำขอเดิมถูกยิงซ้ำ public string Reason { get; } // เกิดจากอะไร เช่น "topup:promptpay:<chargeId>" public IReadOnlyList<LedgerEntry> Entries { get; }
public JournalEntry( JournalEntryId id, string idempotencyKey, string reason, IReadOnlyList<LedgerEntry> entries) { var debit = entries.Where(l => l.Direction == Direction.Debit) .Aggregate(Money.Thb(0), (s, l) => s + l.Amount); var credit = entries.Where(l => l.Direction == Direction.Credit) .Aggregate(Money.Thb(0), (s, l) => s + l.Amount);
// ★ invariant: เดบิตรวม = เครดิตรวม — ไม่สมดุลก็สร้างไม่ขึ้น if (debit != credit) throw new InvalidOperationException( $"journal entry ไม่สมดุล: เดบิต {debit.Amount} ≠ เครดิต {credit.Amount}");
Id = id; IdempotencyKey = idempotencyKey; Reason = reason; Entries = entries; }}เมื่อมี Direction กับ LedgerEntry ครบแล้ว ก็ถึง AccountAccountหน่วยที่ใช้แยกแยะว่า 'เงินอยู่ตรงไหน' ในระบบ เช่น บัญชีเงินสด platform, บัญชีลูกหนี้ร้านอาหาร, บัญชี wallet ของลูกค้า, บัญชี escrow ของออเดอร์ — ทุก Journal Entry ต้องระบุว่าเดบิต/เครดิตบัญชีไหนTactical Design — หน่วยที่บอกว่า “เงินอยู่ตรงไหน” แต่ละบัญชีมี id ของตัวเองและมี ประเภท ที่กำหนดว่ามันเป็นสินทรัพย์หรือหนี้สิน จุดสำคัญที่บทที่ 1 ย้ำไว้: บัญชีไม่มี column balance — ยอดคงเหลือคือ Balance as a FoldBalance as a Foldแนวคิดที่ว่ายอดคงเหลือของบัญชีไม่ใช่ตัวเลขที่เก็บไว้ตรง ๆ แต่เป็นผลลัพธ์จากการ 'พับ' (fold) ไล่บวก-ลบทุก Journal Entry ของบัญชีนั้นตั้งแต่ต้นจนถึงปัจจุบัน คล้ายการ replay event ใน Event Sourcing — บัญชีจึงตรวจสอบย้อนกลับได้เสมอว่ายอดเงินมาจากไหนบ้างArchitecture คือ “พับ” ไล่บวก-ลบทุกรายการของบัญชีนั้นออกมา ไม่ใช่ตัวเลขที่เก็บแยก:
public enum AccountType{ CustomerWallet, // หนี้สิน — เงินที่ platform ติดค้างลูกค้าในกระเป๋า RestaurantPayable, // หนี้สิน — ค้างจ่ายร้านอาหาร RiderPayable, // หนี้สิน — ค้างจ่ายไรเดอร์ PlatformRevenue, // รายได้ — ค่าคอมมิชชันที่หักเป็นของ platform GatewayClearing, // สินทรัพย์ — เงินที่ gateway จับไว้ให้ รอโอนเข้าแบงก์จริง Escrow, // พักเงินค่าอาหารระหว่างออเดอร์กำลังส่ง}
public sealed class Account{ public AccountId Id { get; } public AccountType Type { get; }
private readonly IReadOnlyList<LedgerEntry> _entries;
public Account(AccountId id, AccountType type, IReadOnlyList<LedgerEntry> entries) { Id = id; Type = type; _entries = entries; }
// ยอดคงเหลือ 'พับ' มาจากทุกรายการ ไม่ใช่ column ที่เก็บไว้ (ทวนบทที่ 2) public Money Balance() { var plus = NormalSide(Type); // ด้านที่ทำให้บัญชีประเภทนี้ 'เพิ่ม' var total = _entries.Aggregate(0m, (sum, e) => e.Direction == plus ? sum + e.Amount.Amount : sum - e.Amount.Amount); return Money.Thb(total); }
// สินทรัพย์เพิ่มด้วยเดบิต ที่เหลือ (หนี้สิน/รายได้) เพิ่มด้วยเครดิต private static Direction NormalSide(AccountType type) => type switch { AccountType.GatewayClearing => Direction.Debit, // สินทรัพย์เท่านั้น _ => Direction.Credit, // wallet/payable/revenue/escrow };}และปลายทางที่เราจะ “ลง” รายการคือ ledger เอง — เราเรียกมันผ่าน Post ซึ่งเป็น idempotent: ยิง JournalEntry ที่ IdempotencyKey เดิมซ้ำกี่ครั้ง ก็ลงบัญชีจริงแค่ครั้งเดียว:
public interface ILedger{ // idempotent: เจอ IdempotencyKey ที่เคยลงแล้ว จะข้าม ไม่บันทึกซ้ำ Task Post(JournalEntry entry, CancellationToken ct);}ของทั้งหมดนี้ไม่มีอะไรใหม่ในแง่แนวคิด — มันคือชั้นที่บทที่ 2 กับ 3 วางไว้ บทนี้แค่หยิบมาใช้กับเงินก้อนแรกที่ไหลเข้าจริง
ประตูหน้า: gateway สร้าง QR charge
หัวข้อที่มีชื่อว่า “ประตูหน้า: gateway สร้าง QR charge”เราไม่ได้คุยกับ PromptPay ตรงๆ ใน domain — เราคุยผ่าน Anti-Corruption Layer ตัวเดิมที่ห่อ gateway ไว้ที่ขอบ ทวนได้จากคอร์ส Bounded Contexts บทที่ 5 เรื่อง Anti-Corruption Layer — domain เรารู้จักแค่ interface IPaymentGateway ส่วนเบื้องหลังจะเป็น PromptPay, AcmePay หรือของเจ้าไหนก็เรื่องของ adapter ที่ขอบ (ในคอร์สนี้ gateway เป็นแค่ตัวอย่างประกอบ ไม่ได้ต่อของจริง)
ขั้นแรกของการเติมเงินคือขอให้ gateway สร้าง QR ขึ้นมาให้ลูกค้าสแกน — ส่งจำนวนเงิน (เป็น Money) กับ reference ของเราไป แล้วได้ payload ของ QR กลับมา สังเกตว่าสิ่งที่ ACL คืนกลับมาเป็น primitive ล้วน ไม่ใช่ domain object:
// ACL ครอบ gateway ภายนอก (ทวนจากคอร์ส Bounded Contexts) — domain ไม่รู้จัก PromptPay ตรง ๆpublic interface IPaymentGateway{ Task<QrCharge> CreateQrCharge(Money amount, string reference, CancellationToken ct);}
// ผลลัพธ์เป็น primitive ล้วน — ChargeId ใช้อ้างอิงตอน gateway ยืนยันกลับมาทีหลังpublic sealed record QrCharge(string ChargeId, string QrPayload);ถึงตรงนี้ยัง ไม่มีเงินเข้า ledger เลย เราแค่ได้ QR ให้ลูกค้าสแกน — เงินจริงจะขยับก็ต่อเมื่อลูกค้าจ่ายสำเร็จ ซึ่งเป็นคนละเรื่อง คนละเวลา
confirm มาทีหลัง — แถมมาเป็น webhook ข้ามขอบ
หัวข้อที่มีชื่อว่า “confirm มาทีหลัง — แถมมาเป็น webhook ข้ามขอบ”ลูกค้าเปิด app ธนาคาร สแกน QR กดจ่าย — ทั้งหมดนี้เกิดนอกระบบเรา เราไม่มีทางรู้ผลใน request เดิมที่สร้าง QR gateway จะเป็นคนบอกเราเองว่า “จ่ายสำเร็จแล้ว” โดยยิง webhook กลับมาในอีกไม่กี่วินาที (หรือนาที) ข้างหน้า
การยืนยันที่ข้ามขอบมาแบบนี้คือ Integration EventIntegration Eventเหตุการณ์ที่สื่อสารข้าม Bounded Context หรือข้าม service ผ่าน message broker เช่น PaymentCaptured, OrderDelivered ที่ Payment context ต้องรับฟังจาก Ordering context เพื่อรู้ว่าเมื่อไหร่ควรปล่อยเงินจาก escrow — ต่างจาก Domain Event ที่ทำงานเฉพาะภายใน context เดียว (in-process)Architecture — เหตุการณ์ที่สื่อสารข้ามขอบเขต (ข้าม service, ข้าม context) ผ่านช่องทาง async ต่างจาก domain event ที่ทำงานภายใน context เดียว กฎเหล็กของสัญญาข้ามขอบมีข้อเดียวที่ต้องจำ: มันแบก ID กับค่า primitive เท่านั้น ไม่เคยแบก domain object เพราะอีกฝั่งไม่รู้จักชนิดใน domain ของเรา และเราก็ไม่อยากผูกโครงสร้างภายในของเราไว้กับสายส่ง เรื่องนี้คอร์ส Bounded Contexts บทที่ 4 เรื่อง Integration Events วางกฎไว้แล้ว — Payment context ในบ้านเราฟังเหตุการณ์จาก Ordering context อย่าง OrderConfirmed / OrderDelivered ก็ด้วยสัญญาแบบเดียวกันนี้ (จะได้ใช้จริงตอนทำ escrow บทหน้า)
payload ที่ gateway ยิงกลับมาจึงหน้าตาเป็น record ของค่าดิบล้วนๆ:
// สัญญาข้ามขอบ — ID/primitive ล้วน ไม่มี domain object สักตัวpublic sealed record TopUpConfirmed( string ChargeId, // อ้างอิงของ gateway ที่ได้ตอนสร้าง QR Guid CustomerId, // ใครเติม decimal Amount, // เท่าไร (decimal ดิบ ยังไม่ใช่ Money) string Currency, // สกุลเงิน (string ดิบ) string IdempotencyKey); // รหัสกันซ้ำที่ gateway แนบมากับทุกครั้งที่ยิงฝั่งรับต่างหากที่มีหน้าที่ ประกอบ domain object ขึ้นจาก primitive พวกนี้ — เอา Amount กับ Currency มาห่อเป็น Money แล้วให้ Money ตรวจสกุลเงินและความไม่ติดลบให้เองที่ขอบ
ทำไมเงินต้องแวะบัญชีพักก่อนเข้ากระเป๋า
หัวข้อที่มีชื่อว่า “ทำไมเงินต้องแวะบัญชีพักก่อนเข้ากระเป๋า”พอ webhook ยืนยันเข้ามา เราจะลงรายการอะไร? สัญชาตญาณแรกคือ “เดบิตเงินสด platform เครดิต wallet ลูกค้า” แบบที่บทที่ 1 วาดไว้ตอนปูแนวคิด — แต่ตอนนั้นเราจงใจย่อให้ง่าย เพราะยังไม่ได้ model gateway ที่เป็น async ความจริงคือ ณ วินาทีที่ webhook เข้ามา เงินยังไม่ได้อยู่ในบัญชีธนาคารของ platform — มันยังอยู่กับ gateway ที่จะรวมยอดโอนเข้าแบงก์ให้เราเป็นรอบทีหลัง
นี่คือเหตุผลที่ต้องมี Clearing AccountClearing Accountบัญชีพักเงินชั่วคราวระหว่างสองขั้นตอนของธุรกรรม เช่น ตอนลูกค้าจ่ายเงินผ่าน PromptPay เงินจะเข้าบัญชี clearing ก่อน แล้วค่อยกระจายไปบัญชีร้านอาหาร/ไรเดอร์/ค่าคอมมิชชัน platform ทีหลัง — แยกขั้นตอน 'รับเงินเข้า' ออกจาก 'จัดสรรเงิน' ให้ตรวจสอบได้ทีละขั้นTactical Design — บัญชีพักเงินชั่วคราวที่คั่นระหว่างสองจังหวะของธุรกรรม เราตั้งบัญชีชื่อ GatewayClearing (ประเภทสินทรัพย์) ไว้แทน “เงินที่ gateway จับได้แล้วแต่ยังไม่เข้าแบงก์เรา” ตอน webhook ยืนยัน เราลงรายการที่สมดุลด้วยหลัก Debit and CreditDebit and Creditสองด้านของทุก Journal Entry ในระบบ double-entry — เดบิตกับเครดิตไม่ได้แปลว่า 'บวก' หรือ 'ลบ' ตรง ๆ แต่ขึ้นกับประเภทบัญชี สิ่งที่ต้องจริงเสมอคือผลรวมเดบิตของรายการหนึ่งต้องเท่ากับผลรวมเครดิตพอดีTactical Design เดิม:
- เดบิต
GatewayClearing— สินทรัพย์เพิ่ม (gateway ถือเงินให้เราเพิ่มขึ้น) - เครดิต
CustomerWallet— หนี้สินเพิ่ม (platform เป็นหนี้ลูกค้าในกระเป๋าเพิ่มขึ้น)
ทั้งสองขาเท่ากันเป๊ะ รายการจึงสมดุล ส่วนจังหวะที่ gateway โอนเงินสดเข้าแบงก์เราจริงๆ ทีหลัง จะเป็นอีกรายการหนึ่ง (เดบิตเงินสด platform เครดิต GatewayClearing) ที่ทำให้ยอด clearing หักกลับมาเป็นศูนย์ — บัญชีพักจึงทำหน้าที่ แยกจังหวะ “gateway จับเงินได้” ออกจาก “เงินสดเข้าแบงก์” ให้ตรวจสอบได้ทีละขั้น ถ้าวันไหนยอด clearing ค้างไม่ยอมเป็นศูนย์ แปลว่ามีเงินที่ gateway บอกว่ารับแล้วแต่ยังไม่โอนเข้ามา — เห็นปัญหาได้ทันที
flowchart TB
A["ลูกค้าขอเติมเงิน 200 บาท"] --> B["domain สั่ง IPaymentGateway<br/>สร้าง QR charge — ACL ครอบ PromptPay"]
B --> C["ลูกค้าสแกน QR แล้วจ่ายผ่าน app ธนาคาร"]
C -. "จ่ายสำเร็จทีหลัง — คนละ request" .-> D["gateway ยิง webhook กลับ<br/>TopUpConfirmed — ID/primitive ล้วน"]
D --> E{"เคยเห็น IdempotencyKey นี้แล้ว?"}
E -- "เคย" --> F["ข้าม — ไม่เครดิต wallet ซ้ำ"]
E -- "ยังไม่เคย" --> G["ลง Journal Entry ที่สมดุล"]
G --> H["เดบิต GatewayClearing +200<br/>สินทรัพย์เพิ่ม"]
G --> I["เครดิต CustomerWallet +200<br/>หนี้สินเพิ่ม"]
H --- J["เดบิตรวม 200 = เครดิตรวม 200 — สมดุล"]
I --- J
classDef ext 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;
class A,B,C,D ext;
class H dr;
class I cr;
class J bal;
คำบรรยายภาพ: การเติมเงินหนึ่งครั้งเดินเป็นสองช่วงที่คั่นด้วยเส้นประ — ช่วงแรก (น้ำเงิน) เราสร้าง QR ผ่าน ACL แล้วลูกค้าไปจ่ายที่ app ธนาคาร ซึ่งจบ คนละ request กับตอนสร้าง QR เส้นประคือช่องว่าง async ที่เราต้องรอ gateway ยิง webhook กลับมาเอง เมื่อ TopUpConfirmed เข้ามา เราเช็ก IdempotencyKey ก่อน — ถ้าเคยเห็นแล้วก็ข้าม (กันลงซ้ำ) ถ้ายังไม่เคยจึงลง journal entry ที่มีสองขาสมดุล: เดบิต GatewayClearing (เขียว, สินทรัพย์เพิ่ม) เท่ากับเครดิต CustomerWallet (แดง, หนี้สินเพิ่ม) จุดส้มคือ invariant เหล็กที่ constructor บังคับไว้ — เดบิตรวมต้องเท่าเครดิตรวมเสมอ
❌ version ดิบ — เครดิต wallet ตรงๆ ทุกครั้งที่ webhook เข้ามา
หัวข้อที่มีชื่อว่า “❌ version ดิบ — เครดิต wallet ตรงๆ ทุกครั้งที่ webhook เข้ามา”code ที่พังแบบเงียบๆ มักหน้าตาสมเหตุสมผลที่สุด webhook เข้ามาก็บวกยอดเข้ากระเป๋าลูกค้าเลย จบ:
// ❌ version ดิบ — ไม่มีบัญชีพัก ไม่เช็คว่าเคยรับ webhook นี้แล้วหรือยังpublic async Task HandleNaive(TopUpConfirmed msg){ var wallet = await _db.Wallets.FindAsync(msg.CustomerId); wallet.Balance += msg.Amount; // ← เขียนทับ column ยอด + ไม่กันการรับซ้ำ await _db.SaveChangesAsync();}code นี้ทำงานได้ในเดโมที่ webhook เข้ามาครั้งเดียวเป๊ะๆ แต่พังในสองจุดที่แก้ทีหลังไม่ได้:
- เครดิตซ้ำเมื่อ webhook เข้าซ้ำ — การส่ง webhook เป็นแบบ at-least-once เสมอ ถ้า gateway ยิงมาแล้วไม่ได้รับ ack ทันเวลา (เน็ตกระตุก, เรา deploy อยู่พอดี) มันจะยิงซ้ำ handler นี้ไม่ได้จำว่าเคยรับ
IdempotencyKeyนี้แล้ว จึงบวกเงินให้ลูกค้าสองรอบ — เงินโผล่มาจากอากาศ กฎ “เงินไม่มีวันหายหรือโผล่เอง” แตกทันที - ไม่มีบัญชีพัก ไม่มีสองขา — บวกยอด column เดียวคือกลับไปหาปัญหาเดิมทั้งหมดของบทที่ 1: เขียนทับกันเอง ไม่มีประวัติให้ตรวจ และไม่มีร่องรอยว่าเงินก้อนนี้ยัง “ค้าง” อยู่ที่ gateway หรือเข้าแบงก์แล้ว
บันทึกให้เชื่อถือได้ — outbox บวก webhook ที่ idempotent
หัวข้อที่มีชื่อว่า “บันทึกให้เชื่อถือได้ — outbox บวก webhook ที่ idempotent”ทางแก้ไม่ใช่เขียน handler ให้ระวังมากขึ้น แต่คือ ออกแบบให้การรับซ้ำไม่ก่อผลซ้ำตั้งแต่ราก สองชิ้นประกอบกัน:
ชิ้นแรก — ทุก webhook แบก Idempotency KeyIdempotency Keyรหัสเฉพาะที่แนบไปกับคำขอทำธุรกรรม (เช่น เรียกเก็บเงินผ่าน PromptPay) เพื่อให้ระบบรู้ว่าคำขอที่ถูกส่งซ้ำ (retry) คือคำขอเดิม ไม่ใช่ธุรกรรมใหม่ — กันไม่ให้ลูกค้าโดนหักเงินซ้ำเวลาเครือข่ายมีปัญหาแล้ว client ยิงคำขอเดิมซ้ำArchitecture มาด้วย (msg.IdempotencyKey) เราเอา key นี้ไปเป็น key ของ JournalEntry แล้วให้ ILedger.Post เป็นคนกันซ้ำ: ถ้าเคยลง key นี้แล้วก็ข้าม นี่คือ Idempotent ConsumerIdempotent Consumerตัวรับ event หรือ message ที่ประมวลผลข้อความเดิมซ้ำกี่ครั้งก็ได้ผลลัพธ์เหมือนเดิม ไม่สร้างผลข้างเคียงซ้ำ เช่น รับ PaymentCaptured ซ้ำสองครั้งจาก message broker ต้องไม่บันทึก journal entry สองรอบ — ตรวจด้วย Idempotency Key หรือเช็คว่าเคยประมวลผลรายการนี้ไปแล้วหรือยังArchitecture — ตัวรับที่ประมวลผลข้อความเดิมซ้ำกี่ครั้งก็ได้ผลเท่าเดิม เราไม่ได้คิดกลไกนี้ใหม่ มันคือของที่คอร์ส Bounded Contexts บทที่ 6 เรื่อง Outbox & Reliability สอนไว้ — ฝั่งส่งใช้ outbox ยิงเหตุการณ์ให้ถึงแน่ๆ (at-least-once) ฝั่งรับจึงต้อง dedupe ด้วย key เพื่อกันผลซ้ำ บทนี้แค่หยิบมาใช้กับเงิน
handler ที่ถูกต้องจึงประกอบ domain object จาก primitive แล้วสร้าง journal entry สองขาที่สมดุล ส่งให้ Post เป็นคนลงแบบ idempotent:
public interface IWalletDirectory{ AccountId WalletOf(Guid customerId); // หา AccountId ของ wallet ลูกค้า}
public sealed class TopUpConfirmedHandler{ private readonly ILedger _ledger; private readonly IWalletDirectory _wallets;
// บัญชีพักกลางตัวเดียวของ platform (ประเภท GatewayClearing) private static readonly AccountId GatewayClearing = new(new Guid("00000000-0000-0000-0000-0000000000c1"));
public TopUpConfirmedHandler(ILedger ledger, IWalletDirectory wallets) { _ledger = ledger; _wallets = wallets; }
public async Task Handle(TopUpConfirmed msg, CancellationToken ct) { // ประกอบ Money จาก primitive — Money ปฏิเสธเองถ้าสกุลไม่ใช่ THB หรือติดลบ var amount = new Money(msg.Amount, msg.Currency); var walletId = _wallets.WalletOf(msg.CustomerId); var jid = new JournalEntryId(Guid.NewGuid());
// เติมเงิน = เดบิต GatewayClearing (สินทรัพย์เพิ่ม) / เครดิต CustomerWallet (หนี้สินเพิ่ม) var entry = new JournalEntry( id: jid, idempotencyKey: msg.IdempotencyKey, // ← ใช้ key จาก gateway ยิงซ้ำก็ key เดิม reason: $"topup:promptpay:{msg.ChargeId}", entries: new[] { new LedgerEntry(GatewayClearing, Direction.Debit, amount, jid), new LedgerEntry(walletId, Direction.Credit, amount, jid), });
// Post เป็น idempotent — เจอ IdempotencyKey เดิมจะข้าม ไม่เครดิต wallet ซ้ำ await _ledger.Post(entry, ct); }}ผลรวมของ design นี้: webhook เข้าซ้ำสิบครั้ง Post ก็ลงบัญชีจริงแค่ครั้งเดียว เพราะ IdempotencyKey เดิม และเงินก้อนนี้มีร่องรอยชัดว่ายังพักอยู่ที่ GatewayClearing จนกว่าจะ settle เข้าแบงก์
ปิดท้ายด้วยกฎที่ห้ามลืม: ถ้าภายหลัง gateway แจ้งว่าธุรกรรมนี้ถูกยกเลิกหรือตีกลับ เรา ไม่แก้และไม่ลบ journal entry เดิม แต่เขียน Reversing EntryReversing EntryJournal Entry ใหม่ที่เขียนขึ้นเพื่อหักล้างรายการเดิมทั้งหมดหรือบางส่วน โดยสลับด้านเดบิต/เครดิตของรายการเดิม เช่น ตอนคืนเงินลูกค้า (refund) แทนที่จะลบรายการจ่ายเงินเดิม ระบบเขียน reversing entry ใหม่ทับ — ประวัติเดิมยังอยู่ครบ ตรวจสอบย้อนหลังได้ว่าเกิดอะไรขึ้นบ้างTactical Design — รายการใหม่ที่สลับด้านเดบิต/เครดิตมาหักล้าง (เดบิต CustomerWallet เครดิต GatewayClearing) ประวัติเดิมยังอยู่ครบทุกบรรทัด ตรวจย้อนหลังได้ว่าเกิดอะไรขึ้น — สอดคล้องกับ ledger ที่เป็น append-only มาตั้งแต่บทแรก
สิ่งที่จะทำต่อ
หัวข้อที่มีชื่อว่า “สิ่งที่จะทำต่อ”ตอนนี้เงินก้อนแรกเข้า wallet ลูกค้าแล้วอย่างเชื่อถือได้ บทหน้าจะให้ลูกค้า จ่ายค่าอาหารจริง — เงินจาก CustomerWallet จะไม่ได้วิ่งตรงไปหาร้าน แต่ถูกกันไว้ในบัญชี escrow ระหว่างที่ออเดอร์กำลังส่ง แล้วรอเหตุการณ์ OrderDelivered ข้ามขอบเข้ามาก่อนจึงปล่อยเงินออก — เป็นจุดที่ integration event จากคอร์ส Bounded Contexts ได้ลงมือใช้จริงเต็มตัว
เจาะลึกแนวคิดในบทนี้ต่อได้ที่คลังอ้างอิง DevIQ:
- Anti-Corruption Layer — ทำไม gateway ภายนอกอย่าง PromptPay ต้องถูกห่อด้วย ACL ที่ขอบ ไม่ให้รูปทรงของ gateway รั่วเข้ามาปนกับ model ledger ในบ้าน
- Domain Events — พื้นฐานของ event ใน domain ใช้เทียบให้เห็นเส้นแบ่งกับ integration event ที่ข้ามขอบ (in-process เทียบกับข้าม context/ผ่าน broker)
เช็กความเข้าใจ — บทที่ 4
ข้อ 1 / 3บัญชีพัก GatewayClearing ทำหน้าที่อะไรตอนลูกค้าเติมเงินผ่าน PromptPay?