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 เต็มของคอร์สนี้อยู่ที่ repo kaen-food-ordering (กำลังจัดทำ) — บทนี้เริ่มปั้นโครง domain ของ money context จริง รูปทรง C# ของ Ledger, Account, LedgerEntry และ JournalEntry ที่เห็นในบทเป็นตัวอย่างประกอบให้เห็นภาพ ส่วน value object Money ยกมาจากคอร์ส DDD-in-Code ทั้งดุ้น (path เดิม FoodOrdering.Domain/Orders/Money.cs)
Ledger — aggregate ที่รักษากฎสมดุลไว้ในตัวเอง
หัวข้อที่มีชื่อว่า “Ledger — aggregate ที่รักษากฎสมดุลไว้ในตัวเอง”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 — บอกว่าเงินอยู่ตรงไหน
หัวข้อที่มีชื่อว่า “Account — บอกว่าเงินอยู่ตรงไหน”Account คือหน่วยที่ตอบว่า “เงินก้อนนี้อยู่ตรงไหนในระบบ” money context ของเรามีบัญชีอยู่หกประเภทตายตัว ทั้งคอร์สจะวนอยู่กับกลุ่มนี้ ไม่มีการสร้างประเภทใหม่พร่ำเพรื่อ เราจึงล็อกมันไว้ใน enum:
// AccountId — value object ห่อ Guid ไว้ ไม่ให้ปนกับ id ชนิดอื่นใน domainpublic 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 ก่อน
LedgerEntry — บรรทัดเดียวที่เขียนแล้วห้ามแก้
หัวข้อที่มีชื่อว่า “LedgerEntry — บรรทัดเดียวที่เขียนแล้วห้ามแก้”ก่อนจะไปถึง 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 — ชุดรายการที่ต้องสมดุล ★
หัวข้อที่มีชื่อว่า “Journal Entry — ชุดรายการที่ต้องสมดุล ★”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
Post — ต่อท้าย log อย่าง idempotent
หัวข้อที่มีชื่อว่า “Post — ต่อท้าย log อย่าง idempotent”มาถึงตัว 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 ข้างหน้า
ยอดคงเหลือคือผลของการพับ ไม่ใช่ column
หัวข้อที่มีชื่อว่า “ยอดคงเหลือคือผลของการพับ ไม่ใช่ column”คำถามสุดท้าย: แล้ว “ยอดเงินในบัญชี” มาจากไหน คำตอบคือ 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:
- Aggregate — ทำไม
Ledgerถึงเป็น aggregate ที่รักษา invariant สมดุลไว้ที่ขอบเดียว code ข้างนอกขยับยอดบัญชีตรงๆ ไม่ได้ - Value Object — ทำไม
AccountId,JournalEntryIdและMoneyถึงห่อ primitive ไว้ แทนที่จะส่งGuid/decimalเปล่าๆ ไปทั้งระบบ - Make Illegal States Unrepresentable — ทำไม journal entry ที่ไม่สมดุลถึงควร “สร้างไม่ขึ้น” ตั้งแต่ constructor ไม่ใช่ปล่อยให้มีอยู่แล้วค่อยไล่ตรวจ
เช็กความเข้าใจ — บทที่ 2
ข้อ 1 / 3invariant หลักที่ทำให้ JournalEntry ใน model ledger นี้ถูกต้องคืออะไร?