บทที่ 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 จากคำถาม
| คำถามใน Domain | Pattern ที่ควรพิจารณา | สัญญาณว่าอาจไม่ต้องใช้ |
|---|---|---|
| อะไรมี identity ต่อเนื่องแม้ attribute เปลี่ยน | Entity | row มีแต่ข้อมูลอ้างอิงและไม่มี behavior |
| ค่าใดนิยามด้วย attributes และควร immutable | Value Object | primitive มีความหมายเดียวและ validate จุดเดียวพอ |
| กฎใดต้อง consistent ใน transaction เดียว | Aggregate | กำลังรวม object เพียงเพราะ foreign key |
| กฎไม่เป็นธรรมชาติของ Entity ใด | Domain Service | logic เป็น orchestration/I/O ไม่ใช่ business decision |
| การสร้าง object ถูกต้องต้องใช้กฎหลายขั้น | Factory | constructor เล็กและชัดอยู่แล้ว |
| ต้องค้น/บันทึก Aggregate โดยไม่รั่ว storage model | Repository | CRUD/query ตรงและ abstraction ไม่ปกป้อง model |
| business predicate ต้องตั้งชื่อและประกอบซ้ำ | Specification | เงื่อนไขใช้ครั้งเดียวและอ่านชัดกว่าเมื่อเขียนตรง ๆ |
| business fact ใดเกิดขึ้นแล้วและมีผู้สนใจ | Domain Event | เป็นเพียง technical row update/log message |
| concept ใดควรถูกรวมให้ language/cohesion ชัด | Module | package ถูกแบ่งเพื่อให้ 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 Event | Integration Event |
|---|---|---|
| Audience | model/context เดียว | context หรือองค์กรอื่น |
| Shape | ใกล้ Ubiquitous Language ภายใน | Published Language ที่ตั้งใจเปิดเผย |
| Delivery | in-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
อ่านเพิ่มเติม
- DDD Reference — Entity, Value Object, Aggregate, Service, Factory, Repository, Specification, Event และ Module
- Use Tactical DDD to Design Microservices — current first-party guidance พร้อมตัวอย่าง tactical patterns
- Repository และ Data Mapper — pattern catalog ของ Martin Fowler
- Domain Event — ความหมายของ event ที่ domain experts สนใจ