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

ย้าย​เงิน​แบบ 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 ตัวอย่าง (กำลัง​จัด​ทำ)

code เต็ม​ของ​คอร์ส​นี้​อยู่​ที่ repo kaen-food-ordering (กำลัง​จัด​ทำ) — สนิปเป็ต​ใน​บท​นี้​เป็น​โครง model จริง​ที่บทถัดๆ ไป​จะ​ต่อยอด แต่​ตัว repo ให้​ตาม​ยัง​ทยอย​จัด​ทำ​อยู่ value object Money ยก​มา​จาก​คอร์ส DDD-in-Code ทั้งดุ้น ส่วน gateway/PromptPay ใน​บท​นี้​เป็น​แค่​ภาพประกอบ ไม่ใช่​การ​ต่อ​ระบบ​ชำระ​เงิน​จริง

ก่อน​พูด​เรื่อง 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 เป็น​หนี้สิน​ที่​เพิ่ม​ขึ้น (เครดิต) เท่า​กัน​เป๊ะ รายการ​จึง​สมดุล​และ​บันทึก​ได้ ทีนี้​ปัญหา​ที่แท้​จริง​อยู่​ตรง​จังหวะ “บันทึก” นั่นเอง

ลอง​นึก​ภาพจริง: OrderConfirmed วิ่ง​เข้า​มา​ทาง message broker เรา​ประมวล​ผล​เสร็จ post journal entry ย้าย​เงิน​เรียบร้อย แต่​ตอน​จะ ack กลับ​ไป เครือข่าย​กระตุก broker ไม่​ได้​รับ ack มัน​เลย​ส่ง OrderConfirmed ตัว​เดิม มา​อีกรอบ นี่​ไม่ใช่​เคส​หา​ยาก แต่​เป็น​พฤติกรรม​ปกติ​ของ​การ​ส่ง​แบบ at-least-once — broker การันตี​แค่​ว่า “ส่ง​อย่าง​น้อยหนึ่ง​ครั้ง” ไม่​เคย​การันตี​ว่า “ครั้ง​เดียว​เป๊ะ” เรื่อง​เดียวกัน​เกิด​กับ gateway ด้วย: ตอน​เรียก​เก็บ​เงิน​ผ่าน PromptPay แล้ว timeout ฝั่ง client มัก​ยิง​คำขอ​เดิม​ซ้ำ

version ที่​เผลอ​เขียน​กัน​บ่อย​สุด​คือ​ย้าย​เงิน​ด้วย​การ UPDATE ยอด​ที่​เก็บ​เป็น column — และ​มัน​พัง​หลาย​ชั้น​พร้อม​กัน:

❌ 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

เจาะ​ลึก​แนวคิด​ใน​บท​นี้​ต่อ​ได้ที่​คลัง​อ้างอิง DevIQ:

  • AggregateJournalEntry เป็น aggregate ที่​รักษา invariant “เดบิต​รวม = เครดิต​รวม” ไว้​ใน​ตัว สร้าง​รายการ​ที่​ไม่​สมดุล​ไม่​ได้​ตั้งแต่​แรก
  • Value ObjectAccountId, JournalEntryId และ Money เป็น value object ที่​ห่อ​ค่า​ไว้​ไม่​ให้​ปน​กัน แทน​การ​ส่ง Guid/decimal เปล่าๆ ไป​ทั้ง​ระบบ

เช็กความเข้าใจ — บทที่ 3

ข้อ 1 / 3

ทำไม journal entry ที่ย้ายเงินต้องแนบ idempotency key ที่ผูกกับ event ต้นทาง?