DDD Tactical Design — ออกแบบ Business Solution ที่ Dev แปลงเป็น Code ได้ทันที
Prerequisite 2: DDD Tactical Design — ออกแบบ Domain Model ภายใน Bounded Context ให้อิสระจาก Technical Detail
เรียนรู้ DDD Tactical Design ที่ทำให้ Non-Tech ออกแบบ Business Solution ได้แม่นยำ และ Tech Team แปลงเป็น Code Structure ได้ตรงโดยไม่ต้องตีความอีกต่อไป
ตอนที่แล้ว เราแบ่ง Context ได้แล้ว — ตอนนี้จะเขียนข้างในยังไง?
ใน Prerequisite 1 เราวิเคราะห์ ERP จาก Domain ลงมาถึง Sub-Domain และกำหนด Bounded Context สำหรับแต่ละ Sub-Domain
Domain: ระบบ ERP บริษัทผลิตสินค้า
Sub-Domain → Bounded Context:
MDM (Generic) → mdm/
ProductMaster { ProductId, name, unit, category }
Procurement (Supporting) → procurement/
ProcurementProduct { ProductId, supplierId, leadTime }
Stock (Supporting) → stock/
StockProduct { ProductId, stockLevel, warehouseLocation }
Sales (Core) → sales-management/
SalesProduct { ProductId, sellingPrice, discount }
Cost Accounting (Supporting) → cost-accounting/
CostProduct { ProductId, costPrice, overhead, margin }
ตอนนี้ลองจินตนาการว่าเราเดินเข้าไปใน Sales Management Context — เห็น SalesProduct และ Customer อยู่แล้ว แต่ยังขาด logic สำคัญที่ทีม Sales ต้องการทุกวัน นั่นคือ การสร้าง SalesOrder
ตรงนี้คือจุดที่ Dev ส่วนใหญ่หยุดและตั้งคำถามว่า — “แล้วจะเริ่มเขียนโค้ดข้างในยังไง? SalesOrder ควรรู้จัก SalesProduct โดยตรงไหม? logic ราคาควรอยู่ที่ไหน? ใครรับผิดชอบ validate?”
DDD Tactical Design คือชุดหลักการออกแบบ Domain Model ภายใน Bounded Context — มันบอกว่าข้อมูลและ logic แต่ละประเภทควรอยู่ที่ไหน ใครรับผิดชอบอะไร และโค้ดควรสะท้อนโครงสร้างของ Domain จริงๆ ยังไง
บทความนี้จะลงไปใน Sales Management Context แล้วเดินผ่านหลักการทีละตัว
Domain Model ในบริบทนี้คืออะไร
ก่อนเริ่ม มีคำหนึ่งที่จะเห็นตลอดบทความนี้และ Series ที่ตามมา — “Domain Model”
คำนี้มีความหมายต่างกันใน 2 บริบท และสับสนได้ง่าย
Domain Model ใน Technical Term (Martin Fowler) หมายถึง architectural pattern สำหรับ Business Logic Layer — เป็น object ที่ผสม data และ behavior ไว้ด้วยกันในที่เดียว เป็นแค่ pattern ตัวหนึ่ง ในการออกแบบโค้ด
// Fowler Domain Model pattern
// data + behavior อยู่ด้วยกันใน class เดียว
class SalesOrder {
private items: SalesOrderItem[]
addItem(item: SalesOrderItem): void { ... } // behavior
confirm(): void { ... } // behavior
calculateTotal(): Money { ... } // behavior
}
Domain Model ใน DDD (Eric Evans) หมายถึง ภาพรวมของการออกแบบทั้งหมดภายใน Bounded Context — ครอบคลุมทั้งโครงสร้างข้อมูล ขอบเขต และกฎของ Business โดยไม่ผูกกับ pattern ใด pattern หนึ่ง
Fowler Domain Model
→ เป็นแค่ส่วนหนึ่งของ DDD Domain Model (OOP Style)
→ DDD Domain Model implement ได้หลายแบบ
ทั้ง Rich Model (Fowler) และ Anemic Model (Functional)
ตลอดบทความนี้และ Series ที่ตามมา คำว่า Domain Model หมายถึงความหมายของ DDD เสมอ
ภาพรวม — หลักการทั้งหมดสัมพันธ์กันยังไง
Domain Model ใน DDD ประกอบด้วย 3 กลุ่มที่ทำงานร่วมกัน
กลุ่มที่ 1 — โครงสร้างข้อมูล: Entity และ Value Object บอกว่า Business มีข้อมูลอะไรบ้างและมีโครงสร้างยังไง
กลุ่มที่ 2 — ขอบเขต: Aggregate กำหนดว่าข้อมูลกลุ่มไหนต้องสอดคล้องกันเสมอ
กลุ่มที่ 3 — Domain Logic: Business Rule, Invariant, Domain Service, Domain Event และ Application Service บอกว่า Business มีกฎอะไรและทำงานยังไง
OOP vs Functional — อ่านก่อนเริ่ม
ก่อนดูหลักการแรก มีเรื่องสำคัญที่ต้องรู้ไว้
DDD Tactical Design เกิดในยุค OOP — เมื่อ Eric Evans เขียนหนังสือในปี 2003 เขาอธิบายทุกอย่างผ่าน Class และ Object เป็นหลัก หนังสือและบทความส่วนใหญ่ที่เขียนเรื่องนี้จึงใช้ภาษาเดียวกัน
แต่ concept เหล่านี้ไม่ได้ผูกติดกับ OOP แต่อย่างใด มันคือ “หลักคิด” ว่าโค้ดแต่ละประเภทมีบทบาทอะไร ซึ่ง implement ได้หลายแบบ
บทความนี้จะอธิบายแต่ละ concept ใน 2 รูปแบบ ควบคู่กัน:
| Style | คืออะไร | เหมาะกับใคร |
|---|---|---|
| OOP Style | แบบดั้งเดิมที่หนังสือส่วนใหญ่สอน ใช้ Class เป็นศูนย์กลาง | คนที่เรียน DDD จากแหล่งอื่น หรือทำงานกับ codebase OOP |
| Functional Style | แบบที่ Series Scale-Ready ใช้เป็นหลัก ใช้ pure function + type แทน โดยผสม OOP ในบางจุดที่เข้าใจง่ายกว่า | คนที่จะเริ่ม Series Scale-Ready |
ถ้าคุณไม่ได้จะใช้ Series Scale-Ready — อ่านแค่ OOP Style ก็เพียงพอ แต่ถ้าจะเริ่ม Series Scale-Ready ให้ focus ที่ Functional Style เป็นหลัก เพราะ Series ใช้ Functional เป็นหลักผสม OOP ในบางจุดที่เข้าใจง่ายกว่า
หลักการเลือกระหว่าง OOP กับ Functional
เลือกตาม Technology ที่จะใช้ implement จริง ไม่ใช่ตามความชอบส่วนตัว เพราะ Technology ที่เลือกมีผลโดยตรงว่า style ไหนเข้าใจง่ายกว่า maintain ได้ดีกว่า และสอดคล้องกับ pattern ที่ทีมคุ้นเคยอยู่แล้ว
| ถ้า Technology ที่เลือกคือ | ควรใช้ Style ไหน | เหตุผล |
|---|---|---|
| TypeScript / JavaScript | Functional | Native support ดี type system รองรับ immutability และ pure function ได้โดยตรง |
| Java / C# / Kotlin | OOP | ภาษาออกแบบมาเพื่อ OOP class-based pattern เข้าใจและ maintain ง่ายกว่าในทีม |
| Python | ได้ทั้งคู่ | Python รองรับทั้งสอง style เลือกตามที่ทีมถนัดและ codebase เดิมใช้อยู่ |
| Go | Functional | Go ไม่มี class เลือก struct + function แยกกันเป็น native pattern |
กฎง่ายๆ: ถ้าภาษานั้น native OOP → ใช้ OOP style ถ้าภาษานั้น native Functional → ใช้ Functional style อย่าฝืน pattern ของภาษา เพราะทีมจะ maintain ยาก
Domain Model ที่ออกแบบไว้เหมือนกัน — แค่ implement ต่างกันตาม Technology ที่เลือก หลักการของ Tactical Design ไม่เปลี่ยน
Tactical Design กับ Architecture — สองสิ่งที่ต่างกัน
ก่อนลงลึกหลักการเหล่านี้ มีเรื่องสำคัญที่ต้องแยกให้ชัด เพราะคนส่วนใหญ่มักสับสนระหว่างสองสิ่งนี้
DDD Tactical Design = ออกแบบ Domain Model อิสระจาก Technology
หลักการที่จะเรียนในบทความนี้ — Entity, Value Object, Aggregate, Repository, Domain Service — ทั้งหมดนี้เป็นการออกแบบ ว่า Business Logic มีโครงสร้างยังไง ยังไม่พูดถึงเลยว่าจะใช้ Framework อะไร Database อะไร หรือ Language อะไร
SalesOrder ต้องมีอย่างน้อย 1 รายการสินค้า
→ นี่คือ Invariant ของ Domain — ไม่ว่าจะใช้ Prisma หรือ MongoDB ก็ยังเป็น rule เดิม
Architecture (Hexagonal / Clean) = กำหนดว่า Domain Model วางอยู่ที่ไหนในโครงสร้างโค้ด
หลังจากออกแบบ Domain Model แล้ว Architecture มาตอบว่า หลักการเหล่านั้นวางอยู่ใน layer ไหน dependency ไหลทิศทางไหน และ wire กันยังไงกับ tools จริง เช่น Prisma, Express, หรือ NestJS
Repository interface อยู่ใน Domain Layer → กำหนดโดย Architecture
PrismaOrderRepo อยู่ใน Infrastructure Layer → กำหนดโดย Architecture
ทำไมต้องแยก?
เพราะ Domain Model ที่ดีควรอ่านและทดสอบได้โดยไม่ต้องรู้ว่าระบบใช้ Framework อะไร ถ้า Domain Model ผูกติดกับ Framework ตั้งแต่ต้น — เปลี่ยน Framework ทีก็ต้องแก้ Business Logic ไปด้วย
บทความนี้ focus ที่ Tactical Design — ออกแบบ Domain Model ให้ถูกก่อน Prerequisite 3 จะพูดถึง Architecture — วาง Domain Model นั้นในโครงสร้างโค้ดจริง
Domain Logic คืออะไร — ก่อนเริ่มหลักการ
⚠️ Section นี้สำคัญมาก ถ้าข้ามไปจะเข้าใจหลักการได้ไม่ครบ
Domain Logic คือ general term ที่หมายถึง “โค้ดที่ implement กฎของ Business” ไม่สนว่าจะใช้ OOP, Functional, ภาษาไหน, หรือ framework อะไร — มันเป็นแค่ “หลักคิด” ว่าโค้ดกลุ่มนี้คือของ Business ไม่ใช่ของ Database และไม่ใช่ของ Framework
Domain Model กับ Domain Logic — ต่างกันยังไง?
ก่อนลงลึก มีคำสองคำที่จะเห็นบ่อยในบทความนี้และมักสับสนกัน
Domain Model คือภาพรวมของการออกแบบทั้งหมดภายใน Bounded Context ประกอบด้วยทั้งข้อมูล กฎ และพฤติกรรมของ Business รวมกัน
Domain Logic คือส่วนย่อยหนึ่งของ Domain Model — เฉพาะส่วนที่เป็นกฎและพฤติกรรมของ Business ไม่รวมโครงสร้างข้อมูล
Domain Model = บ้านทั้งหลัง
├── โครงสร้างข้อมูล (Entity, Value Object)
└── Domain Logic (กฎและพฤติกรรม)
Domain Logic = กฎของบ้านเท่านั้น
ไม่ใช่ตัวบ้านหรือโครงสร้าง
ตัวอย่างใน Sales Management Context:
| ตัวอย่าง | |
|---|---|
| Domain Model | SalesOrder ทั้งหมด — ทั้งโครงสร้างและกฎ |
| Domain Logic | ”SalesOrder ต้องมีอย่างน้อย 1 รายการ”, “confirm ได้เฉพาะ draft” |
Domain Logic แบ่งออกได้อีก 2 ประเภท ซึ่งมีธรรมชาติต่างกันโดยสิ้นเชิง
Business Rule — กฎที่เปลี่ยนได้
Business Rule คือกฎที่ธุรกิจตัดสินใจกำหนดขึ้น เปลี่ยนได้ถ้า policy หรือ strategy เปลี่ยน
"ลูกค้ากลุ่ม Premium ได้ส่วนลด 10% จากราคาขายใน SalesProduct"
"ถ้ายอดสั่งซื้อเกิน 50,000 บาท ได้ค่าขนส่งฟรี"
"ถ้าสั่งสินค้ามากกว่า 10 รายการ ได้ส่วนลดเพิ่ม 5%"
"SalesOrder ต้องมีอย่างน้อย 1 รายการสินค้า" ← ดูเหมือน Invariant แต่บาง Business
อนุญาตให้ draft order ว่างได้ก่อน
ต้องถาม Domain Expert ก่อนเสมอ
ถ้า Domain Expert ฝ่าย Sales บอกว่า “เปลี่ยน Premium เป็น 15% นะ” — เปลี่ยนได้ ไม่มีอะไรพัง
Business Constraint (Invariant) — กฎที่ห้ามละเมิด
Invariant คือเงื่อนไขที่ต้องเป็นจริงเสมอ ไม่มีข้อยกเว้น ละเมิดได้ = ข้อมูลในระบบเสียหาย
"SalesOrder ที่ confirm แล้ว ต้องมี orderId ที่ไม่ซ้ำกันเสมอ"
→ ถ้าซ้ำ ระบบ Billing และ Stock จะ track ผิดทั้งหมด
"เลขที่ SalesOrder ต้องเรียงลำดับต่อเนื่อง ข้ามไม่ได้"
→ ถ้าข้าม เช่น มี SO-001, SO-003 แต่ไม่มี SO-002
ผู้ตรวจสอบบัญชีจะ flag ทันทีว่ามีการปกปิดข้อมูล
และผิดกฎหมายบัญชีในหลายประเทศ
ถ้า Domain Expert บอกว่า “อยากเปลี่ยนกฎนี้” — ต้องถามให้แน่ใจมากๆ เพราะนั่นคือการเปลี่ยน Invariant ซึ่งอาจทำให้ระบบอื่นพัง
Analogy ให้เห็นความต่าง:
Business Rule = กฎจราจร "ห้ามจอดในเส้นสีเหลือง"
→ รัฐบาลเปลี่ยนกฎได้ถ้า policy เปลี่ยน
Invariant = กฎฟิสิกส์ "รถไม่สามารถอยู่สองที่พร้อมกัน"
→ ไม่มีทางเปลี่ยนได้ เพราะละเมิดแล้วระบบพัง
Invariant ควร enforce ที่ไหน
Invariant ถูก enforce ได้หลายระดับ แต่ลำดับความสำคัญชัดเจน
ระดับ 1 — Domain (enforce ที่นี่ก่อนเสมอ):
- OOP Style →
constructorthrow Error หรือ Value Object ป้องกันการสร้างค่าผิด - Functional Style →
smart constructor+Result<T, Error>
ระดับ 2 — Infrastructure (safety net เท่านั้น):
- Data Validation → DTO validation ก่อนเข้า Use Case
- DB Constraint →
CHECK constraint,NOT NULLเป็นด่านสุดท้าย ไม่ใช่ที่บังคับหลัก
กฎสำคัญ: Invariant ต้อง enforce ที่ Domain ก่อนเสมอ DB Constraint คือ net สุดท้าย — ไม่ใช่ที่แรก ถ้า enforce แค่ที่ DB แสดงว่า Domain Logic รั่ว
มุมมองแต่ละบทบาท:
Dev — ก่อนเขียน validation ใหม่ ให้ถามตัวเองว่า “นี่คือ Business Rule หรือ Invariant?” เพราะ Invariant ต้องอยู่ใกล้ข้อมูลมากที่สุด ห้ามปล่อยให้ caller ข้ามได้
EM — เวลา BA บอกว่า “อยากเปลี่ยนกฎนี้” ให้ทีมถามกลับว่า “มันเป็น Business Rule หรือ Invariant?” เพราะ cost ในการเปลี่ยนต่างกันมาก
BA — เวลา Dev บอกว่า “อันนี้เปลี่ยนไม่ได้” ให้ถามว่า “เพราะมันเป็น Invariant ของ Domain หรือเพราะระบบออกแบบมาแบบนั้น?” สองอย่างนี้ต่างกันโดยสิ้นเชิง
Invariant ของ Domain กับ Technical Constraint — ต่างกันยังไง?
คำถามที่ BA ควรถามเสมอเมื่อ Dev บอกว่า “เปลี่ยนไม่ได้” คือ “เปลี่ยนไม่ได้เพราะอะไร?”
Invariant ของ Domain — มาจากกฎของ Business จริงๆ ละเมิดแล้ว Business เสียหาย
"เลขที่ SalesOrder ต้องเรียงลำดับต่อเนื่อง ข้ามไม่ได้"
→ ถ้าละเมิด: ผิดกฎหมายบัญชี ผู้ตรวจสอบ flag ทันที
→ Business เสียหายจริง — เปลี่ยนไม่ได้ ✅
Technical Constraint — มาจากการตัดสินใจทางเทคนิคของ Dev ไม่ใช่กฎของ Business
"เปลี่ยน DiscountPolicy ไม่ได้หลัง confirm"
→ ถ้าถามว่า "ทำไม?" Dev ตอบว่า "เพราะ discount คำนวณครั้งเดียวแล้ว hardcode ลง DB"
→ Business ไม่ได้เสียหาย — เปลี่ยนได้ถ้า refactor ✅
| Invariant ของ Domain | Technical Constraint | |
|---|---|---|
| เกิดจาก | กฎของ Business | การตัดสินใจทางเทคนิคของ Dev |
| ละเมิดแล้ว | Business เสียหายจริง | ระบบ error หรือต้อง refactor |
| เปลี่ยนได้ไหม | ไม่ได้ | ได้ แต่ต้อง refactor |
| ใครกำหนด | Domain Expert | Dev |
สำคัญ: Dev ไม่ควรตัดสินใจเองว่าอะไรคือ Invariant สิ่งที่ดูเหมือน “ต้องเป็นแบบนี้เสมอ” ในสายตา Dev อาจมีข้อยกเว้นใน Business จริงๆ เสมอ — ต้องถาม Domain Expert ก่อนทุกครั้ง
Entity — “สิ่งที่มี Identity เป็นของตัวเอง”
ทำความเข้าใจผ่าน analogy
ลองนึกถึงคน — แม้จะเปลี่ยนชื่อ ย้ายบ้าน แต่งงาน เปลี่ยนอาชีพ — ก็ยังเป็นคนคนเดียวกันตลอด เพราะมี Identity ของตัวเอง
Entity คือสิ่งที่มีคุณสมบัติแบบนั้น ค่าภายในจะเปลี่ยนไปแค่ไหน Entity นั้นก็ยังเป็นตัวเดิม เพราะระบบ track มันจาก Identity ไม่ใช่จาก value
ใน Sales Management Context — SalesOrder คือ Entity เพราะต่อให้เพิ่มรายการสินค้าหรือเปลี่ยน status มันก็ยังเป็น SalesOrder หมายเลขเดิม ทีม Sales และทีม Cost Accounting ต่างก็ track order เดียวกันผ่าน orderId เดียวกัน
OOP Style vs Functional Style
OOP Style:
class SalesOrder {
private readonly id: SalesOrderId
private items: SalesOrderItem[]
private status: SalesOrderStatus
constructor(id: SalesOrderId, items: SalesOrderItem[], status: SalesOrderStatus) {
// Invariant: ต้องมีอย่างน้อย 1 รายการสินค้า
if (items.length === 0) throw new Error("SalesOrder ต้องมีอย่างน้อย 1 รายการสินค้า")
this.id = id
this.items = items
this.status = status
}
// methods ที่ mutate state อยู่ใน class
addItem(item: SalesOrderItem): void {
if (this.status !== "draft") throw new Error("เพิ่มรายการไม่ได้หลัง confirm")
this.items.push(item)
}
}
Functional Style (Series ใช้เป็นหลัก):
type SalesOrder = {
readonly id: SalesOrderId
readonly customerId: CustomerId
readonly items: readonly SalesOrderItem[]
readonly status: SalesOrderStatus
readonly createdAt: Date
}
// functions แยกออกมา ไม่อยู่ใน type
// รับ SalesOrder เข้า คืน SalesOrder ใหม่ออก — ไม่มี mutation
const addItem = (
order: SalesOrder,
item: SalesOrderItem
): Result<SalesOrder, string> => {
if (order.status !== "draft") return Err("เพิ่มรายการไม่ได้หลัง confirm")
return Ok({ ...order, items: [...order.items, item] })
}
ประกาศ Type ให้ชัดเจนสำหรับ Identity
Entity แต่ละตัวต้องมี Identity ที่ชัดเจน วิธีที่ Series Scale-Ready ใช้คือประกาศ type แยกสำหรับแต่ละ Entity
type SalesOrderId = string
type CustomerId = string
type ProductId = string // Shared Kernel จาก MDM
แค่นี้ก็เพียงพอแล้ว — คนอ่านโค้ดรู้ทันทีว่า function นี้รับ id ของอะไร
// อ่านแล้วรู้ทันทีว่ารับ SalesOrderId เท่านั้น
const findOrder = (id: SalesOrderId): Promise<SalesOrder | null> => { ... }
สร้าง Identity ด้วย UUID:
const orderId = crypto.randomUUID() as SalesOrderId
ทำไมใช้ UUID + named type แทน Branded Type
UUID unique อยู่แล้วโดยธรรมชาติ ถ้าส่ง id ผิดประเภท ระบบหา record ไม่เจอเอง โดยไม่ต้องให้ TypeScript จับ — ปัญหาที่ Branded Type แก้จึงแทบไม่เกิดขึ้นจริง
type SalesOrderId = stringให้ประโยชน์เรื่อง self-documenting code เหมือนกัน โดยไม่เพิ่มความซับซ้อนของ syntax โดยไม่จำเป็น
ProductIdที่ใช้ในSalesOrderItemคือ Shared Kernel เดียวกับที่มาจากProductMasterใน MDM Context — ใน Prerequisite 1 เราเรียก pattern นี้ว่า Shared Kernel
Value Object — “สิ่งที่ไม่มี Identity แต่มีความหมาย”
ทำความเข้าใจผ่าน analogy
ลองนึกถึงธนบัตร 100 บาท — ไม่มีใครสนว่าใบนั้น print วันไหน อยู่ที่ธนาคารไหนมาก่อน ธนบัตร 100 บาทใบไหนก็เหมือนกัน เพราะเปรียบเทียบด้วย value ไม่ใช่ reference
Value Object คือสิ่งที่มีคุณสมบัติแบบนั้น — Money(100, "THB") สองตัวเท่ากันทุกประการ ไม่สนว่าสร้างคนละเวลากัน
ที่สำคัญกว่านั้น — Value Object คือเครื่องมือ enforce Invariant ราคาขายที่ติดลบไม่ควร exist ได้ใน Sales Context เลย ใน OOP การทำให้ Money(-500, "THB") เป็นไปไม่ได้คือ constructor throw error ใน Functional Style คือ createMoney(-500, "THB") return Err แทน
OOP Style vs Functional Style
OOP Style:
class Money {
constructor(
private readonly amount: number,
private readonly currency: string
) {
// enforce invariant ใน constructor
if (amount < 0) throw new Error("amount ติดลบไม่ได้")
if (!currency) throw new Error("ต้องระบุ currency")
}
equals(other: Money): boolean {
return this.amount === other.amount && this.currency === other.currency
}
add(other: Money): Money {
if (this.currency !== other.currency)
throw new Error("currency ไม่ตรงกัน")
return new Money(this.amount + other.amount, this.currency)
}
}
Functional Style (Series ใช้เป็นหลัก):
type Money = {
readonly amount: number // หน่วย: สตางค์ (หลีกเลี่ยง floating point)
readonly currency: string // ISO 4217: "THB", "USD"
}
// smart constructor — enforce invariant ก่อน create
// caller รู้ทันทีจาก return type ว่าอาจ fail ได้
const createMoney = (
amount: number,
currency: string
): Result<Money, string> => {
if (amount < 0) return Err("amount ติดลบไม่ได้")
if (!currency) return Err("ต้องระบุ currency")
return Ok({ amount, currency })
}
const addMoney = (a: Money, b: Money): Result<Money, string> => {
if (a.currency !== b.currency)
return Err(`currency ไม่ตรงกัน: ${a.currency} vs ${b.currency}`)
return Ok({ amount: a.amount + b.amount, currency: a.currency })
}
Smart Constructor คืออะไร
Smart Constructor คือ Factory Function ที่ enforce Invariant ก่อนสร้าง object — ต่างจาก Factory Function ทั่วไปตรงที่ return
Result<T, Error>แทน object ตรงๆ บังคับให้ caller จัดการ error ก่อนใช้ค่าได้เสมอFactory Function = สร้าง object ให้ Smart Constructor = ตรวจ Invariant ก่อน แล้วค่อยสร้าง ถ้าผ่าน → Ok(object) ถ้าไม่ผ่าน → Err(reason)
sellingPrice ใน SalesProduct ก็ควรเป็น Money ไม่ใช่ number เปล่าๆ เพราะ number เปล่าๆ ไม่บังคับ currency และไม่ป้องกันค่าติดลบ
Mutable vs Immutable — Technical Decision ไม่ใช่ DDD ระบุ
DDD ไม่ได้กำหนดว่า Entity หรือ Value Object ต้องเป็น Mutable หรือ Immutable — นั่นเป็นผลที่ตามมาจาก style ที่เลือก
OOP Style Functional Style Entity Mutable Immutable Value Object Immutable Immutable “Mutable” ใน OOP ไม่ได้แปลว่าแก้ค่าตรงๆ จากข้างนอกได้ แต่หมายถึง state เปลี่ยนได้ผ่าน method ของ instance เท่านั้น — Invariant ยังถูก enforce อยู่เสมอ
order.status = "confirmed" // ❌ ข้าม method — Invariant ไม่ถูก enforce order.confirm() // ✅ ผ่าน method — Invariant ถูก enforceSeries Scale-Ready ใช้ Functional เป็นหลัก ดังนั้นทั้งคู่เป็น Immutable
✅ DO vs ❌ DON’T
// ❌ Mutable Value Object — ผิด concept
let address = { city: "Chiang Mai", province: "เชียงใหม่" }
address.city = "Bangkok" // mutation โดยตรง
// ✅ Immutable — สร้าง Value Object ใหม่ทุกครั้งที่เปลี่ยน
type ShippingAddress = {
readonly recipientName: string
readonly city: string
readonly province: string
readonly postalCode: string
}
// เปลี่ยน city = สร้าง ShippingAddress ใหม่
const updatedAddress: ShippingAddress = { ...address, city: "Bangkok" }
Aggregate — Consistency Boundary ที่ข้อมูลต้องสอดคล้องกันเสมอ
ทำความเข้าใจผ่าน analogy
ลองนึกถึงการเปิดคำสั่งซื้อในระบบ ERP จริงๆ — เมื่อฝ่าย Sales เปิดคำสั่งซื้อ ระบบต้องรู้ทันทีว่ามีสินค้าอะไรบ้าง จำนวนเท่าไหร่ ราคาเท่าไหร่ ข้อมูลเหล่านี้ต้องสอดคล้องกันตลอดเวลา
ถ้าใครสักคนไปแก้จำนวนสินค้าโดยตรงโดยไม่ผ่านคำสั่งซื้อ — ยอดรวมจะผิดทันที Cost Accounting จะได้ตัวเลขที่ไม่ตรงกับความเป็นจริง
นั่นคือเหตุผลว่าทำไม SalesOrder ต้องเป็น Aggregate — มันทำหน้าที่สองอย่างพร้อมกัน
1. ทุกการเปลี่ยนแปลงต้องผ่าน SalesOrder เสมอ — ใครอยากแก้ SalesOrderItem ต้องผ่าน SalesOrder เสมอ Invariant ทั้งหมดถูก enforce ที่นี่
2. เป็น Transaction Boundary — SalesOrder และ SalesOrderItem ทั้งหมด save พร้อมกันใน transaction เดียวเสมอ ข้อมูลจะไม่มีวันสอดคล้องกันครึ่งๆ กลางๆ
SalesOrder (Aggregate)
│
├── id: SalesOrderId
├── customerId: CustomerId
├── status: SalesOrderStatus
├── discount: Money
│
└── SalesOrderItem[] ← ทุกการเปลี่ยนแปลงต้องผ่าน SalesOrder เสมอ
productId: ProductId ← Shared Kernel จาก MDM
productName: string ← snapshot ตอนสั่งซื้อ ไม่ reference live
quantity: number
unitPrice: Money ← snapshot ของ SalesProduct.sellingPrice
สังเกตว่า
SalesOrderItemเก็บ snapshot ของproductNameและunitPriceณ เวลาที่สั่งซื้อ ไม่ใช่ reference ไปยังSalesProductโดยตรง เพราะถ้า Sales team เปลี่ยนsellingPriceทีหลัง ราคาในคำสั่งซื้อเก่าไม่ควรเปลี่ยนตาม
แต่ละ Aggregate มี Root เพียงหนึ่งเดียว
Root คือ object หลักที่เป็นตัวแทนของ Aggregate ทั้งหมด — ใน
SalesOrderนั้นSalesOrderเองคือ Root ไม่ใช่SalesOrderItemกฎนี้หมายความว่า Repository จะ save และ load แค่ Root เท่านั้น ไม่มี
SalesOrderItemRepositoryแยกต่างหาก เพราะSalesOrderItemไม่มีชีวิตอิสระนอกSalesOrder
OOP Style vs Functional Style
OOP Style:
class SalesOrder { // Aggregate — ทุกการเปลี่ยนแปลงต้องผ่านที่นี่
private items: SalesOrderItem[] = []
private status: SalesOrderStatus = "draft"
addItem(item: SalesOrderItem): void {
// enforce invariant ที่นี่ — ไม่ให้ caller ข้ามได้
if (this.status !== "draft")
throw new Error("เพิ่มรายการไม่ได้หลัง confirm")
if (item.quantity <= 0)
throw new Error("quantity ต้องมากกว่า 0")
this.items.push(item)
}
confirm(): void {
if (this.items.length === 0)
throw new Error("confirm ไม่ได้ถ้าไม่มีรายการสินค้า")
this.status = "confirmed"
}
}
Functional Style (Series ใช้เป็นหลัก):
โครงสร้างไฟล์:
sales-order/
├── sales-order.type.ts ← type ทั้งหมด
├── sales-order.ts ← functions ทั้งหมด
└── index.ts ← barrel export สำหรับ expose ออกนอก module
sales-order.type.ts
export type SalesOrderStatus = "draft" | "confirmed" | "cancelled"
export type SalesOrderItem = {
readonly productId: ProductId
readonly productName: string // snapshot ตอนสั่งซื้อ
readonly quantity: number
readonly unitPrice: Money // snapshot ของ SalesProduct.sellingPrice
}
export type SalesOrder = {
readonly id: SalesOrderId
readonly customerId: CustomerId
readonly items: readonly SalesOrderItem[]
readonly status: SalesOrderStatus
readonly discount: Money
}
sales-order.ts
import type { SalesOrder, SalesOrderItem } from "./sales-order.type"
// Named export — แต่ละ function export แยกกัน
export const addItem = (
order: SalesOrder,
item: SalesOrderItem
): Result<SalesOrder, string> => {
if (order.status !== "draft")
return Err("เพิ่มรายการไม่ได้หลัง confirm")
if (item.quantity <= 0)
return Err("quantity ต้องมากกว่า 0")
return Ok({ ...order, items: [...order.items, item] })
}
export const confirmSalesOrder = (
order: SalesOrder
): Result<SalesOrder, string> => {
if (order.items.length === 0)
return Err("confirm ไม่ได้ถ้าไม่มีรายการสินค้า")
return Ok({ ...order, status: "confirmed" as const })
}
index.ts
// Barrel export — expose เฉพาะสิ่งที่ต้องการให้ภายนอก module เห็น
export type { SalesOrder, SalesOrderItem, SalesOrderStatus } from "./sales-order.type"
export { addItem, confirmSalesOrder } from "./sales-order"
Aggregate กับ Transaction Boundary
Aggregate เป็นตัวกำหนด consistency boundary ในระดับ Domain — ทุกอย่างภายใน Aggregate ต้องสอดคล้องกันเสมอ และควร save พร้อมกันใน transaction เดียว
กฎง่ายๆ: 1 Transaction = 1 Aggregateถ้า Use Case ต้องการแก้ 2 Aggregate พร้อมกัน — ใช้ Domain Event แทนการทำ Transaction ข้าม Aggregate
Unit of Work และ Domain Event จะอธิบายในรายละเอียดในเนื้อหาถัดไป
✅ DO vs ❌ DON’T
// ❌ ข้าม Aggregate Root — ไปแก้ SalesOrderItem โดยตรง
await salesOrderItemRepository.save(newItem)
// invariant ของ SalesOrder ไม่ถูก enforce เลย
// ✅ ผ่าน Aggregate Root เสมอ
const result = addItem(order, newItem)
if (result.isOk()) {
await salesOrderRepository.save(result.value) // save ทั้ง SalesOrder
}
Repository — “Abstraction ของการเข้าถึงข้อมูล”
ทำความเข้าใจผ่าน analogy
เมื่อฝ่าย Sales ต้องการดึงคำสั่งซื้อหมายเลข SO-001 ขึ้นมาแก้ไข Domain code แค่บอกว่า “ขอ SalesOrder หมายเลขนี้” — ไม่รู้ว่าเก็บอยู่ใน Postgres หรือ Redis ไม่รู้ว่า query ยังไง นั่นคือหน้าที่ของ Repository
จุดสำคัญที่ต้องเน้น
Repository ใน DDD Tactical = interface เท่านั้น
Domain Layer:
interface SalesOrderRepository ← interface อยู่ที่นี่ ไม่รู้จัก Database เลย
Infrastructure Layer:
class PrismaSalesOrderRepository implements SalesOrderRepository ← implementation อยู่ที่นี่
นี่คือจุดที่ DDD เชื่อมกับ Hexagonal Architecture โดยตรง — Repository interface คือ Port และ Prisma implementation คือ Adapter จะเห็นชัดขึ้นใน Prerequisite 3
OOP Style vs Functional Style (ส่วนนี้เหมือนกัน)
ทั้งสอง style ใช้ interface เหมือนกัน:
// interface อยู่ใน Domain Layer — ไม่รู้จัก Prisma, SQL, หรืออะไรเลย
interface SalesOrderRepository {
findById(id: SalesOrderId): Promise<SalesOrder | null>
findByCustomerId(customerId: CustomerId): Promise<SalesOrder[]>
save(order: SalesOrder): Promise<void>
}
ต่างกันตรงที่ inject เข้า Use Case:
// OOP Style: inject ผ่าน constructor
class PlaceSalesOrderUseCase {
constructor(private readonly orderRepo: SalesOrderRepository) {}
async execute(input: PlaceSalesOrderInput): Promise<SalesOrder> { ... }
}
const useCase = new PlaceSalesOrderUseCase(prismaSalesOrderRepo)
await useCase.execute(input)
// Functional Style: ส่ง repository เป็น parameter ตรงๆ
const placeSalesOrder = async (
deps: { orderRepo: SalesOrderRepository },
input: PlaceSalesOrderInput
): Promise<Result<SalesOrder, string>> => { ... }
await placeSalesOrder({ orderRepo: prismaSalesOrderRepo }, input)
✅ DO vs ❌ DON’T
// ❌ import Prisma ใน Domain function โดยตรง
import { PrismaClient } from "@prisma/client"
const findSalesOrder = async (id: SalesOrderId) => {
const prisma = new PrismaClient()
return prisma.salesOrder.findUnique({ where: { id } })
// Domain logic รู้จัก Prisma — test ยากมาก ต้อง mock Prisma ทั้งหมด
}
// ✅ รับ SalesOrderRepository interface เป็น parameter
const findSalesOrder = async (
repo: SalesOrderRepository,
id: SalesOrderId
): Promise<SalesOrder | null> => {
return repo.findById(id)
// test ได้ง่ายมาก แค่ pass mock repo เข้าไป
}
Domain Service — “logic ที่ไม่เป็นของ Entity ใดเลย”
ทำความเข้าใจผ่าน analogy
ลองนึกถึงการตรวจ credit limit ก่อนรับคำสั่งซื้อในระบบ ERP — ฝ่าย Sales เปิดคำสั่งซื้อให้ลูกค้า แต่ใครเป็นคนตรวจว่ายอดสั่งซื้อเกิน credit limit หรือเปล่า?
ลูกค้าตรวจ credit ตัวเอง?
→ ในความเป็นจริง ลูกค้าไม่ได้เป็นคนตรวจ
คำสั่งซื้อรู้ว่าลูกค้ามี credit เท่าไหร่?
→ คำสั่งซื้อแค่บันทึกว่าสั่งอะไร เท่าไหร่
คำตอบคือ ระบบ ERP เป็นคนตรวจสอบข้อมูลจากทั้งสองฝ่ายแล้วตัดสินใจ
→ ไม่เป็นของ Entity ใด = Domain Service
Domain Service คือ pure logic ที่ตอบคำถาม “ใครเป็นเจ้าของ logic นี้?” ไม่ได้ เพราะมันเกี่ยวข้องกับหลาย Entity พร้อมกัน และที่สำคัญ ไม่รู้จัก I/O (Side Effect) ใดๆ ทั้งสิ้น
Domain Service ต้องอยู่ภายใน BC เดียวกันเท่านั้น
Domain Service รู้จักได้เฉพาะ model ภายใน BC ของตัวเอง ถ้า logic ต้องรู้จัก model ของ BC อื่น — นั่นไม่ใช่ Domain Service แล้ว
✅ Domain Service ภายใน Sales BC validateCreditLimit(customer, order) → ทั้ง Customer และ SalesOrder อยู่ใน Sales BC ❌ ข้าม BC — ไม่ใช่ Domain Service checkStockAvailability(order, stockLevel) → SalesOrder อยู่ใน Sales BC → StockLevel อยู่ใน Stock BCถ้าต้องการ logic ที่ข้าม BC ให้ใช้ Application Service orchestrate ผ่าน ServiceClient แทน — จะเห็นชัดขึ้นใน Prerequisite 3
OOP Style vs Functional Style
OOP Style:
// validate-credit-limit.ts
class CreditLimitService {
// stateless class — ไม่มี state ของตัวเอง
validate(customer: Customer, order: SalesOrder): void {
if (order.totalAmount.amount > customer.creditLimit.amount)
throw new Error("ยอดสั่งซื้อเกิน credit limit")
// caller ต้องใช้ try-catch เพื่อจัดการ error
}
}
Functional Style (Series ใช้เป็นหลัก):
// validate-credit-limit.ts
// pure function ตรงๆ — ไม่ต้อง wrap ใน class
// รับ data เข้า คืน Result ออก — ไม่มี side effect
export const validateCreditLimit = (
customer: Customer,
order: SalesOrder
): Result<void, string> => {
if (order.totalAmount.amount > customer.creditLimit.amount)
return Err("ยอดสั่งซื้อเกิน credit limit")
return Ok(undefined)
// caller รู้ทันทีจาก return type ว่าอาจ fail ได้
}
✅ DO vs ❌ DON’T
// ❌ Domain Service ปนกับ I/O (Side Effect)
// test ต้อง mock repo ทั้งหมด แทนที่จะ pass data ตรงๆ
const validateCreditLimit = async (
orderRepo: SalesOrderRepository, // ← I/O
customerRepo: CustomerRepository, // ← I/O
orderId: SalesOrderId,
customerId: CustomerId
): Promise<void> => {
const order = await orderRepo.findById(orderId) // ← side effect
const customer = await customerRepo.findById(customerId) // ← side effect
if (order.totalAmount.amount > customer.creditLimit.amount)
throw new Error("ยอดสั่งซื้อเกิน credit limit")
}
// ❌ Domain Service ข้าม BC
// StockLevel มาจาก Stock BC — ไม่ใช่ Sales BC
const checkOrderFulfillable = (
order: SalesOrder,
stockLevel: StockLevel // ← ข้าม BC
): boolean => { ... }
// ✅ Domain Service — pure function รับ data เข้า คืน Result ออก
// test ได้ทันทีโดยไม่ต้อง mock อะไรเลย
// validate-credit-limit.ts
export const validateCreditLimit = (
customer: Customer,
order: SalesOrder
): Result<void, string> => {
if (order.totalAmount.amount > customer.creditLimit.amount)
return Err("ยอดสั่งซื้อเกิน credit limit")
return Ok(undefined)
}
Application Service — “Business Operation ตาม Requirement”
ทำความเข้าใจผ่าน analogy
BA เก็บ requirement จาก Domain Expert ฝ่าย Sales แล้วพบว่าระบบต้องมี capability เหล่านี้
Capability ที่ต้องการ:
- เปิดคำสั่งซื้อให้ลูกค้าได้
- ยืนยันคำสั่งซื้อได้
- ยกเลิกคำสั่งซื้อได้
BA จัด group capability เหล่านี้เป็น Feature “การจัดการคำสั่งซื้อ” แล้วส่งให้ Dev
แต่ละ capability คือ Application Service หนึ่งตัว — ชื่อในโค้ดควรตรงกับที่ BA และ Domain Expert ใช้ เพราะนั่นคือ Ubiquitous Language ที่วางไว้ใน Prerequisite 1
PlaceSalesOrder ← เปิดคำสั่งซื้อ
ConfirmSalesOrder ← ยืนยันคำสั่งซื้อ
CancelSalesOrder ← ยกเลิกคำสั่งซื้อ
Application Service ทำหน้าที่สองอย่างพร้อมกัน
1. สะท้อน Business Operation จริงๆ — ชื่อและ flow ตรงกับสิ่งที่ BA และ Domain Expert อธิบาย
2. จัดการ I/O (Side Effect) ทั้งหมด — ดึงข้อมูลจาก Repository, เรียก Domain Service, คุยกับ Context อื่นผ่าน ServiceClient, แล้ว save ผลลัพธ์กลับ
Application Service รู้จัก I/O (Side Effect) แต่ ไม่รู้จัก HTTP, Framework, หรือ UI — นั่นคือความต่างระหว่าง Application Service กับ Controller หรือ Handler ที่จะเห็นใน Prerequisite 3
ความต่างจาก Domain Service
| Domain Service | Application Service | |
|---|---|---|
| รู้จัก I/O (Side Effect) ไหม | ❌ ไม่รู้เลย — pure logic | ✅ รู้จัก Repository, ServiceClient |
| ทดสอบยังไง | Test โดยไม่ mock อะไร | Test โดย mock dependencies |
| ทำหน้าที่อะไร | คำนวณ / ตรวจสอบ logic | ประสาน flow ทั้งหมดของ Business Operation |
Application Service คือ class หรือ function ที่ทำหน้าที่ประสาน flow ทั้งหมดของ Business Operation หนึ่งๆ เช่น PlaceSalesOrder — รู้จัก Repository และ ServiceClient แต่ไม่รู้จัก HTTP, Framework, หรือ UI
ใน Sales Management Context — placeSalesOrder ต้องตรวจ credit limit ของลูกค้า ตรวจ stock กับ Stock Context ผ่าน StockServiceClient สร้าง SalesOrder แล้ว save
StockServiceClientคือ interface ที่ Sales Context ใช้คุยกับ Stock & Inventory Context ที่เราแบ่งไว้ใน Prerequisite 1 — ใน Series Scale-Ready จะเรียก pattern นี้ว่า ServiceClient
OOP Style vs Functional Style
OOP Style:
// place-sales-order.usecase.ts
class PlaceSalesOrderUseCase {
constructor(
private readonly orderRepo: SalesOrderRepository,
private readonly customerRepo: CustomerRepository,
private readonly stockClient: StockServiceClient,
private readonly creditLimitService: CreditLimitService
) {}
async execute(input: PlaceSalesOrderInput): Promise<SalesOrder> {
// 1. ดึงข้อมูล Customer
const customer = await this.customerRepo.findById(input.customerId)
if (!customer) throw new Error("ไม่พบ Customer")
// 2. ตรวจ credit limit — เรียก Domain Service
this.creditLimitService.validate(customer, input.order)
// 3. ตรวจสอบ stock กับ Stock Context
const stockResult = await this.stockClient.checkAvailability(input.items)
if (!stockResult.available) throw new Error("สินค้าไม่เพียงพอ")
// 4. สร้าง SalesOrder
const order = new SalesOrder(generateId(), input.customerId, input.items)
// 5. Confirm และ Save
order.confirm()
await this.orderRepo.save(order)
return order
}
}
Functional Style (Series ใช้เป็นหลัก):
โครงสร้างไฟล์:
place-sales-order/
├── place-sales-order.type.ts ← type ทั้งหมด
├── place-sales-order.ts ← function
└── index.ts ← barrel export
place-sales-order.type.ts
export type PlaceSalesOrderDeps = {
orderRepo: SalesOrderRepository
customerRepo: CustomerRepository
stockClient: Pick<StockServiceClient, "checkAvailability">
}
export type PlaceSalesOrderInput = {
readonly customerId: CustomerId
readonly items: readonly SalesOrderItem[]
}
export type PlaceSalesOrderError =
| { code: "CUSTOMER_NOT_FOUND" }
| { code: "CREDIT_LIMIT_EXCEEDED" }
| { code: "INSUFFICIENT_STOCK" }
| { code: "INVALID_ORDER"; reason: string }
| { code: "CONFIRM_FAILED"; reason: string }
place-sales-order.ts
import type { PlaceSalesOrderDeps, PlaceSalesOrderInput, PlaceSalesOrderError } from "./place-sales-order.type"
// รับ dependencies เป็น parameter ตรงๆ — ไม่ต้อง new class
export const placeSalesOrder = async (
deps: PlaceSalesOrderDeps,
input: PlaceSalesOrderInput
): Promise<Result<SalesOrder, PlaceSalesOrderError>> => {
// 1. ดึงข้อมูล Customer
const customer = await deps.customerRepo.findById(input.customerId)
if (!customer) return Err({ code: "CUSTOMER_NOT_FOUND" })
// 2. ตรวจ credit limit — เรียก Domain Service (pure function)
const creditResult = validateCreditLimit(customer, input.order)
if (creditResult.isErr()) return Err({ code: "CREDIT_LIMIT_EXCEEDED" })
// 3. ตรวจสอบ stock กับ Stock Context
const stock = await deps.stockClient.checkAvailability(input.items)
if (!stock.available) return Err({ code: "INSUFFICIENT_STOCK" })
// 4. สร้าง SalesOrder ผ่าน smart constructor (enforce invariant)
const orderResult = createSalesOrder({
customerId: input.customerId,
items: input.items,
})
if (orderResult.isErr()) return Err({ code: "INVALID_ORDER", reason: orderResult.error })
// 5. Confirm และ Save
const confirmedResult = confirmSalesOrder(orderResult.value)
if (confirmedResult.isErr()) return Err({ code: "CONFIRM_FAILED", reason: confirmedResult.error })
await deps.orderRepo.save(confirmedResult.value)
return Ok(confirmedResult.value)
}
index.ts
export type { PlaceSalesOrderDeps, PlaceSalesOrderInput, PlaceSalesOrderError } from "./place-sales-order.type"
export { placeSalesOrder } from "./place-sales-order"
validateCreditLimitคือ Domain Service — pure function ที่อยู่ใน Sales BC รับ data เข้า คืน Result ออก ไม่มี I/O (Side Effect) ใดๆ Application Service เป็นคนดึงข้อมูลมาก่อนแล้วค่อยส่งเข้า Domain Service
OOP → Functional Translation Table
“ของเดิมยังอยู่ แค่เปลี่ยนรูปร่าง”
| DDD Concept | OOP Style | Functional Style (Series ใช้เป็นหลัก) |
|---|---|---|
| Entity | class SalesOrder { methods } | type SalesOrder + pure functions แยกไฟล์ |
| Value Object | immutable class + constructor throw | readonly type + smart constructor + Result |
| Aggregate | class เป็น root, enforce ผ่าน methods | ไม่มี class, enforce ผ่าน module functions |
| Invariant | throw ใน constructor/setter | return Result<T, Error> — ไม่ throw |
| Repository | interface (เหมือนกัน) | interface (เหมือนกัน) |
| Domain Service | stateless class ที่มี method | pure function ตรงๆ ไม่ต้อง wrap class |
| Application Service | class ที่ inject repo ผ่าน constructor | function ที่รับ deps เป็น parameter |
| Domain Event | class หรือ interface | type + const (เหมือนกัน) |
3 สิ่งที่ต่างออกไปมากที่สุดใน Functional Style
1. ไม่มี Aggregate Root class
แทนด้วย module functions ทุก function รับ Aggregate เข้าแล้วคืน Aggregate ใหม่ออก ไม่มี mutation ใดๆ:
// OOP Style
const order = new SalesOrder(id, customerId, items)
order.addItem(newItem) // mutate state ใน object
order.confirm() // mutate state ใน object
// Functional Style
const orderResult = createSalesOrder({ customerId, items })
const withItemResult = orderResult.andThen(o => addItem(o, newItem))
const confirmedResult = withItemResult.andThen(confirmSalesOrder)
// แต่ละขั้นตอนสร้าง SalesOrder ใหม่ — ไม่แตะ SalesOrder ต้นฉบับ
2. Invariant ไม่ throw Exception — ใช้ Result แทน
// OOP Style — caller ต้องเดาว่า function ไหน throw บ้าง
try {
const order = new SalesOrder(id, customerId, []) // throw ถ้าไม่มีรายการ
order.confirm() // throw ถ้า already confirmed
} catch (e) {
// จัดการ error ที่นี่
}
// Functional Style — return type บอกทุกอย่างตั้งแต่ compile time
const createResult = createSalesOrder({ customerId, items: [] })
// createResult: Result<SalesOrder, "EMPTY_ITEMS">
// caller รู้ทันทีว่าอาจ fail และต้องจัดการ error อย่างไร
if (createResult.isErr()) {
return Err(createResult.error) // ไม่ต้อง try-catch
}
3. Dependency Injection ผ่าน Parameter ไม่ใช่ Constructor
// OOP Style — ต้อง instantiate class ก่อน
const useCase = new PlaceSalesOrderUseCase(
new PrismaSalesOrderRepo(prisma),
new PrismaCustomerRepo(prisma),
new StockHttpClient(env.STOCK_SERVICE_URL),
new CreditLimitService()
)
await useCase.execute(input)
// Functional Style — ส่ง deps เป็น object ตรงๆ
await placeSalesOrder(
{
orderRepo: new PrismaSalesOrderRepo(prisma),
customerRepo: new PrismaCustomerRepo(prisma),
stockClient: new StockHttpClient(env.STOCK_SERVICE_URL),
},
input
)
// test ง่ายกว่ามาก — แค่ pass mock object เข้าไปแทน
Domain Event — “บันทึกเหตุการณ์ที่ Context อื่นต้องรู้”
Domain Event คือ “สิ่งที่เกิดขึ้นแล้วใน Domain และ Context อื่นอาจสนใจ”
ลองนึกถึง Context Mapping ที่วางไว้ใน Prerequisite 1 — เมื่อ Sales Context สร้าง SalesOrder สำเร็จ Context อื่นต้องรู้ด้วย:
SalesOrderPlaced → Cost Accounting Context สนใจ เพื่อสร้าง Invoice
SalesOrderPlaced → Stock Context สนใจ เพื่อ reserve stock
SalesOrderCancelled → Stock Context สนใจ เพื่อคืน stock
ทั้ง OOP และ Functional ใช้ type เหมือนกัน:
type SalesOrderPlaced = {
readonly type: "SalesOrderPlaced"
readonly orderId: SalesOrderId
readonly customerId: CustomerId
readonly totalAmount: Money
readonly items: readonly SalesOrderItem[]
readonly placedAt: Date
}
type SalesOrderCancelled = {
readonly type: "SalesOrderCancelled"
readonly orderId: SalesOrderId
readonly reason: string
readonly cancelledAt: Date
}
// Union type รวม events ทั้งหมดของ Sales Context
type SalesDomainEvent = SalesOrderPlaced | SalesOrderCancelled | SalesOrderItemAdded
บทความนี้แนะนำให้รู้จัก concept ก่อน Series Scale-Ready จะลงลึกเรื่อง Domain Event ใน Part ที่เกี่ยวข้อง รวมถึงวิธีที่ events เดินทางข้าม Context ผ่าน Message Bus
สรุปตามบทบาท
Developer — “สิ่งที่คุณทำได้จากวันนี้”
ก่อนเขียน function หรือ class ใหม่ใน Sales Management Context ให้ถามตัวเองก่อน:
“นี่คือ Domain Logic หรือ Infrastructure?” ถ้าโค้ดนั้นรู้จัก Database, HTTP Client, หรือ Framework — มันไม่ใช่ Domain Logic อย่าผสมกัน
“นี่คือ Business Rule หรือ Invariant?” ถ้าเป็น Invariant ต้องอยู่ใกล้ข้อมูลมากที่สุด และต้องป้องกันได้โดยไม่ขึ้นกับ caller
“นี่คือ Domain Service หรือ Application Service?” ถ้า function นั้นรู้จัก Repository หรือ StockServiceClient — มันคือ Application Service ไม่ใช่ Domain Service
Engineering Manager — “สิ่งที่คุณสังเกตได้ในทีม”
ถ้า PR มีการแก้ business logic อยู่ในไฟล์เดียวกับ database call — นั่นคือ signal ว่า layer ยังปนกันอยู่ ใช้ภาษา Domain เหล่านี้ในการ review PR ได้เลย:
“function นี้ควรเป็น pure Domain Service หรือเปล่า? เห็นว่ามี await stockClient.checkAvailability() อยู่ด้วย”
“Invariant ‘SalesOrder ต้องมีอย่างน้อย 1 รายการ’ enforce ที่ไหน? เห็นว่า validate แค่ที่ API layer”
Business Analyst — “คำถามที่คุณถาม Dev ได้ตั้งแต่วันนี้”
ใช้ภาษาของ Tactical Design ในการสื่อสารกับ Dev ได้เลย:
“Use Case ‘PlaceSalesOrder’ อยู่ใน Sales Management Context — ชื่อนี้ตรงกับที่ Domain Expert ฝ่าย Sales ใช้ไหม?”
“กฎ ‘SalesOrder ต้องมีอย่างน้อย 1 รายการ’ เป็น Business Rule หรือ Invariant? เพราะอยากให้ Sales team draft order ก่อนเลือกสินค้าได้”
“ถ้าเราเปลี่ยน DiscountPolicy ของลูกค้ากลุ่ม Corporate มันกระทบ SalesOrder ที่ confirm ไปแล้วไหม?”
บทความถัดไป
ตอนนี้รู้แล้วว่า Tactical Design ทำอะไรบ้างภายใน Bounded Context — ออกแบบ Entity, Value Object, Aggregate, Domain Service, Repository, Application Service และ Domain Event
สรุป flow ของ Tactical Design ที่ทำไปในบทความนี้
Tactical Design (ทำแยกในแต่ละ Bounded Context)
7. ระบุ Business Function ทั้งหมด (Application Service)
8. ออกแบบ Entity, Value Object, Aggregate
และระบุ Domain Service ที่ไม่เป็นของ Entity ใดเลย
9. กำหนด Business Rule และ Invariant
10. ออกแบบ Repository Interface
11. กำหนด Domain Event
— Business Function ไหนเสร็จแล้วต้องแจ้ง Context อื่น (ตาม Context Mapping)
— Context อื่น subscribe event อะไรบ้าง
และนี่คือ flow ทั้งหมดตั้งแต่ต้นจนถึงจุดที่เรามาถึง
Strategic Design
1. ระบุ Domain
2. แบ่ง Sub-Domain + ประเภท (Core/Supporting/Generic)
3. กำหนดหัวข้อการแก้ปัญหา
4. กำหนดขอบเขต (Bounded Context)
5. วาง Ubiquitous Language เบื้องต้น
6. วาด Context Mapping
↓
Tactical Design (ทำแยกในแต่ละ Bounded Context)
7. ระบุ Business Function ทั้งหมด (Application Service)
8. ออกแบบ Entity, Value Object, Aggregate
และระบุ Domain Service ที่ไม่เป็นของ Entity ใดเลย
9. กำหนด Business Rule และ Invariant
10. ออกแบบ Repository Interface
11. กำหนด Domain Event
— Business Function ไหนเสร็จแล้วต้องแจ้ง Context อื่น (ตาม Context Mapping)
— Context อื่น subscribe event อะไรบ้าง
↓
Architecture
12. วาง Building Blocks ลงใน Layer จริง
13. กำหนด Package และ Dependency
คำถามที่เหลือคือ — “Building Blocks พวกนี้ควรอยู่ที่ไหนในโครงสร้างโค้ด และใครควรรู้จักใคร?”
นั่นคือสิ่งที่ Clean Architecture และ Hexagonal Architecture มาตอบ Clean Architecture บอกว่า dependency ควรไหลทิศทางไหน Hexagonal Architecture บอกว่า Port และ Adapter คืออะไร และทั้งสองอธิบายว่าทำไม SalesOrderRepository ถึงต้องเป็น interface ใน Domain ไม่ใช่ implementation — และทำไม StockServiceClient ถึงต้องเป็น interface ไม่ใช่ HTTP call ตรงๆ
→ Prerequisite 3: Clean Architecture + Hexagonal Architecture
FAQ
Q: Entity กับ Value Object ต่างกันยังไงในทางปฏิบัติ?
วิธีตัดสินใจง่ายที่สุดคือถามว่า “ถ้าข้อมูลภายในเปลี่ยน ระบบยังต้อง track มันว่าเป็นสิ่งเดิมไหม?”
ถ้าใช่ — มันคือ Entity เช่น SalesOrder ต่อให้เพิ่มรายการสินค้าหรือเปลี่ยน status ก็ยังเป็น order หมายเลขเดิมที่ Domain Expert ฝ่าย Sales track ได้
ถ้าไม่ใช่ — มันคือ Value Object เช่น Money(50000, "THB") สองตัวเท่ากันทุกประการ ไม่มีเหตุผลต้อง track ว่าใครเป็นใคร หรือ DiscountPolicy ที่ type และ rate เหมือนกันก็คือ policy เดียวกัน
ทางปฏิบัติ: ถ้า type นั้นต้องมี id field — มันคือ Entity ถ้าไม่มี id และเปรียบเทียบด้วย value ล้วนๆ — มันคือ Value Object
Q: Aggregate ใหญ่แค่ไหนถึงจะพอดี?
เริ่มจาก Aggregate เล็กก่อนเสมอ แล้วค่อยขยายเมื่อมีเหตุผล กฎง่ายๆ คือ — สิ่งที่ต้องเปลี่ยนพร้อมกันเพื่อให้ข้อมูลสอดคล้องกัน ควรอยู่ใน Aggregate เดียวกัน สิ่งที่เปลี่ยนแยกกันได้ ควรอยู่คนละ Aggregate
ใน Sales Management Context — SalesOrder และ SalesOrderItem ต้องเปลี่ยนพร้อมกัน (เพิ่มรายการต้อง update ยอดรวม) จึงอยู่ใน Aggregate เดียวกัน แต่ SalesOrder กับ Customer เปลี่ยนแยกกันได้โดยสิ้นเชิง ควรอยู่คนละ Aggregate และอ้างอิงกันผ่าน CustomerId เท่านั้น
Aggregate ใหญ่เกินไปทำให้ contention สูง เพราะหลายคนต้องรอ lock เดียวกัน ถ้า Aggregate ไหนกลายเป็น bottleneck — นั่นคือ signal ว่าน่าจะแยกได้
Q: Domain Service กับ Application Service ต่างกันยังไง?
ความต่างหลักคือ “รู้จัก I/O ไหม”
Domain Service ไม่รู้จัก I/O เลย เป็น pure logic ล้วนๆ เช่น validateCreditLimit(customer, order) รับ parameter เข้า คืน Result ออก ไม่ว่าจะเรียกกี่ครั้งก็ได้ผลเหมือนกัน test ได้โดยไม่ต้อง mock อะไรเลย
Application Service รู้จัก I/O (Side Effect) รับผิดชอบ orchestrate flow ทั้งหมดของ Business Operation เช่น placeSalesOrder ต้องเรียก stockClient.checkAvailability, ดึงข้อมูล Customer จาก repo, แล้ว save SalesOrder กลับ test ได้โดย mock dependencies
ถ้าพบว่าต้อง inject Repository เข้า “Domain Service” — นั่นคือ Application Service ไม่ใช่ Domain Service
Q: Repository ใน DDD กับ Repository Pattern ทั่วไปเหมือนกันไหม?
ชื่อเหมือนกัน แต่มีจุดเน้นต่างกัน
Repository Pattern ทั่วไปมักหมายถึง “wrapper ของ Database access” เน้น abstraction ของ query
Repository ใน DDD เน้นที่การเป็น “interface ใน Domain Layer ที่ไม่รู้จัก Database เลย” สิ่งสำคัญไม่ใช่แค่ว่ามัน abstract Database — แต่ว่า Domain code ไม่รู้ว่า Database มีอยู่ด้วยซ้ำ เพราะมันทำงานกับ interface ล้วนๆ implementation จะเป็น Prisma, raw SQL, in-memory, หรืออะไรก็ได้ Domain ไม่สน
Q: Invariant กับ Data Validation ต่างกันยังไง?
Data Validation ตรวจว่า input ที่รับมาจากภายนอกอยู่ในรูปแบบที่ถูกต้อง เช่น ฟิลด์ quantity ต้องเป็นตัวเลข, ฟิลด์ customerId ต้องไม่ว่าง — เกิดก่อน business logic และมักอยู่ที่ API layer
Invariant คือกฎที่ต้องเป็นจริงตลอดเวลาของ Domain Object ต่อให้ input ผ่าน validation มาแล้ว Invariant ยังต้องถูก enforce ภายใน Domain เสมอ
ตัวอย่าง: quantity: 5 ผ่าน validation แน่นอน แต่ถ้า SalesOrder status เป็น confirmed แล้ว การเพิ่ม SalesOrderItem ยังต้องถูก block ด้วย Invariant อยู่ดี Validation บอกว่า “input ถูกรูปแบบ” แต่ Invariant บอกว่า “operation นี้ถูกต้องใน context นี้ไหม”
Q: ต้องใช้ทุกหลักการเสมอไหม?
ไม่จำเป็น ใช้เท่าที่ปัญหาต้องการ
ถ้า Context ง่ายมากและมีแค่ Entity เดียวที่ไม่มี child objects ก็ไม่จำเป็นต้องนึกถึง Aggregate ถ้าไม่มี logic ที่เกี่ยวข้องกับหลาย Entity พร้อมกัน ก็ไม่ต้องสร้าง Domain Service ขึ้นมาไว้ใช้เพียงเพราะ pattern บอกให้มี
หลักการเหล่านี้คือ “เครื่องมือ” ไม่ใช่ checklist ที่ต้องครบ สัญญาณที่บอกว่าควรนำหลักการเพิ่มเติมเข้ามาคือเมื่อ logic เริ่มกระจัดกระจาย เมื่อ test เริ่มยาก หรือเมื่อคนใหม่ในทีมหา business logic ไม่เจอ — นั่นคือเวลาที่ หลักการเหล่านี้จะช่วยได้จริง
Q: Aggregate กับ Entity ต่างกันยังไง? SalesOrder เป็นได้ทั้งสองอย่างหรือ?
ใช่ครับ — SalesOrder เป็นได้ทั้งสองอย่างพร้อมกัน แต่คนละบทบาท
Entity คือสิ่งที่มี Identity และถูก track ตลอด lifecycle — SalesOrder มี orderId และระบบ track มันจาก draft จนถึง confirmed
Aggregate คือบทบาทของ SalesOrder ในฐานะที่เป็น consistency boundary — มันเป็นตัวกำหนดว่า SalesOrderItem ทั้งหมดต้องเปลี่ยนผ่านมันเสมอ และ save พร้อมกันใน transaction เดียว
SalesOrder เป็น Entity → มี Identity, ถูก track ตลอด lifecycle
SalesOrder เป็น Aggregate → เป็น consistency boundary ของ SalesOrderItem
ทุก Aggregate ต้องมี Root ที่เป็น Entity เสมอ แต่ไม่ใช่ทุก Entity จะเป็น Aggregate Root — SalesOrderItem เป็น Entity แต่ไม่ใช่ Aggregate Root เพราะมันไม่มีชีวิตอิสระนอก SalesOrder
Q: Domain Service ข้าม Bounded Context ได้ไหม?
ไม่ได้ครับ Domain Service รู้จักได้เฉพาะ model ภายใน BC ของตัวเอง
เหตุผลคือ Bounded Context คือขอบเขตของ Ubiquitous Language — ถ้า Domain Service รู้จัก model ของ BC อื่น มันจะทำให้ BC สองอันผูกกันโดยตรง ซึ่งขัดกับหลักการ isolation ของ Bounded Context ที่วางไว้ใน Prerequisite 1
✅ Domain Service ภายใน Sales BC
validateCreditLimit(customer, order)
→ ทั้ง Customer และ SalesOrder อยู่ใน Sales BC
❌ ข้าม BC — ไม่ใช่ Domain Service
checkStockAvailability(order, stockLevel)
→ SalesOrder อยู่ใน Sales BC
→ StockLevel อยู่ใน Stock BC
ถ้าต้องการ logic ที่ต้องรู้ข้อมูลจาก BC อื่น — ให้ใช้ Application Service orchestrate ผ่าน ServiceClient แทน ซึ่งจะเห็นชัดขึ้นใน Prerequisite 3