บทที่ 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 อย่างน้อย:

  1. full refund เท่ากับ captured amount ผ่าน
  2. cumulative partial refunds ไม่เกิน amount ผ่าน
  3. over-refund ถูกปฏิเสธ
  4. currency mismatch ถูกปฏิเสธ
  5. charge ที่ไม่ captured ถูกปฏิเสธ
  6. 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 มาไว้ก้อนเดียว

อ่านเพิ่มเติม