บทที่ 23 · Part 4 — Model Behavior in Code

Tactical Modeling in Practice

ประกอบ Tactical DDD patterns เป็น model เดียวจาก forces และลบ pattern ที่ไม่เพิ่มความชัด

Tactical Modeling in Practice

ทีมหนึ่งเปลี่ยนทุก table เป็น Entity ทุก request เป็น Command และทุก row change เป็น Domain Event ผลคือ code ยาวขึ้น แต่ refund policy ยังอยู่ใน handler และ provider DTO ยังไหลเข้ามาถึง model Tactical DDD ไม่ใช่ checklist ของ class มันเป็น vocabulary สำหรับทำให้ model แสดง identity, value, consistency, lifecycle และ business decision อย่างตรงไปตรงมา

จบบทนี้คุณจะ

ประกอบ Entity, Value Object, Aggregate, Domain Service, Factory, Repository, Specification, Module และ Domain Event ตามแรงของปัญหา แยก Application Service ออกจาก Domain Service และแยก Domain Event ภายในออกจาก Integration Event ที่เป็น public contract

[!IMPORTANT] ใช้ Pattern เมื่อมันทำให้ Model ชัดขึ้น นิยามในบทนี้อิง DDD Reference และ guidance ปัจจุบันเรื่อง Tactical DDD ของ Microsoft ส่วนชื่อ Go package และ interface เป็น Course Convention ไม่ใช่มาตรฐาน DDD หรือ Go

เลือก Building Block จากคำถาม

คำถามใน DomainPattern ที่ควรพิจารณาสัญญาณว่าอาจไม่ต้องใช้
อะไรมี identity ต่อเนื่องแม้ attribute เปลี่ยนEntityrow มีแต่ข้อมูลอ้างอิงและไม่มี behavior
ค่าใดนิยามด้วย attributes และควร immutableValue Objectprimitive มีความหมายเดียวและ validate จุดเดียวพอ
กฎใดต้อง consistent ใน transaction เดียวAggregateกำลังรวม object เพียงเพราะ foreign key
กฎไม่เป็นธรรมชาติของ Entity ใดDomain Servicelogic เป็น orchestration/I/O ไม่ใช่ business decision
การสร้าง object ถูกต้องต้องใช้กฎหลายขั้นFactoryconstructor เล็กและชัดอยู่แล้ว
ต้องค้น/บันทึก Aggregate โดยไม่รั่ว storage modelRepositoryCRUD/query ตรงและ abstraction ไม่ปกป้อง model
business predicate ต้องตั้งชื่อและประกอบซ้ำSpecificationเงื่อนไขใช้ครั้งเดียวและอ่านชัดกว่าเมื่อเขียนตรง ๆ
business fact ใดเกิดขึ้นแล้วและมีผู้สนใจDomain Eventเป็นเพียง technical row update/log message
concept ใดควรถูกรวมให้ language/cohesion ชัดModulepackage ถูกแบ่งเพื่อให้ tree สมมาตรเท่านั้น

Entity และ Value Object

Entity ตอบคำถามว่า “ยังเป็นสิ่งเดิมหรือไม่” Refund ยังเป็นรายการเดิมเมื่อ status เปลี่ยน จาก requested เป็น approved เพราะ identity และ lifecycle ต่อเนื่อง ส่วน Money เท่ากันเมื่อ amount กับ currency เท่ากันและไม่มี identity ของตน

type Money struct {
	minorUnits int64
	currency   string
}

func NewMoney(minorUnits int64, currency string) (Money, error) {
	if minorUnits < 0 {
		return Money{}, ErrNegativeAmount
	}
	if currency == "" {
		return Money{}, ErrMissingCurrency
	}
	return Money{minorUnits: minorUnits, currency: currency}, nil
}

func (m Money) Add(other Money) (Money, error) {
	if m.currency != other.currency {
		return Money{}, ErrCurrencyMismatch
	}
	return NewMoney(m.minorUnits+other.minorUnits, m.currency)
}

Value Object ปิด state ที่ invalid และทำให้ operation อยู่ข้าง concept จำนวนเงินใช้ integer หน่วยย่อยเสมอ แต่ห้ามสมมติว่าทุก currency มี 2 decimal places Currency ต้องอยู่ใน value เพื่อป้องกันการบวกคนละสกุล

Aggregate คือ Consistency Boundary

Aggregate เป็น cluster ที่มี root เป็นทางเข้าของการเปลี่ยน state ภายใน boundary จุดสำคัญไม่ใช่ object graph ใหญ่ แต่คือ invariant ใดต้องเป็นจริงทันทีหลัง command

ตัวอย่าง RefundCase อาจปกป้องว่า approved amount รวมต้องไม่เกิน refundable amount ของ snapshot ที่ case เป็นเจ้าของ แต่ไม่ควรโหลด Customer, Invoice, Provider Account และ Wallet Ledger ทั้งหมด เข้า Aggregate เดียวเพียงเพราะ journey เกี่ยวข้องกัน

func (c *RefundCase) Approve(requestID string, amount Money, policy PolicyVersion) error {
	if c.status != RefundPending {
		return ErrRefundNotPending
	}
	if c.seenRequests[requestID] {
		return nil
	}
	if amount.currency != c.refundable.currency || amount.minorUnits > c.refundable.minorUnits {
		return ErrAmountNotRefundable
	}
	c.status = RefundApproved
	c.approved = amount
	c.policyVersion = policy
	c.seenRequests[requestID] = true
	c.events = append(c.events, RefundApprovedEvent{CaseID: c.id, Amount: amount})
	return nil
}

Code เป็นตัวอย่างตำแหน่ง invariant ไม่ใช่ policy จริง PolicyVersion ต้องอ้าง source/owner/date จากระบบจริง และ duplicate request อาจต้องคืนผลเดิมซึ่ง application persistence ต้องรองรับด้วย unique constraint หรือ concurrency control Aggregate ใน memory ไม่ป้องกัน concurrent writers เอง

Consistency Boundary ไม่เท่ากับ Financial Authority ทั้งระบบ

Aggregate ตัดสินได้เฉพาะ state ที่ context เป็นเจ้าของ การอนุมัติ refund ไม่ได้แปลว่า provider คืนเงินหรือ Wallet post adjustment แล้ว Fact ข้าม context ต้องผ่าน contract, idempotency และ reconciliation ไม่ควรขยาย Aggregate ให้ครอบ network transaction

Application Service กับ Domain Service

คำว่า service มีหลายความหมาย ให้แยกจาก responsibility:

  • Application Service รับ use case, load model, เรียก domain decision, จัด transaction, ใช้ port และบันทึกผล มันรู้ workflow แต่ไม่ควรเป็นเจ้าของ business rule
  • Domain Service เป็น business operation แบบ stateless ที่สำคัญต่อ language แต่ไม่เข้ากับ Entity/Value Object เดียว เช่น RefundEligibility ซึ่งต้องเปรียบเทียบ policy กับ facts หลายชุด
  • Infrastructure Service/Adapter คุยกับ MySQL, provider, clock หรือ message broker

หาก RefundService มี 40 methods ทั้ง validate, SQL, HTTP, logging และ policy มันไม่ได้กลายเป็น Domain Service เพราะอยู่ใน package domain ลองตั้งชื่อด้วย Ubiquitous Language และย้าย orchestration/I/O ออกก่อน

Factory และการสร้างที่มีความหมาย

ใช้ Factory เมื่อการสร้าง Aggregate ต้อง enforce invariant หลายตัว, สร้าง identity, snapshot policy หรือเลือก subtype และขั้นตอนเหล่านี้บดบังความหมายของ use case ตัวอย่าง OpenRefundCase(originalCharge, request, policy) สื่อ business transition ได้ดีกว่า constructor ที่รับ 12 fields แต่ถ้าสร้าง Value Object ด้วย validation 2 บรรทัด NewMoney ก็เพียงพอ ไม่ต้องมี MoneyFactory เพื่อให้ครบ pattern

Factory ต้องคืน object ที่ valid หรือ error ไม่ควรสร้าง half-valid object แล้วหวังว่า caller จะเรียก Initialize() ภายหลัง ข้อมูลที่ต้องดึงจาก provider หรือ repository ควรถูก orchestrate ใน Application Service แล้วส่ง fact ที่ผ่าน translation เข้าสู่ Factory

Repository ตาม Aggregate ไม่ใช่ตาม Table

Repository ทำให้ caller รู้สึกว่ากำลังเรียก collection ของ Aggregate และซ่อน persistence mapping ที่ไม่ใช่ business concern interface ควรถูก consumer เป็นเจ้าของ:

type RefundCases interface {
	ByID(ctx context.Context, id RefundCaseID) (*RefundCase, error)
	Save(ctx context.Context, refundCase *RefundCase) error
}

อย่าสร้าง generic Repository[T] ที่บังคับ CRUD เหมือนกันทุก model Query สำหรับ Portal ที่ join read models ไม่จำเป็นต้องประกอบ Aggregate และอาจใช้ query port ที่คืน projection โดยตรง ส่วน Save ต้องนิยาม concurrency expectation เช่น version check ให้ชัดใน contract และ integration test

Specification เมื่อ Predicate เป็นภาษาธุรกิจ

Specification encapsulates predicate ที่มีชื่อใน domain และต้อง reuse/compose เช่น RefundRequiresManualApproval หรือ MerchantIsEligibleForInstantSettlement มันมีค่าหาก Business ใช้แนวคิดนั้นและต้องเปลี่ยนร่วมกัน ไม่ใช่เหตุผลให้ห่อ amount > 0 ทุกจุด

ต้องระวัง 2 model: Specification ที่ตัดสิน Aggregate ใน memory กับ database query specification อาจต้องแปลคนละแบบ อย่าฝืนให้ object เดียวสร้าง SQL และตัดสิน domain หากทำให้ persistence concern รั่วเข้ามา รวมทั้งอย่าให้ชื่อ specification ซ่อน threshold ที่ไม่มี accountable source

Domain Event กับ Integration Event

Domain Event บอก fact ที่สำคัญภายใน model เช่น RefundApproved และอาจใช้กระตุ้น behavior ใน transaction/process เดียว Integration Event เป็น public contract ที่ context อื่นพึ่งพา มันต้องคำนึงถึง schema, compatibility, sensitive data, delivery และ consumer semantics

มิติDomain EventIntegration Event
Audiencemodel/context เดียวcontext หรือองค์กรอื่น
Shapeใกล้ Ubiquitous Language ภายในPublished Language ที่ตั้งใจเปิดเผย
Deliveryin-memory หรือ internal mechanism ได้outbox/broker/API พร้อม failure contract
Evolutionเปลี่ยนพร้อม module ได้version/deprecation และ consumer coordination
Dataเท่าที่ domain behavior ต้องใช้minimum necessary ตาม contract/security

อย่า publish object ภายในตรง ๆ Application layer/translator ควรแปลง RefundApproved เป็น contract เช่น RefundExecutionRequested เฉพาะเมื่ออีก context ต้องทำงานต่อ และ transaction กับ outbox ต้องออกแบบเพื่อไม่ทำ event หาย

Module ทำให้ Language มีขอบเขต

Module ใน DDD จัด concept ที่ cohesive และบอกเรื่องราวของ model ชื่อ refundapproval, receivables หรือ paymentattempt ให้ข้อมูลมากกว่า models, services, common แต่ module ไม่ต้องเท่ากับ package เดียว, Bounded Context หรือ deployable เสมอ ใช้ public API, visibility และ import rule คุมการเข้าถึง ไม่ใช้ folder nesting เป็นหลักฐานเพียงอย่างเดียว

แบบฝึกปฏิบัติ: Tactical Design Card

ออกแบบ Approve Partial Refund โดยส่ง artifact เหล่านี้:

Ubiquitous Language statement:
Entity identities and lifecycles:
Value Objects and invalid states prevented:
Aggregate invariant and transaction boundary:
Application Service steps:
Domain Service, if any, and why rule belongs there:
Factory, Repository or Specification: force that justifies each:
Domain Event:
Integration Event or command, if actually needed:
Concurrency and duplicate-request evidence:
Patterns deliberately omitted:

ให้ reviewer ลบ pattern ทีละตัว หาก model ยังชัดและ invariant ยังปลอดภัย การลบนั้นอาจเป็น improvement เกณฑ์ไม่ได้อยู่ที่ใช้ pattern ครบ แต่อยู่ที่ business behavior ถูกวางในจุดที่เห็น, ทดสอบ และเปลี่ยนได้

รายการตรวจสอบ

  • Entity มี identity/lifecycle จริง และ Value Object นิยามด้วยค่า
  • Aggregate ปกป้อง invariant ที่ต้อง consistent ไม่ใช่ object graph ทั้ง journey
  • Application Service orchestrate แต่ Domain Service ตัดสิน business rule
  • Factory/Repository/Specification มีแรงเฉพาะที่คุ้ม abstraction
  • Repository boundary ตรงกับ Aggregate และ concurrency contract
  • Domain Event แยกจาก public Integration Event
  • Module ใช้ Ubiquitous Language แทน technical bucket
  • Pattern ที่ไม่เพิ่มความชัดถูกละไว้โดยตั้งใจ

สรุปบทนี้

Tactical DDD ทำให้ code เป็นแบบจำลองของ identity, value, invariant และ business fact ที่ทีมพูดถึง Pattern แต่ละตัวมีหน้าที่เฉพาะและมีต้นทุน ไม่ต้องใช้ครบทุก module เมื่อประกอบ เท่าที่แรงของ domain ต้องการ Model จะลึกพอปกป้องกฎโดยไม่สร้าง architecture theater

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