บทที่ 9 · Part 2 — Choosing Code Architecture
Rich Domain Model
ใช้ Entity, Value Object, Aggregate และ invariant เมื่อ state กับกฎธุรกิจเปลี่ยนร่วมกันอย่างซับซ้อน
Rich Domain Model
Refund ไม่ใช่แค่ insert row หาก Charge ถูก partially refunded แล้ว มี pending request จากอีก channel หรืออยู่ใน state ที่ provider ยังไม่ยืนยัน การตัดสินใจต้องใช้ state และ rule หลายชุดร่วมกัน การกระจายกฎนี้ใน handler, SQL และ worker ทำให้สถานะผิดเกิดง่าย Rich Domain Model มีคุณค่าเมื่อรวม language กับ invariant ที่เปลี่ยนร่วมกัน
จบบทนี้คุณจะ
ใช้ Entity, Value Object, Aggregate Root, Domain Service, Domain Event และ invariant อย่างมีขอบเขต model Charge/Refund ด้วย Money แบบ integer minor units และอธิบายว่า Aggregate consistency ต้องทำงานร่วมกับ transaction/concurrency control
[!IMPORTANT] วิธีอ่านหลักฐานในบทนี้ คำที่เป็น Definition/Pattern มีแหล่งต้นทางอยู่ใกล้ claim ส่วน decision table, starting structure, checklist และ lab เป็น Heuristic/Trade-off หรือ Course Convention สำหรับฝึกตัดสินใจ ไม่ใช่มาตรฐานสากล เว้นแต่บทจะระบุแหล่งและขอบเขตไว้ชัดเจน
องค์ประกอบของ Tactical DDD
จาก Evans และ Vernon:
- Entity มี identity ต่อเนื่องแม้ attribute เปลี่ยน เช่น Charge
- Value Object นิยามด้วยค่าและควร valid ตั้งแต่สร้าง เช่น Money
- Aggregate คือกลุ่ม model ที่รักษา invariant ภายใน consistency boundary
- Aggregate Root เป็นทางเข้าของการเปลี่ยน state ภายใน Aggregate
- Domain Service ใช้กับ operation ที่เป็นภาษาธุรกิจแต่ไม่เป็นธรรมชาติของ Entity เดียว
- Domain Event บอกข้อเท็จจริงที่ domain ยอมรับแล้ว
ไม่ต้องใช้ทุก building block ในทุก module Aggregate ไม่ใช่ object graph ขนาดใหญ่และ Domain Event ไม่ควรถูกสร้างเพียงเพื่อให้ handler ทุก function ดูเหมือน event-driven
Money ในฐานะ Value Object
จำนวนเงินในคอร์สใช้ integer minor units พร้อม Currency:
type Money struct {
minor int64
currency string
}
func NewMoney(minor int64, currency string) (Money, error) {
if minor < 0 || currency == "" {
return Money{}, ErrInvalidMoney
}
return Money{minor: minor, currency: currency}, nil
}
func (m Money) Add(other Money) (Money, error) {
if m.currency != other.currency {
return Money{}, ErrCurrencyMismatch
}
return NewMoney(m.minor+other.minor, m.currency)
}
func (m Money) Minor() int64 { return m.minor }
func (m Money) Currency() string { return m.currency }
integer minor units หลีกเลี่ยง binary floating-point แต่ไม่ได้แปลว่าทุก currency มี 2 decimal
metadata, rounding และ conversion policy ต้องมี owner ตาม use case
ตัวอย่างซ่อน fields เพื่อบังคับการสร้างผ่าน constructor โดยยอมให้ Money เป็น 0 สำหรับ
ยอดสะสม แต่ ApproveRefund รับเฉพาะจำนวนที่มากกว่า 0
Monetary policy ต้องระบุแหล่งที่มา
ตัวอย่าง THB 50,000 หมายถึง 50,000 สตางค์เท่านั้น ไม่กำหนด refund limit,
exchange rate หรือ rounding ของระบบจริง ค่าเหล่านั้นต้องมี policy owner, version และวันที่
Charge ในฐานะ Aggregate
Aggregate แสดง legal transitions และ refund invariant:
type Charge struct {
id ChargeID
amount Money
state ChargeState
refunded Money
version int64
}
func (c *Charge) ApproveRefund(requested Money) (Refund, error) {
if c.state != ChargeCaptured {
return Refund{}, ErrChargeNotRefundable
}
if requested.minor <= 0 {
return Refund{}, ErrInvalidRefundAmount
}
next, err := c.refunded.Add(requested)
if err != nil {
return Refund{}, err
}
if next.minor > c.amount.minor {
return Refund{}, ErrRefundExceedsCharge
}
refund := NewRefund(c.id, requested)
c.refunded = next
return refund, nil
}
field เป็น private เพื่อบังคับการเปลี่ยนผ่าน method แต่ code นี้ยังไม่แก้ concurrent writes
หาก request 2 ตัวโหลด version เดียว ทั้งคู่อาจผ่าน ApproveRefund
Repository ต้องบันทึกด้วย optimistic version หรือ lock row ใน MySQL transaction:
UPDATE charges
SET refunded_minor = ?, version = version + 1
WHERE charge_id = ? AND version = ?;
affected rows = 0 หมายถึง conflict ให้ application reload และตัดสินใหม่ หรือคืน conflict ตาม contract Domain model แสดงกฎ ส่วน datastore รับรอง serialization ของการเปลี่ยน
รักษา Aggregate ให้เล็ก
อย่าใส่ Customer, Payment Provider, Invoice และ Wallet Entry ทั้งหมดใน Charge Aggregate เพียงเพราะเกี่ยวกับ journey เดียว ข้อมูลภายนอก Aggregate ใช้ identifier, immutable fact หรือ application coordination
เลือก boundary จาก invariant ที่ต้อง consistent ทันที ไม่ใช่ navigation convenience ถ้า Refund มี lifecycle async ยาว มันอาจเป็น Entity/Aggregate ของตัวเองและ Charge เก็บ reserved/refunded totals ผ่าน contract การออกแบบต้องทดสอบ failure/concurrency scenario
Domain Service เหมาะเมื่อ policy ต้องเปรียบเทียบหลาย object แต่ไม่เป็นเจ้าของ lifecycle
เช่น allocation rule ระหว่าง refunds หลาย charge อย่าใช้ DomainService เป็นถังรวม method
ที่หา class วางไม่ได้
Domain Event โดยไม่รีบใช้ Messaging
RefundApproved เป็น domain fact หลัง Aggregate ยอมรับ transition มันยังไม่ใช่ Kafka message
โดยอัตโนมัติ Application layer อาจเก็บ event กับ state ใน transaction แล้วแปลงเป็น
integration event ผ่าน outbox
แยกคำ:
- Domain event ใช้ภาษาภายใน context
- Integration event เป็น public contract ที่ออกแบบเพื่อ consumer
- Provider webhook เป็น external message ที่ยังต้อง translate/verify
การแยกนี้ป้องกัน internal model change ทำลายผู้บริโภคภายนอก
แบบฝึกปฏิบัติ: Refund Invariant
สร้าง tests ก่อน implementation อย่างน้อย:
- full refund เท่ากับ captured amount ผ่าน
- cumulative partial refunds ไม่เกิน amount ผ่าน
- over-refund ถูกปฏิเสธ
- currency mismatch ถูกปฏิเสธ
- charge ที่ไม่ captured ถูกปฏิเสธ
- concurrent save ด้วย version เดียวมีเพียง 1 รายการสำเร็จ
จากนั้นเขียน diagram boundary: อะไรอยู่ใน Charge Aggregate อะไรเป็น provider port และ อะไรอยู่ application workflow พร้อมเหตุผลสำหรับ object ที่ตั้งใจไม่รวม
รายการตรวจสอบ
- Entity มี identity/lifecycle ที่ต้องการจริง
- Value Object valid ตั้งแต่สร้างและไม่ใช้ float กับเงิน
- Aggregate ถูกเลือกจาก invariant ไม่ใช่ graph convenience
- Domain method ใช้ Ubiquitous Language และซ่อน illegal transition
- Concurrency protection อยู่ที่ persistence transaction/version ด้วย
- Domain Event ไม่ถูกเผยเป็น vendor/public schema อัตโนมัติ
สรุปบทนี้
Rich Domain Model คุ้มเมื่อ rules และ state มีความหนาแน่นสูง มันทำให้ invariant เป็น executable language แต่ correctness ยังต้องพึ่ง transaction, constraint และ concurrency control Aggregate ที่ดีเล็กพอให้รักษากฎได้และไม่ดึงทั้ง business journey มาไว้ก้อนเดียว
อ่านเพิ่มเติม
- DDD Reference — Entity, Value Object, Aggregate, Service และ Event
- Money — Money pattern
- Implementing Domain-Driven Design — Aggregates และ application integration
- MySQL Locking Reads — official concurrency behavior