DDD Tactical Design — ออกแบบ Business Solution ที่ Dev แปลงเป็น Code ได้ทันที

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: Fowler vs 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 มีกฎอะไรและทำงานยังไง

Domain Model Overview


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 / JavaScriptFunctionalNative support ดี type system รองรับ immutability และ pure function ได้โดยตรง
Java / C# / KotlinOOPภาษาออกแบบมาเพื่อ OOP class-based pattern เข้าใจและ maintain ง่ายกว่าในทีม
Pythonได้ทั้งคู่Python รองรับทั้งสอง style เลือกตามที่ทีมถนัดและ codebase เดิมใช้อยู่
GoFunctionalGo ไม่มี 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 ModelSalesOrder ทั้งหมด — ทั้งโครงสร้างและกฎ
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 ซึ่งอาจทำให้ระบบอื่นพัง

Business Rule vs Invariant

Analogy ให้เห็นความต่าง:

Business Rule = กฎจราจร "ห้ามจอดในเส้นสีเหลือง"
  → รัฐบาลเปลี่ยนกฎได้ถ้า policy เปลี่ยน

Invariant     = กฎฟิสิกส์ "รถไม่สามารถอยู่สองที่พร้อมกัน"
  → ไม่มีทางเปลี่ยนได้ เพราะละเมิดแล้วระบบพัง

Invariant ควร enforce ที่ไหน

Invariant ถูก enforce ได้หลายระดับ แต่ลำดับความสำคัญชัดเจน

ระดับ 1 — Domain (enforce ที่นี่ก่อนเสมอ):

  • OOP Style → constructor throw 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 ของ DomainTechnical Constraint
เกิดจากกฎของ Businessการตัดสินใจทางเทคนิคของ Dev
ละเมิดแล้วBusiness เสียหายจริงระบบ error หรือต้อง refactor
เปลี่ยนได้ไหมไม่ได้ได้ แต่ต้อง refactor
ใครกำหนดDomain ExpertDev

สำคัญ: 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 StyleFunctional Style
EntityMutableImmutable
Value ObjectImmutableImmutable

“Mutable” ใน OOP ไม่ได้แปลว่าแก้ค่าตรงๆ จากข้างนอกได้ แต่หมายถึง state เปลี่ยนได้ผ่าน method ของ instance เท่านั้น — Invariant ยังถูก enforce อยู่เสมอ

order.status = "confirmed"  // ❌ ข้าม method — Invariant ไม่ถูก enforce
order.confirm()             // ✅ ผ่าน method — Invariant ถูก enforce

Series 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 BoundarySalesOrder และ 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 Structure — SalesOrder

แต่ละ 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

Repository Abstraction

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

Domain Service BC Boundary

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 ServiceApplication 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 ConceptOOP StyleFunctional Style (Series ใช้เป็นหลัก)
Entityclass SalesOrder { methods }type SalesOrder + pure functions แยกไฟล์
Value Objectimmutable class + constructor throwreadonly type + smart constructor + Result
Aggregateclass เป็น root, enforce ผ่าน methodsไม่มี class, enforce ผ่าน module functions
Invariantthrow ใน constructor/setterreturn Result<T, Error> — ไม่ throw
Repositoryinterface (เหมือนกัน)interface (เหมือนกัน)
Domain Servicestateless class ที่มี methodpure function ตรงๆ ไม่ต้อง wrap class
Application Serviceclass ที่ inject repo ผ่าน constructorfunction ที่รับ deps เป็น parameter
Domain Eventclass หรือ interfacetype + 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

Supawut Thomas

Supawut Thomas

Software Developer

มีประสบการณ์พัฒนา Software ระดับ Enterprise มากกว่า 10 ปี ผ่านงานจริงหลากหลายโปรเจกต์องค์กร — เชื่อว่าความรู้ที่ดีที่สุดคือความรู้ที่มาจากประสบการณ์จริง และอยากแบ่งปันสิ่งเหล่านั้นให้เพื่อน Developer ทุกคนได้นำไปพัฒนาตัวเองได้ดีขึ้นในทุกๆ วัน