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

model Ledger

บท​ที่​แล้ว​เรา​สรุป​หลักการ​ไว้​สอง​ข้อ — เงิน​ทุก​การ​เคลื่อนไหว​ต้อง​บันทึก​เป็น​สอง​ขา​ที่​สมดุล และ LedgerLedgerแหล่ง​ความ​จริง​เดียว​ที่​รักษา​ประวัติ Journal Entry ทุก​รายการ​ของ​ระบบ​ไว้​เรียง​ตาม​เวลา แบบ append-only ไม่มี​การ​แก้​ของ​เก่า ยอด​เงิน​ของ​บัญชี​ไหน​ก็ตาม​คำนวณ​ได้​จาก​การ​ไล่​ผล​รวม​รายการ​ใน ledger เท่านั้น ไม่ใช่​ตัวเลข​ที่​เก็บ​แยก​ไว้​ต่างหากTactical Design คือ​แหล่ง​ความ​จริง​เดียว​ที่​เก็บ​ทุก​รายการ​ต่อ​ท้าย​กัน​ไป​เรื่อยๆ ห้าม​แก้ ห้าม​ลบ ส่วน​ยอด​เงิน​คง​เหลือ​ไม่ใช่​ตัวเลข​ที่​เก็บไว้ตรงๆ แต่​เป็น​สิ่ง​ที่​คำนวณ​จาก​ประวัติ บท​นี้​เรา​จะ​แปล​หลักการ​เหล่า​นั้น​ให้​กลาย​เป็น​รูปทรง C# จริง — ปั้น ledger ให้​เป็น aggregate ที่ “รักษา​กฎ​สมดุล​ไว้​ใน​ตัวเอง” คือ​ถ้า​รายการ​ไหน​ไม่​สมดุล มัน​ต้อง​สร้าง​ไม่​ขึ้น​ตั้งแต่​แรก ไม่ใช่​รอ​ไป​ตรวจ​เจอ​ทีหลัง​ตอน​เงิน​หาย​ไป​แล้ว

model ของ​เรา​มี​ตัว​ละคร​ที่​ต่อ​กัน​เป็นชั้นๆ: AccountAccountหน่วย​ที่​ใช้​แยกแยะ​ว่า 'เงิน​อยู่​ตรง​ไหน' ใน​ระบบ เช่น บัญชี​เงินสด platform, บัญชี​ลูกหนี้​ร้าน​อาหาร, บัญชี wallet ของ​ลูกค้า, บัญชี escrow ของออเดอร์ — ทุก Journal Entry ต้อง​ระบุ​ว่า​เดบิต/เครดิต​บัญชี​ไหนTactical Design บอกว่า​เงิน​อยู่​ตรง​ไหน, LedgerEntry คือ​บรรทัด​เดียว​ที่​บันทึก​ว่า​เดบิต​หรือ​เครดิต​บัญชี​ไหน​เท่าไร, Journal EntryJournal Entryหน่วย​ที่​เล็ก​ที่สุด​ของ​การ​บันทึก​ใน ledger หนึ่ง​รายการ​มี​อย่าง​น้อย​สอง​บรรทัด (เดบิต​กับ​เครดิต) ที่​ยอด​รวม​เท่า​กัน เช่น ตอน​ลูกค้า​จ่าย​ค่า​อาหาร เกิด journal entry เดียว​ที่​กระทบ​ทั้ง​บัญชี​เงินสด​และ​บัญชี escrow — เขียน​แล้ว​ห้าม​แก้ ถ้า​ผิด​ต้อง​เขียน Reversing Entry มา​หักล้าง​แทนTactical Design คือ​ชุด​ของ LedgerEntry ที่​ต้อง​สมดุล​กัน​ทั้ง​ก้อน และ ledger คือ aggregate ที่​ถือ log ทั้งหมด​ไว้​แล้ว​เปิด​ให้ “post” journal entry ใหม่​ต่อ​ท้าย​ได้​อย่าง​เดียว เรา​จะ​ไล่​สร้าง​จาก​ชิ้น​เล็ก​ที่สุด​ไป​หา​ก้อน​ใหญ่

📦 code ตัวอย่าง (กำลัง​จัด​ทำ)

code เต็ม​ของ​คอร์ส​นี้​อยู่​ที่ repo kaen-food-ordering (กำลัง​จัด​ทำ) — บท​นี้​เริ่ม​ปั้น​โครง domain ของ money context จริง รูปทรง C# ของ Ledger, Account, LedgerEntry และ JournalEntry ที่​เห็น​ใน​บท​เป็น​ตัวอย่าง​ประกอบ​ให้​เห็น​ภาพ ส่วน value object Money ยก​มา​จาก​คอร์ส DDD-in-Code ทั้งดุ้น (path เดิม FoodOrdering.Domain/Orders/Money.cs)

aggregate ใน​ภาษา DDD คือ​กลุ่ม​ของ object ที่​มี “ผู้​รักษา​กฎ” คน​เดียว​คอย​ดูแล​ว่า​สถานะ​ข้าง​ใน​ถูก​กติกา​เสมอ สำหรับ money context ของ​เรา ผู้​รักษา​กฎ​คน​นั้น​คือ ledger — มัน​ถือ​รายการ​ทั้งหมด​ไว้​เป็น Append-only LogAppend-only Logโครงสร้าง​การ​เก็บ​ข้อมูล​ที่ 'เขียน​เพิ่ม​ได้​อย่าง​เดียว' ห้าม UPDATE หรือ DELETE รายการ​เก่า — ledger ของ​ระบบ​การเงิน​ต้อง​เป็น​แบบ​นี้​เสมอ เพราะ​ประวัติ​เงิน​ต้อง​ตรวจสอบ​ย้อนหลัง​ได้​ทุก​บรรทัด ต่าง​จาก​ตาราง​ทั่วไป​ที่​แก้​ทับ​ค่า​เดิม​ได้Architecture คือ​ต่อ​ท้าย​ได้​อย่าง​เดียว ไม่มี method ไหน​เปิด​ให้ UPDATE หรือ DELETE รายการ​เก่า​เลย และ​มัน​ยอมรับ journal entry ใหม่​ก็​ต่อ​เมื่อ​รายการ​นั้น​สมดุล​แล้ว​เท่านั้น

จุด​นี้​สำคัญ: กฎ “เงิน​ไม่มี​วัน​หาย” ถูก​บังคับ​ไว้​ที่​ขอบ​ของ aggregate เดียว ไม่​รั่ว​ออก​ไป​ให้ code ส่วน​อื่น​เผลอ​ทำ​พัง code ที่​เรียก​ใช้ ledger ทำได้​อย่าง​เดียว​คือ​ยื่น journal entry ที่​สมดุล​แล้ว​เข้าไป post จะ​แอบ​ไป​ขยับ​ยอดบัญชีตรงๆ ไม่​ได้ เดี๋ยว​เรา​จะ​เห็น code ของ ledger ตอน​พูด​เรื่อง Post แต่​ก่อน​อื่น​มา​ปั้น​ชิ้น​ส่วน​ที่​มัน​ถือ​ไว้​ให้​ครบ​ก่อน

Account คือ​หน่วย​ที่​ตอบ​ว่า “เงิน​ก้อน​นี้​อยู่​ตรง​ไหน​ใน​ระบบ” money context ของ​เรา​มี​บัญชี​อยู่​หก​ประเภท​ตายตัว ทั้ง​คอร์ส​จะ​วน​อยู่​กับ​กลุ่ม​นี้ ไม่มี​การ​สร้าง​ประเภท​ใหม่​พร่ำเพรื่อ เรา​จึง​ล็อก​มัน​ไว้​ใน enum:

// AccountId — value object ห่อ Guid ไว้ ไม่ให้ปนกับ id ชนิดอื่นใน domain
public sealed record AccountId(Guid Value);
// หกประเภทบัญชีของ money context นี้
public enum AccountType
{
CustomerWallet, // wallet ลูกค้า — platform ติดค้างลูกค้า (หนี้สิน)
RestaurantPayable, // เจ้าหนี้ร้านอาหาร — เงินที่ติดค้างร้าน (หนี้สิน)
RiderPayable, // เจ้าหนี้ไรเดอร์ — เงินที่ติดค้างไรเดอร์ (หนี้สิน)
PlatformRevenue, // รายได้ platform — ค่าคอมมิชชันที่หักเก็บเป็นของตัวเอง
GatewayClearing, // บัญชีพักตอนรับเงินเข้าจาก gateway (สินทรัพย์)
Escrow, // บัญชีพักเงินค่าอาหารระหว่างออเดอร์กำลังส่ง (หนี้สิน)
}

สังเกต​ว่า​บัญชี​แต่ละ​ประเภท​มี “ด้าน​ปกติ” ที่​ทำให้​ยอด​เพิ่ม​ต่าง​กัน ตาม​ที่​บท​ที่ 1 ปู​ไว้: บัญชี​ฝั่ง​หนี้สิน​และ​รายได้ (wallet, เจ้าหนี้​ร้าน, เจ้าหนี้​ไร​เด​อร์, รายได้ platform, escrow) เครดิต​ทำให้​ยอด​เพิ่ม ส่วน​บัญชี​ฝั่ง​สินทรัพย์ (GatewayClearing) เดบิต​ทำให้​ยอด​เพิ่ม เรา​จะ​เอา​ความ​รู้​ข้อ​นี้​ไป​ใช้​ตอน​คำนวณ​ยอด​คง​เหลือ

สอง​ใน​หก​ประเภท​เป็น​บัญชี​พัก​เงิน​ชั่วคราว — Clearing AccountClearing Accountบัญชี​พัก​เงิน​ชั่วคราว​ระหว่าง​สอง​ขั้นตอน​ของ​ธุรกรรม เช่น ตอน​ลูกค้า​จ่าย​เงิน​ผ่าน PromptPay เงิน​จะ​เข้า​บัญชี clearing ก่อน แล้ว​ค่อย​กระจาย​ไป​บัญชี​ร้าน​อาหาร/ไร​เด​อร์/ค่า​คอม​มิช​ชัน platform ทีหลัง — แยก​ขั้นตอน 'รับ​เงิน​เข้า' ออก​จาก 'จัดสรร​เงิน' ให้​ตรวจสอบ​ได้ที​ละ​ขั้นTactical Design คือ GatewayClearing พัก​เงิน​ตอน​เพิ่ง​รับ​เข้า​จาก gateway ก่อน​กระจาย​ต่อ และ Escrow AccountEscrow Accountบัญชี​ที่​กัน​เงิน​ไว้​รอ​เงื่อนไข​ก่อน​ปล่อย​ให้​ผู้รับ​ตัว​จริง เช่น เงิน​ค่า​อาหาร​ที่​ลูกค้า​จ่าย​จะ​พัก​อยู่​ใน​บัญชี escrow จน​กว่า​ออเดอร์​จะ​ถูก​ส่ง​มอบ​สำเร็จ ค่อย​ปล่อย​เงิน​ให้​ร้าน​อาหาร​กับ​ไร​เด​อร์ — รักษา​สิทธิ์​ทั้ง​สอง​ฝ่าย​ไว้​จนกว่า​เงื่อนไข​จะ​สำเร็จ ถ้า​ออเดอร์​ถูก​ยกเลิก​ก่อน​ส่ง​มอบ เงิน​ใน escrow จะ​ถูก​คืน​แทนที่​จะ​จ่าย​ออก​ไปTactical Design คือ Escrow กัน​เงิน​ค่า​อาหาร​ไว้​ระหว่างออเดอร์​กำลัง​ส่ง ทั้ง​สอง​จะ​ได้​บท​เต็ม​ของ​ตัวเอง​ข้าง​หน้า บท​นี้​แค่​จอง​ที่​ไว้​ใน model ก่อน

ก่อน​จะ​ไป​ถึง journal entry ขอ​ทวน MoneyMoneyValue Object ที่​แทน​จำนวน​เงิน​พร้อม​สกุล​เงิน (เช่น จำนวน + THB) เปรียบเทียบ​กัน​ด้วย​ค่า ไม่ใช่ identity, บวก/ลบ​กัน​ได้​แต่​ต้อง​เป็น​สกุล​เดียวกัน, immutable — ห้าม​ใช้ decimal เปล่า ๆ แทน​เงิน​เพราะ​จะ​ทำให้​หน่วย​เงิน​และ​กฎ​การ​ปัดเศษ​หล่น​หาย​ไป (Primitive Obsession)Tactical Design จาก​บท​ที่ 1 สั้นๆ ก่อน เพราะ​ทุก LedgerEntry อ้าง​ถึง​มัน — เงิน​ใน​ระบบ​นี้​ไม่ใช่ decimal เปล่าๆ แต่​เป็น value object ที่​ห่อ​จำนวน​กับ​สกุล​เงิน​ไว้​ด้วย​กัน​และ​บวก​กัน​ได้:

// ทวน Money จากบทที่ 1 (ยกมาจากคอร์ส DDD-in-Code ทั้งดุ้น)
// 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);
}
}

ทีนี้ LedgerEntry คือ​บรรทัด​เดียว​ใน ledger ที่​บอกว่า “รายการ​นี้​เดบิต​หรือ​เครดิต​บัญชี​ไหน จำนวน​เท่าไร และ​สังกัด journal entry ใบ​ไหน” หัวใจ​ของ​มัน​คือ ImmutabilityImmutabilityคุณสมบัติ​ที่​ข้อมูล​เปลี่ยนแปลง​ไม่​ได้​หลัง​ถูก​บันทึก — Journal Entry ที่​เขียน​ลง ledger แล้ว​ต้อง immutable ตลอด​ไป ถ้า​ธุรกรรม​ผิดพลาด​ก็​เขียน​รายการ​ใหม่​มา​หักล้าง ไม่ใช่​แก้​ของ​เดิม เพราะ​การ​แก้​ของ​เดิม​ทำให้​ตรวจสอบ (audit) เชื่อถือ​ไม่​ได้​อีก​ต่อ​ไปTactical Design — เขียน​ลง​ไป​แล้ว​ต้อง​แก้​ไม่​ได้​ตลอด​กาล เรา​จึง​ใช้ record ที่​รับ​ค่า​ครบ​ตอน​สร้าง​แล้ว​ไม่มี setter ให้​ขยับ:

// JournalEntryId — value object ห่อ Guid ของ journal entry ที่รายการนี้สังกัด
public sealed record JournalEntryId(Guid Value);
public enum Direction { Debit, Credit }
// LedgerEntry — บรรทัดเดียวใน ledger, immutable: เขียนแล้วห้ามแก้/ลบ
public sealed record LedgerEntry(
AccountId AccountId,
Direction Direction,
Money Amount,
JournalEntryId JournalEntryId);

Direction มี​แค่ Debit กับ Credit — ไม่มี​ค่าที่​สาม ไม่มี​เครื่องหมาย​บวก​ลบ​ให้​เผลอ​ใส่​ผิด ส่วน JournalEntryId ที่​ผูก​ติด​ไว้​ทำให้​ทุก​บรรทัด​รู้​ว่า​ตัวเอง​เกิด​จาก​ธุรกรรม​ใบ​ไหน เวลา​ไล่ audit ย้อนหลัง​จึง​จับ​กลุ่ม​กลับ​มา​เป็น​รายการ​เดียว​ได้​เสมอ

journal entry หนึ่ง​ใบ​คือ​ชุด​ของ LedgerEntry ที่​ต้อง​สมดุล​กัน​ทั้ง​ก้อน — นี่​คือ​ที่​ที่ invariant ★ หัวใจ​ของ​ทั้ง​คอร์ส​อยู่: ผล​รวม​ของ​ขา​เดบิต​ต้อง​เท่ากับ​ผล​รวม​ของ​ขา​เครดิต​พอดี ถ้า​ไม่​เท่า แปล​ว่า​รายการ​นั้น​ทำ​เงิน​หาย​หรือ​เสก​เงิน​ขึ้น​มา​เอง เรา​จึง​บังคับ​กฎ​นี้​ไว้​ใน constructor: สร้าง JournalEntry ที่​ไม่​สมดุล​ไม่​ได้​เลย object แบบ​นั้น​เกิด​ขึ้น​ใน​หน่วย​ความ​จำ​ไม่​ได้​ตั้งแต่​แรก

public sealed class JournalEntry
{
public JournalEntryId Id { get; }
public string IdempotencyKey { get; } // กัน post ซ้ำ — ทวนแนวคิดจากคอร์ส Bounded Contexts
public string Reason { get; } // เหตุผลของรายการ ไว้ตรวจย้อนหลังว่าเกิดอะไร
public IReadOnlyList<LedgerEntry> Entries { get; }
public JournalEntry(
JournalEntryId id,
string idempotencyKey,
string reason,
IReadOnlyList<LedgerEntry> entries)
{
if (entries.Count < 2)
throw new ArgumentException("journal entry ต้องมีอย่างน้อยสองขา", nameof(entries));
Money debits = entries.Where(e => e.Direction == Direction.Debit)
.Aggregate(Money.Thb(0), (sum, e) => sum + e.Amount);
Money credits = entries.Where(e => e.Direction == Direction.Credit)
.Aggregate(Money.Thb(0), (sum, e) => sum + e.Amount);
// ★ invariant หัวใจของทั้งคอร์ส: เดบิตรวมต้องเท่ากับเครดิตรวมพอดี
if (debits != credits)
throw new InvalidOperationException(
$"journal entry ไม่สมดุล: เดบิตรวม {debits.Amount} ≠ เครดิตรวม {credits.Amount}");
Id = id;
IdempotencyKey = idempotencyKey;
Reason = reason;
Entries = entries;
}
}

เพราะ Money เป็น value object ที่​เทียบเท่า​กัน​ด้วย​ค่า บรรทัด debits != credits จึง​เทียบ​ยอด​สอง​ก้อนได้ตรงๆ ไม่​ต้อง​กังวล​เรื่อง​หน่วย​เงิน​หรือ​การ​ปัดเศษ และ​เพราะ Money.Thb(0) เป็น seed ของ​การ​พับ ทุก​ขา​จึง​ถูก​บวก​เข้า​ด้วย operator + ตัว​เดิม กฎ​สมดุล​จึง​กลาย​เป็น​เงื่อนไข​เดียวสั้นๆ ที่​ผ่าน​หรือ​ไม่​ผ่าน​ก็​รู้ทันที​ตอน​สร้าง object

มา​ถึง​ตัว aggregate เอง Ledger ถือ log ไว้​เป็น List ที่​มี​ทาง​เข้า​ออก​อย่าง​เดียว​คือ “ต่อ​ท้าย” และ​เปิด method Post ให้​รับ journal entry ที่​สมดุล​แล้ว​เข้า​มา​บันทึก:

public sealed class Ledger
{
private readonly List<LedgerEntry> _log = new(); // append-only: ต่อท้ายอย่างเดียว
private readonly HashSet<string> _postedKeys = new(); // จำ idempotency key ที่เคย post
public IReadOnlyList<LedgerEntry> Entries => _log;
// Post เป็น idempotent: post journal entry ที่มี key เดิมซ้ำ ไม่เพิ่มรายการซ้ำ
public void Post(JournalEntry entry)
{
if (!_postedKeys.Add(entry.IdempotencyKey))
return; // เคย post key นี้แล้ว — ข้าม ไม่ต่อรายการซ้ำ
_log.AddRange(entry.Entries); // ต่อท้าย log เท่านั้น ไม่เคยแก้/ลบของเก่า
}
}

ตรง​นี้​เรา​ยืม​ของ​จาก​คอร์ส Bounded Contexts มา​โดย​ไม่​รื้อ​สอน​ใหม่ ใน​ทาง​ปฏิบัติ ledger ไม่​ได้ post รายการ​เอง แต่​ทำงาน​ตอบ​สนอง Integration EventIntegration Eventเหตุการณ์​ที่​สื่อสาร​ข้าม Bounded Context หรือ​ข้าม service ผ่าน message broker เช่น PaymentCaptured, OrderDelivered ที่ Payment context ต้อง​รับฟัง​จาก Ordering context เพื่อ​รู้​ว่า​เมื่อไหร่​ควร​ปล่อย​เงิน​จาก escrow — ต่าง​จาก Domain Event ที่​ทำงาน​เฉพาะ​ภายใน context เดียว (in-process)Architecture ที่ Ordering context ส่ง​ข้าม​มา เช่น OrderConfirmed (ยืนยัน​ออเดอร์​แล้ว) กับ OrderDelivered (ส่ง​ถึง​มือ​ลูกค้า​แล้ว) — สัญญา​ของ event พวก​นี้​มี​แต่ ID กับ​ค่า primitive ตาม​ที่​คอร์ส​นั้น​วาง​ไว้ ส่วนตัว payment gateway อย่าง PromptPay อยู่​หลัง ACL (IPaymentGateway) เป็น​แค่​ภาพประกอบ​ใน​คอร์ส​นี้ ไม่ใช่​การ​ต่อ​จริง

เพราะ event เดิม​อาจ​ถูก​ส่ง​ซ้ำ​เวลา​เครือข่าย​มี​ปัญหา เรา​จึง​ต้อง​เป็น Idempotent ConsumerIdempotent Consumerตัว​รับ event หรือ message ที่​ประมวล​ผล​ข้อความ​เดิม​ซ้ำ​กี่​ครั้ง​ก็ได้​ผลลัพธ์​เหมือน​เดิม ไม่​สร้าง​ผล​ข้าง​เคียง​ซ้ำ เช่น รับ PaymentCaptured ซ้ำ​สอง​ครั้ง​จาก message broker ต้อง​ไม่​บันทึก journal entry สอง​รอบ — ตรวจ​ด้วย Idempotency Key หรือ​เช็ค​ว่า​เคย​ประมวล​ผล​รายการ​นี้​ไป​แล้ว​หรือ​ยังArchitecture คือ​รับ​ข้อความ​เดิม​ซ้ำ​กี่​ครั้ง​ก็​ให้​ผล​เหมือน​เดิม กลไก​ที่​ใช้​คือ Idempotency KeyIdempotency Keyรหัส​เฉพาะ​ที่​แนบ​ไป​กับ​คำขอ​ทำ​ธุรกรรม (เช่น เรียก​เก็บ​เงิน​ผ่าน PromptPay) เพื่อ​ให้​ระบบ​รู้​ว่า​คำขอ​ที่​ถูก​ส่ง​ซ้ำ (retry) คือ​คำขอ​เดิม ไม่ใช่​ธุรกรรม​ใหม่ — กัน​ไม่​ให้​ลูกค้า​โดน​หัก​เงิน​ซ้ำ​เวลา​เครือข่าย​มี​ปัญหา​แล้ว client ยิง​คำขอ​เดิม​ซ้ำArchitecture ที่​ติด​มา​กับ journal entry — Post เช็ค​ก่อน​ว่า​เคย​เห็น key นี้​ไหม ถ้า​เคย​ก็​ข้าม​ไป​เงียบๆ ไม่​บันทึก​รายการ​ซ้ำ ลูกค้า​จึง​ไม่​โดน​หัก​เงิน​สอง​รอบ​เพราะ retry

แล้ว​ถ้า post ผิด​ล่ะ ลบ​ทิ้ง​ได้​ไหม — ไม่​ได้​เด็ดขาด การ​แก้​หรือ​ลบ​ทำลาย​ประวัติ​ที่ audit ได้ วิธี​ที่​ถูกต้อง​คือ​เขียน Reversing EntryReversing EntryJournal Entry ใหม่​ที่​เขียน​ขึ้น​เพื่อ​หักล้าง​รายการ​เดิม​ทั้งหมด​หรือ​บาง​ส่วน โดย​สลับ​ด้าน​เดบิต/เครดิต​ของ​รายการ​เดิม เช่น ตอน​คืน​เงิน​ลูกค้า (refund) แทนที่​จะ​ลบ​รายการ​จ่าย​เงิน​เดิม ระบบ​เขียน reversing entry ใหม่​ทับ — ประวัติ​เดิม​ยัง​อยู่​ครบ ตรวจสอบ​ย้อนหลัง​ได้​ว่า​เกิด​อะไร​ขึ้น​บ้างTactical Design — journal entry ใหม่​ที่​สลับ​ด้าน​เดบิต/เครดิต​ของ​รายการ​เดิม​มา​หักล้าง ประวัติ​เดิม​ยัง​อยู่​ครบ เห็น​ได้​ว่า​เกิด​อะไร​ขึ้น​และ​ถูก​แก้​ยังไง เรื่อง​นี้​จะ​มี​บท​เต็ม​ตอน​พูด​ถึง refund ข้าง​หน้า

คำถาม​สุดท้าย: แล้ว “ยอด​เงิน​ใน​บัญชี” มา​จาก​ไหน คำ​ตอบ​คือ Balance as a FoldBalance as a Foldแนวคิด​ที่​ว่ายอด​คง​เหลือ​ของ​บัญชี​ไม่ใช่​ตัวเลข​ที่​เก็บ​ไว้​ตรง ๆ แต่​เป็น​ผลลัพธ์​จาก​การ 'พับ' (fold) ไล่​บวก-ลบ​ทุก Journal Entry ของ​บัญชี​นั้น​ตั้งแต่​ต้น​จนถึง​ปัจจุบัน คล้าย​การ replay event ใน Event Sourcing — บัญชี​จึง​ตรวจสอบ​ย้อน​กลับ​ได้​เสมอ​ว่ายอด​เงิน​มา​จาก​ไหน​บ้างArchitecture — ยอด​คง​เหลือ​ไม่ใช่ column ที่​เก็บ​ไว้ แต่​เป็น​ผล​ของ​การ “พับ” (fold) ไล่​บวก-ลบ​ทุก LedgerEntry ที่​กระทบ​บัญชี​นั้น โดย​ดู​จาก​ด้าน​ปกติ​ของ​บัญชี​ว่า​เดบิต​หรือ​เครดิต​คือ​ด้าน​ที่​ทำให้​ยอด​เพิ่ม:

public sealed class Account
{
public AccountId Id { get; }
public AccountType Type { get; }
public Account(AccountId id, AccountType type) => (Id, Type) = (id, type);
// ★ ยอดคงเหลือไม่ใช่ column ที่เก็บไว้ — พับ (fold) ทุก LedgerEntry ที่กระทบบัญชีนี้
public Money Balance(IEnumerable<LedgerEntry> log)
{
decimal running = log
.Where(e => e.AccountId == Id)
.Aggregate(0m, (acc, e) =>
e.Direction == NormalSide ? acc + e.Amount.Amount
: acc - e.Amount.Amount);
return Money.Thb(running);
}
// "ด้านปกติ" ที่ทำให้ยอดเพิ่ม ขึ้นกับประเภทบัญชี (ทวนจากบทที่ 1)
private Direction NormalSide => Type switch
{
AccountType.GatewayClearing => Direction.Debit, // สินทรัพย์: เดบิตทำให้ยอดเพิ่ม
_ => Direction.Credit, // wallet/payable/revenue/escrow (หนี้สิน/รายได้): เครดิตทำให้ยอดเพิ่ม
};
}

Aggregate(0m, ...) คือ​การพับตรงๆ: เริ่ม​จาก​ศูนย์ แล้ว​ไล่​แต่ละ​รายการ ถ้า​ตรง​กับ​ด้าน​ปกติ​ของ​บัญชี​ก็​บวก ไม่​งั้น​ก็​ลบ ได้​เท่าไร​ค่อย​ห่อ​กลับ​เป็น Money ตอน​จบ ผลพลอยได้​คือ​ถ้า​บัญชี​ที่​ควร​มี​ยอด​ไม่​ติดลบ​ดัน​คำนวณ​ได้​ติดลบ Money.Thb จะ​โยน exception ทันที — เป็น fail-fast ที่​คอย​จับ bug ใน​ระบบ​บันทึก​ให้​เราฟรีๆ

ลอง​เดิน​ครบ​วงจร​หนึ่ง​รอบ ตอน​ลูกค้า​เติม​เงิน​เข้า wallet 200 บาท​ผ่าน PromptPay:

var ledger = new Ledger();
var gatewayClearingId = new AccountId(Guid.NewGuid());
var customerWalletId = new AccountId(Guid.NewGuid());
var customerWallet = new Account(customerWalletId, AccountType.CustomerWallet);
var jeId = new JournalEntryId(Guid.NewGuid());
var topUp = new JournalEntry(
id: jeId,
idempotencyKey: "promptpay:txn-4821",
reason: "ลูกค้าเติมเงินเข้า wallet 200 บาท",
entries: new[]
{
new LedgerEntry(gatewayClearingId, Direction.Debit, Money.Thb(200), jeId),
new LedgerEntry(customerWalletId, Direction.Credit, Money.Thb(200), jeId),
});
ledger.Post(topUp); // ต่อท้าย log; post key เดิมซ้ำก็ไม่เพิ่มรายการ
Money walletBalance = customerWallet.Balance(ledger.Entries); // = 200 บาท (พับจากรายการ ไม่ใช่ column)
flowchart TB
  JE["JournalEntry — สมดุล ★<br/>เดบิตรวม = เครดิตรวม<br/>+ idempotency key + reason"]
  JE --> D["LedgerEntry · เดบิต<br/>GatewayClearing +200"]
  JE --> C["LedgerEntry · เครดิต<br/>CustomerWallet +200"]
  D -->|Post ต่อท้าย log| A1["บัญชี GatewayClearing"]
  C -->|Post ต่อท้าย log| A2["บัญชี CustomerWallet"]
  A2 --> F["ยอดคงเหลือ = พับ fold ทุก LedgerEntry ของบัญชี<br/>ไม่ใช่ column ที่เก็บไว้"]
  classDef je fill:#1e3a8a,stroke:#1e40af,color:#f8fafc;
  classDef dr fill:#16a34a,stroke:#065f46,color:#f8fafc;
  classDef cr fill:#dc2626,stroke:#7f1d1d,color:#f8fafc;
  classDef acc fill:#0f766e,stroke:#134e4a,color:#f8fafc;
  classDef fold fill:#f59e0b,stroke:#92400e,color:#1c1917;
  class JE je;
  class D dr;
  class C cr;
  class A1,A2 acc;
  class F fold;

คำ​บรรยาย​ภาพ: journal entry หนึ่ง​ใบ (น้ำเงิน) แตก​เป็น LedgerEntry สอง​ขา​ที่​สมดุล — ขา​เดบิต (เขียว) เข้า​บัญชี GatewayClearing และ​ขา​เครดิต (แดง) เข้า​บัญชี CustomerWallet จำนวน​เท่า​กัน​เป๊ะ จุด ★ คือ invariant ที่ constructor รักษา​ไว้: ถ้า​เดบิต​รวม​ไม่​เท่า​เครดิต​รวม journal entry นี้​สร้าง​ไม่​ขึ้น เมื่อ​ผ่าน​แล้ว Post จะ​ต่อ​ทั้ง​สอง​บรรทัด​ท้าย log (บัญชี​สี​เขียว​เข้ม) แบบ append-only ส่วน​ยอด​คง​เหลือ (ส้ม) ไม่​ได้​เก็บ​ไว้​ที่ไหน แต่คำนวณสดๆ ด้วย​การ​พับ​ทุก LedgerEntry ของ​บัญชี​นั้น​ทุก​ครั้ง​ที่​ถาม

ตอน​นี้​เรา​มี​โครง​ของ money context ครบ​ทุก​ชิ้น — Ledger ที่​รักษา​กฎ​สมดุล, Account หก​ประเภท, LedgerEntry ที่ immutable และ JournalEntry ที่​บังคับ invariant ใน​ตัวเอง บท​ต่อ​ไป​เรา​จะ​เอา​โครง​นี้​ไป​รับ​งาน​จริง​ชิ้น​แรก: เติม​เงิน​เข้า wallet ด้วย PromptPay (บท​ที่ 3) — รับ​สัญญาณ​จาก gateway ที่​ขอบ แล้ว​แปลง​เป็น journal entry เข้า​มา​ใน ledger พร้อม​ใช้ idempotency key กัน​หัก​เงิน​ซ้ำ​เวลา retry จาก​นั้น​จึง​ไล่​เรื่อง​ต่อ​ไป​ที่ escrow, settlement, payout, refund และ​ปิด​ท้าย​ด้วย reconciliation

ทุก​บท​จะ​กลับ​มา post ลง ledger เดียวกัน​นี้​เสมอ — ไม่มี column ยอด​เงิน​ให้​เขียน​ทับ มี​แต่​รายการ​ที่​ต่อ​ท้าย​ไป​เรื่อยๆ และ​ยอด​คง​เหลือ​ที่​พับ​ออก​มา​จาก​ประวัติ​ได้​ทุกเมื่อ


🔗 อ้างอิง​เพิ่มเติม​ใน DevIQ

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

  • Aggregate — ทำไม Ledger ถึง​เป็น aggregate ที่​รักษา invariant สมดุล​ไว้​ที่​ขอบ​เดียว code ข้าง​นอก​ขยับ​ยอดบัญชีตรงๆ ไม่​ได้
  • Value Object — ทำไม AccountId, JournalEntryId และ Money ถึง​ห่อ primitive ไว้ แทนที่​จะ​ส่ง Guid/decimal เปล่าๆ ไป​ทั้ง​ระบบ
  • Make Illegal States Unrepresentable — ทำไม journal entry ที่​ไม่​สมดุล​ถึง​ควร “สร้าง​ไม่​ขึ้น” ตั้งแต่ constructor ไม่ใช่​ปล่อย​ให้​มี​อยู่​แล้ว​ค่อย​ไล่​ตรวจ

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

ข้อ 1 / 3

invariant หลักที่ทำให้ JournalEntry ใน model ledger นี้ถูกต้องคืออะไร?