ข้ามไปยังเนื้อหา

Tactical Design

เมื่อรู้ขอบเขตแล้ว เราลงมือสร้าง Domain ModelDomain ModelModel ของ Domain ที่ฝังกฎและกระบวนการทางธุรกิจไว้ในโค้ดจริง หัวใจของ DDD คือการพัฒนา domain model ที่ “เข้าใจธุรกิจอย่างลึกซึ้ง”Strategic Design ที่ “รวยพฤติกรรม” ด้วย building blocks ของ DDD Fowler จัดกลุ่มหลักไว้สามอย่าง (Evans Classification): EntityEntityอ็อบเจ็กต์ที่มี “ตัวตน” (identity) ต่อเนื่องตามเวลา แม้ค่าภายในเปลี่ยน ก็ยังเป็นสิ่งเดิม เช่น ลูกค้า, คำสั่งซื้อ — แยกแยะด้วย id ไม่ใช่ด้วยค่าTactical Design, Value ObjectValue Objectอ็อบเจ็กต์ที่สำคัญที่ “ค่า” ของมัน ไม่มีตัวตนเฉพาะ สองตัวที่ค่าเท่ากันถือว่าเท่ากัน ควรเป็น immutable (เปลี่ยนค่าไม่ได้) เช่น เงิน, วันที่, พิกัดTactical Design, และ service

EntityEntityอ็อบเจ็กต์ที่มี “ตัวตน” (identity) ต่อเนื่องตามเวลา แม้ค่าภายในเปลี่ยน ก็ยังเป็นสิ่งเดิม เช่น ลูกค้า, คำสั่งซื้อ — แยกแยะด้วย id ไม่ใช่ด้วยค่าTactical Design คืออ็อบเจ็กต์ที่มี IdentityIdentity“ตัวตน” ที่ทำให้ Entity เป็นสิ่งเดิมตลอดอายุ แม้ค่าเปลี่ยน เช่น เลขที่คำสั่งซื้อ หรือ id ที่ระบบสร้างให้Tactical Design ต่อเนื่องตามเวลา แม้ค่าภายในเปลี่ยน ก็ยังเป็น “สิ่งเดิม” เช่น ลูกค้าคนหนึ่งเปลี่ยนชื่อ–ที่อยู่ได้ แต่ก็ยังเป็นลูกค้าคนเดิม เราแยกแยะด้วย id ไม่ใช่ด้วยค่า และที่สำคัญ Entity ต้อง “มีพฤติกรรม” ไม่ใช่แค่เก็บข้อมูล

Value ObjectValue Objectอ็อบเจ็กต์ที่สำคัญที่ “ค่า” ของมัน ไม่มีตัวตนเฉพาะ สองตัวที่ค่าเท่ากันถือว่าเท่ากัน ควรเป็น immutable (เปลี่ยนค่าไม่ได้) เช่น เงิน, วันที่, พิกัดTactical Design คืออ็อบเจ็กต์ที่เราสนใจแค่ “ค่า” ของมัน ไม่มีตัวตนเฉพาะ สองตัวที่ค่าเท่ากันถือว่าเท่ากัน เช่น เงิน 100 บาท ก็คือ 100 บาท ไม่ว่าจะเป็นวัตถุไหน กฎสำคัญคือควรเป็น ImmutableImmutableเปลี่ยนแปลงไม่ได้หลังสร้าง ถ้าจะ “เปลี่ยนค่า” ต้องสร้างอ็อบเจ็กต์ใหม่แทน หลักการสำคัญของ Value Object เพื่อเลี่ยงบั๊กจากการใช้อ้างอิงร่วมกันTactical Design — ถ้าจะเปลี่ยนค่า ให้สร้างตัวใหม่แทน เพื่อเลี่ยงบั๊กจากการใช้อ้างอิงร่วมกัน

Value Object ที่ดีจะรวม validation ไว้ในตัว ทำให้ “ค่าที่ไม่ถูกต้อง” เป็นไปไม่ได้ตั้งแต่แรก
// Value Object: เทียบกันด้วยค่า, เปลี่ยนแปลงไม่ได้, validate ตอนสร้าง
class Money {
private constructor(
readonly amountSatang: number, // เก็บเป็นสตางค์ (เลขจำนวนเต็ม) เลี่ยงปัญหาทศนิยม
readonly currency: string,
) {}
static of(amountSatang: number, currency: string): Money {
if (!Number.isInteger(amountSatang)) throw new Error('ต้องเป็นจำนวนเต็ม');
if (amountSatang < 0) throw new Error('เงินติดลบไม่ได้');
return new Money(amountSatang, currency);
}
add(other: Money): Money {
if (other.currency !== this.currency) throw new Error('สกุลเงินไม่ตรงกัน');
return Money.of(this.amountSatang + other.amountSatang, this.currency); // คืนตัวใหม่
}
equals(other: Money): boolean { // เท่ากันเมื่อค่าทุกตัวเท่ากัน
return this.amountSatang === other.amountSatang && this.currency === other.currency;
}
}

AggregateAggregateกลุ่มของ Entity และ Value Object ที่ถูกมองเป็นหนึ่งหน่วยเดียวเพื่อรักษาความถูกต้องของข้อมูล มีขอบเขตชัดเจน และเป็นหน่วยของ transactionTactical Design คือกลุ่มของ EntityEntityอ็อบเจ็กต์ที่มี “ตัวตน” (identity) ต่อเนื่องตามเวลา แม้ค่าภายในเปลี่ยน ก็ยังเป็นสิ่งเดิม เช่น ลูกค้า, คำสั่งซื้อ — แยกแยะด้วย id ไม่ใช่ด้วยค่าTactical Design และ Value ObjectValue Objectอ็อบเจ็กต์ที่สำคัญที่ “ค่า” ของมัน ไม่มีตัวตนเฉพาะ สองตัวที่ค่าเท่ากันถือว่าเท่ากัน ควรเป็น immutable (เปลี่ยนค่าไม่ได้) เช่น เงิน, วันที่, พิกัดTactical Design ที่ถูกมองเป็น “หนึ่งหน่วยเดียว” เพื่อรักษาความถูกต้องของข้อมูล มี Aggregate RootAggregate RootEntity หนึ่งตัวที่เป็น “ประตูเดียว” เข้าสู่ Aggregate การอ้างอิงจากภายนอกทำได้กับ root เท่านั้น root จึงควบคุมกฎความถูกต้องของทั้งกลุ่มTactical Design เป็นประตูเดียวเข้าสู่กลุ่ม การอ้างอิงจากภายนอกทำได้กับ root เท่านั้น Fowler ย้ำกฎสำคัญ: “transaction ไม่ควรข้ามขอบเขตของ Aggregate”

Cargo Aggregate — ขอบเขตความถูกต้อง

aggregate
Cargo Aggregate✓ consistent ภายใน 1 transactionCargoAggregate RootRouteSpecificationValue ObjectItineraryValue ObjectDeliveryValue ObjectHandlingEvent — Aggregate แยกHandlingEventAggregate RootLOAD · UNLOAD · CLAIM …by idTracking ID
Aggregate RootValue Objectขอบเขต (boundary)
กฎข้อ 1 & 2: สิ่งที่อยู่ ภายใน ขอบเขต Cargo ต้องถูกต้องตาม invariant พร้อมกันภายใน หนึ่ง transaction — และควรออกแบบให้เล็กที่สุด
สี่กฎการออกแบบ Aggregate (Vaughn Vernon)

1. จำลอง [[Invariant]] ที่แท้จริงไว้ในขอบเขตความถูกต้อง — กฎที่ต้องตรงกันทันทีต้องอยู่ใน Aggregate เดียวกันและถูกต้องภายใน transaction เดียว

2. ออกแบบ Aggregate ให้เล็ก — จำกัดให้เหลือ root กับสิ่งจำเป็นน้อยที่สุด Aggregate ใหญ่จะ “ไม่มีวันเร็วหรือสเกลได้ดี”

3. อ้างอิง Aggregate อื่นด้วย [[Identity]] — เก็บแค่ id ของอีก Aggregate ไม่ใช่อ้างอิงตรง ป้องกันการแก้หลาย Aggregate ใน transaction เดียว และแมปเข้ากับขอบเขต microservice ได้ดี

4. ใช้ [[Eventual Consistency]] นอกขอบเขต — เมื่อกระบวนการธุรกิจกระทบหลาย Aggregate ให้ส่ง Domain EventDomain Eventสิ่งที่ “เกิดขึ้นแล้ว” ใน domain ซึ่งส่วนอื่นสนใจ แทนด้วยอ็อบเจ็กต์ที่เปลี่ยนแปลงไม่ได้ ตั้งชื่อเป็นอดีต เช่น CargoWasRouted ใช้สื่อสารข้าม AggregateTactical Design แล้วประมวลผลตามมาแบบ async

ภายนอกแก้ไขรายการสินค้าได้ผ่าน Order เท่านั้น — root จึงรับประกัน invariant ได้เสมอ
// Order = Aggregate Root; OrderLine อยู่ภายในขอบเขต
class Order { // <- Aggregate Root
private lines: OrderLine[] = [];
private constructor(readonly id: OrderId, private status: OrderStatus) {}
// พฤติกรรมอยู่บน root และคอย “บังคับ invariant”
addLine(product: ProductId, qty: number, price: Money): void {
if (this.status !== 'DRAFT') throw new Error('แก้คำสั่งซื้อที่ยืนยันแล้วไม่ได้');
this.lines.push(new OrderLine(product, qty, price));
}
total(): Money { // invariant: ยอดรวม = ผลรวมของรายการ
return this.lines.reduce((sum, l) => sum.add(l.subtotal()), Money.of(0, 'THB'));
}
confirm(): DomainEvent {
if (this.lines.length === 0) throw new Error('คำสั่งซื้อว่างเปล่ายืนยันไม่ได้');
this.status = 'CONFIRMED';
return new OrderConfirmed(this.id, this.total()); // ส่ง event ออกไป (กฎข้อ 4)
}
}
class OrderLine { // ภายใน Aggregate — ไม่มีใครอ้างจากนอกได้
constructor(readonly product: ProductId, readonly qty: number, readonly price: Money) {}
subtotal(): Money { return this.price.times(this.qty); }
}
🚢 ทำไม Handling Event จึงเป็น Aggregate แยก?

ในระบบขนส่ง Cargo เป็น Aggregate RootAggregate RootEntity หนึ่งตัวที่เป็น “ประตูเดียว” เข้าสู่ Aggregate การอ้างอิงจากภายนอกทำได้กับ root เท่านั้น root จึงควบคุมกฎความถูกต้องของทั้งกลุ่มTactical Design (มี Route SpecificationRoute SpecificationValue Object ที่ระบุ “ความต้องการของลูกค้า”: ต้นทาง ปลายทาง และกำหนดเวลาถึง — มีเมธอด isSatisfiedBy() ตรวจว่าแผนเดินทางตรงตามนี้หรือไม่Tactical Design, ItineraryItineraryValue Object ที่เป็น “แผนการเดินทางจริง” ของ Cargo ประกอบด้วยลำดับของ Leg (ช่วงการขนส่งแต่ละช่วง)Tactical Design, Delivery อยู่ภายใน) ส่วน Handling EventHandling Eventการบันทึกการจัดการสินค้าจริง (LOAD, UNLOAD, RECEIVE, CLAIM) เป็น Aggregate “แยกต่างหาก” จาก Cargo เพราะมีปริมาณมากและต้องประมวลผลแบบ asyncTactical Design — การบันทึกว่าสินค้าถูกยก/ลง/รับ ณ ท่าใด — เป็น Aggregate แยกต่างหาก

เหตุผลคือ “ประสิทธิภาพ”: Handling Event หลั่งไหลเข้ามาปริมาณมหาศาลจากระบบท่าเรือ/คลังภายนอก และต้องประมวลผลเร็วแบบ async จึงไม่ควรโหลดมาพร้อมโครงสร้าง Cargo ที่ใหญ่ — ตรงกับกฎ “ออกแบบให้เล็ก” และ “อ้างอิงด้วย id” พอดี


RepositoryRepositoryตัวที่ทำให้เราเข้าถึง Aggregate ราวกับเป็นคอลเลกชันในหน่วยความจำ ซ่อนรายละเอียดฐานข้อมูล มีหนึ่ง repository ต่อหนึ่ง Aggregate RootTactical Design ทำให้เราเข้าถึง AggregateAggregateกลุ่มของ Entity และ Value Object ที่ถูกมองเป็นหนึ่งหน่วยเดียวเพื่อรักษาความถูกต้องของข้อมูล มีขอบเขตชัดเจน และเป็นหน่วยของ transactionTactical Design ราวกับเป็นคอลเลกชันในหน่วยความจำ โดยซ่อนรายละเอียดฐานข้อมูล หลักการคือ หนึ่ง repository ต่อหนึ่ง [[Aggregate Root]] และอินเทอร์เฟซควรนิยามไว้ในชั้น domain ส่วน implementation อยู่ชั้น infrastructure เพื่อไม่ให้ ORM มา “ปนเปื้อน” โมเดล (เราจะเห็นเหตุผลชัดขึ้นในเรื่องสถาปัตยกรรม)

domain ขึ้นกับ “อินเทอร์เฟซ” ที่ตัวเองนิยาม ไม่ขึ้นกับฐานข้อมูล — นี่คือ [[Port]] ในสถาปัตยกรรม Hexagonal
// อินเทอร์เฟซอยู่ในชั้น domain — domain บอกแค่ว่า “ต้องการอะไร”
interface OrderRepository {
findById(id: OrderId): Promise<Order | null>;
save(order: Order): Promise<void>;
nextId(): OrderId;
}
// implementation จริง (เช่น Drizzle/Postgres) อยู่ชั้น infrastructure แยกต่างหาก

เมื่อตรรกะธุรกิจสำคัญ “ไม่เข้า” กับ Entity หรือ Value Object ตัวใดตัวหนึ่งโดยธรรมชาติ ให้ทำเป็น Domain ServiceDomain Serviceบริการที่ถือ “ตรรกะธุรกิจ” ซึ่งไม่เข้ากับ Entity หรือ Value Object ตัวใดตัวหนึ่งโดยธรรมชาติ เช่น การโอนเงินที่เกี่ยวข้องกับสองบัญชี — ไร้สถานะ (stateless)Tactical Design เช่น การหาเส้นทาง หรือการโอนเงินที่กระทบสองบัญชี ส่วน Application ServiceApplication Serviceบริการที่ “ประสานงาน” use case — จัดการ transaction, ความปลอดภัย, การบันทึก, แปลง DTO — แต่ไม่มีกฎธุรกิจอยู่ในตัวมันเองTactical Design ทำหน้าที่ “ประสานงาน” use case — เปิด transaction, จัดการสิทธิ์, เรียก repository — แต่ ไม่มีกฎธุรกิจในตัวเอง

เส้นแบ่งที่จำง่าย

Domain ServiceDomain Serviceบริการที่ถือ “ตรรกะธุรกิจ” ซึ่งไม่เข้ากับ Entity หรือ Value Object ตัวใดตัวหนึ่งโดยธรรมชาติ เช่น การโอนเงินที่เกี่ยวข้องกับสองบัญชี — ไร้สถานะ (stateless)Tactical Design = ถือ “การตัดสินใจทางธุรกิจ” · Application ServiceApplication Serviceบริการที่ “ประสานงาน” use case — จัดการ transaction, ความปลอดภัย, การบันทึก, แปลง DTO — แต่ไม่มีกฎธุรกิจอยู่ในตัวมันเองTactical Design = ประสานงานการตัดสินใจเหล่านั้น (เหมือนที่มันประสานงานการเรียกเมธอดบน Entity) ถ้า Application Service เริ่มมีกฎธุรกิจ นั่นคือสัญญาณว่ากำลังจะกลายเป็น Anemic Domain ModelAnemic Domain Modelanti-pattern ที่อ็อบเจ็กต์มีแต่ getter/setter ไร้พฤติกรรม ตรรกะถูกดึงไปไว้ใน service ทั้งหมด — จ่ายค่า domain model แต่ไม่ได้ประโยชน์ของมันเลยTactical Design

Domain EventDomain Eventสิ่งที่ “เกิดขึ้นแล้ว” ใน domain ซึ่งส่วนอื่นสนใจ แทนด้วยอ็อบเจ็กต์ที่เปลี่ยนแปลงไม่ได้ ตั้งชื่อเป็นอดีต เช่น CargoWasRouted ใช้สื่อสารข้าม AggregateTactical Design แทน “สิ่งที่เกิดขึ้นแล้ว” ใน domain ที่ส่วนอื่นสนใจ ตั้งชื่อเป็นอดีตเสมอ (เช่น CargoWasRouted, OrderConfirmed) เป็น ImmutableImmutableเปลี่ยนแปลงไม่ได้หลังสร้าง ถ้าจะ “เปลี่ยนค่า” ต้องสร้างอ็อบเจ็กต์ใหม่แทน หลักการสำคัญของ Value Object เพื่อเลี่ยงบั๊กจากการใช้อ้างอิงร่วมกันTactical Design และมักพก timestamp กับ id ของสิ่งที่เกี่ยวข้อง ใช้เพื่อทำ side-effect ข้าม Aggregate และเปิดทาง Eventual ConsistencyEventual Consistencyความถูกต้องที่ “ตามมาทีหลัง” ไม่ใช่ทันที ใช้กับการอัปเดตข้าม Aggregate ผ่าน Domain Event แทนการบังคับให้ตรงกันในทันทีภายใน transaction เดียวTactical Design ส่วน FactoryFactoryตัวที่ห่อหุ้มตรรกะการ “สร้าง” Aggregate ที่ซับซ้อน และรับประกันว่าอ็อบเจ็กต์ใหม่ถูกต้องตาม invariant ตั้งแต่แรกเกิด — “สร้างของใหม่” (repository หา “ของเก่า”)Tactical Design ห่อหุ้มการ “สร้าง” Aggregate ที่ซับซ้อนให้ถูกต้องตาม invariant ตั้งแต่แรก — Evans เปรียบว่า “factory สร้างของใหม่ ส่วน repository หาของเก่า”


Anemic Domain ModelAnemic Domain Modelanti-pattern ที่อ็อบเจ็กต์มีแต่ getter/setter ไร้พฤติกรรม ตรรกะถูกดึงไปไว้ใน service ทั้งหมด — จ่ายค่า domain model แต่ไม่ได้ประโยชน์ของมันเลยTactical Design คือกับดักที่พบบ่อยที่สุด: อ็อบเจ็กต์ดูเหมือน domain model มีชื่อตรงกับคำในธุรกิจ แต่ “แทบไม่มีพฤติกรรม” เป็นเพียง “ถุงใส่ getter/setter” ส่วนตรรกะทั้งหมดถูกผลักไปไว้ในชั้น service Fowler เรียกว่าขัดกับแก่นของ OOP โดยสิ้นเชิง เพราะ OOP คือการรวมข้อมูลกับพฤติกรรมเข้าด้วยกัน

ปัญหาของ anemic domain model คือมันแบกต้นทุนทั้งหมดของ domain model โดยไม่ได้ประโยชน์ของมันเลย

— ใจความจาก Martin Fowler

แต่ — ไม่ใช่ทุกที่ต้องรวย

Microsoft ตั้งข้อสังเกตว่า ถ้า Bounded Context นั้นง่ายมาก (เป็น CRUD ล้วน เช่น แคตตาล็อกสินค้า) anemic model ก็ “ดีพอ” ได้ ไม่ต้องฝืนใช้ DDD เต็มรูปแบบทุกที่ — ขึ้นกับว่ากำลังสร้างอะไร (เรื่องนี้เชื่อมกับ Stage 5)


[quiz: เช็กความเข้าใจ — Stage 3 — coming soon]