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

Value Objects

ถ้า EntityEntityอ็อบเจ็กต์ที่​มี “ตัวตน” (identity) ต่อ​เนื่อง​ตาม​เวลา แม้​ค่า​ภายใน​เปลี่ยน ก็​ยัง​เป็น​สิ่ง​เดิม เช่น ลูกค้า, คำ​สั่ง​ซื้อ — แยกแยะ​ด้วย id ไม่ใช่​ด้วย​ค่าTactical Design เปรียบ​เหมือน “ตัว​ละคร” ใน​หนัง สิ่ง​ที่​ทำให้​ตัว​ละคร​น่า​สนใจ​มัก​เป็น คุณลักษณะ ของ​มัน — James Bond มี​เสน่ห์​สูง, BankAccount น่า​สนใจ​เพราะ​มี Balance ก้อน​โต การ​จำลอง​คุณลักษณะ​เชิง​พรรณนา​เหล่า​นี้​คือ​หน้าที่​ของ building block ตัว​หนึ่ง​ของ DDD ที่​ชื่อ​ว่า Value ObjectValue Objectอ็อบเจ็กต์ที่​สำคัญ​ที่ “ค่า” ของ​มัน ไม่มี​ตัวตน​เฉพาะ สอง​ตัว​ที่​ค่า​เท่า​กัน​ถือว่า​เท่า​กัน ควร​เป็น immutable (เปลี่ยน​ค่า​ไม่​ได้) เช่น เงิน, วัน​ที่, พิกัดTactical Design

Value ObjectValue Objectอ็อบเจ็กต์ที่​สำคัญ​ที่ “ค่า” ของ​มัน ไม่มี​ตัวตน​เฉพาะ สอง​ตัว​ที่​ค่า​เท่า​กัน​ถือว่า​เท่า​กัน ควร​เป็น immutable (เปลี่ยน​ค่า​ไม่​ได้) เช่น เงิน, วัน​ที่, พิกัดTactical Design คือ​อ็อบเจ็กต์ที่ ไม่มี identity มัน​มี​ไว้​เพื่อ​อธิบาย​คุณลักษณะ​ของ entity ที่​เกี่ยวข้อง​กับ domain ซึ่ง​มัก​อยู่​ใน​รูป​ของ “ปริมาณ” อย่าง​ใด​อย่าง​หนึ่ง การ​ไม่มี identity ให้​ต้อง​คอย​จัดการ ทำให้การ​ทำงาน​กับ value object เบา​และ​ตรง​ไป​ตรง​มา — โดย​เฉพาะ​คุณสมบัติ​สอง​ข้อ​คือ เปลี่ยน​ค่า​ไม่​ได้ (immutableImmutableเปลี่ยนแปลง​ไม่​ได้​หลัง​สร้าง ถ้า​จะ “เปลี่ยน​ค่า” ต้อง​สร้าง​อ็อบเจ็กต์ใหม่​แทน หลักการ​สำคัญ​ของ Value Object เพื่อ​เลี่ยง bug จาก​การ​ใช้​อ้างอิง​ร่วม​กันTactical Design) และ นำ​มา​รวม​กัน​ได้ (combinable) บท​นี้​จะ​พา​คุณ​รู้จัก​คุณสมบัติ​เหล่า​นี้ พร้อม modeling pattern ที่​ใช้​บ่อย และ​แนวทางการ persist ลง​ฐาน​ข้อมูล


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

ลอง​มอง​อย่าง​ตั้งใจ คุณ​จะ​เห็น​ว่า​แนวคิด​อย่าง Money หรือ Temperature นั้น “ไม่​สม​เหตุ​สม​ผล​ที่​จะ​มี identity” — มี​แต่ entity เท่านั้น​ที่​ควร​มี identity ตัวอย่าง​คลาสสิก​คือ​บัญชี​ธนาคาร: คุณ​ค้นหา BankAccount ด้วย ID แน่นอน แต่​คุณ​จะ​ค้นหา “ยอด​เงิน (balance)” ของ​มัน​ด้วย ID ไหม? ใน​เกือบ​ทุก domain คำ​ตอบ​คือ​ไม่ เพราะ​ยอด​เงิน​ไม่มี​ความหมาย​เมื่อ​อยู่​โดดเดี่ยว

สัญญาณ​ว่า​ควร​เป็น Value Object

เมื่อ​แนวคิด​หนึ่ง​ดู ไม่มี identity ที่​ชัดเจน นั่น​คือ​เบาะแส​สำคัญ​ว่า​มัน​ควร​เป็น value object ใน model ของ​คุณ ตาม​ปกติ​เรา​จะ ค้น​พบ entity ก่อน แล้ว​จึง​ค่อย​มอง​ออกว่า​มี value object ใด​บ้าง​ที่​เกี่ยวข้อง​กับ​มัน — value object ต้อง​อาศัย context ของ entity จึง​จะ​มี​ความหมาย

DDD คือ​เรื่อง​ของ​การ สื่อ​กฎ​ธุรกิจ​และ​ตรรกะ domain อย่าง​ชัด​แจ้ง ใน​ทาง​กลับ​กัน primitive type อย่าง int หรือ string เดี่ยวๆ ทำ​สิ่ง​นี้​ได้​แย่​มาก ลอง​ดูราคา​ประมูล​ที่​แทน​ด้วย int:

WinningBid.cs
public class WinningBid
{
public int Price { get; private set; } // ราคาคืออะไรกันแน่? หน่วยอะไร? ช่วงค่าที่ยอมรับเท่าไร?
}

การ​ใช้ int แทน​ราคา​เป็นการ​ออกแบบ​ที่​อ่อน เพราะ​มัน​ไม่​บอกว่า “ราคา” ใน domain นี้​คือ​อะไร ไม่​จำกัด​ช่วง​ค่าที่​อนุญาต ไม่​บอก​หน่วย​หรือ​สกุล​เงิน — กลาย​เป็น​แหล่ง​ความ​กำกวม​ขนาด​ใหญ่​ที่​ซ่อน​รายละเอียด​สำคัญ​ของ domain เอา​ไว้ ยิ่ง​กว่า​นั้น คุณ เพิ่ม​พฤติกรรม​ให้ primitive ไม่​ได้ ตรรกะ​ที่​เกี่ยวข้อง​กับ​ราคา​จึง​กระจัดกระจาย​ไป​ทั่ว แทนที่​จะ​อยู่​รวม​กัน​เป็น​ที่​เป็น​ทาง

การ​ห่อ primitive ด้วย value object ช่วย​ให้​ระบบ type ทำงาน​แทน​เรา และ​ดึง​พฤติกรรม​ที่​เกี่ยวข้อง​มา​รวม​กัน​อย่าง cohesive:

Price.cs
public class Price
{
public Price(Money amount)
{
if (amount == null)
throw new ArgumentNullException("Amount cannot be null");
Amount = amount;
}
public Money Amount { get; private set; }
// กฎการเพิ่มราคาประมูล (bid increment) ขึ้นกับช่วงราคาปัจจุบัน
public Money BidIncrement() { /* ... */ }
// ราคาใหม่ที่เสนอมา “เกิน” ราคาปัจจุบันพอจะแซงได้ไหม
public bool CanBeExceededBy(Money offer)
{
return offer.IsGreaterThanOrEqualTo(BidIncrement());
}
}

ทุก​ครั้ง​ที่​มี​การ​พูด​ถึง “ราคา” นัก​พัฒนา​และ domain expert ต้อง​ดู​แค่ class เดียว​ก็​เข้าใจ​ว่า​ราคา​คือ​อะไร​และ​มี​กฎ​อะไร​บังคับ​อยู่

🛒 Money ใน​ตะกร้า​สินค้า

ใน​ระบบ e-commerce ราคา​สินค้า ยอด​รวม​ตะกร้า ค่า​จัด​ส่ง และ​ส่วนลด ล้วน​เป็น Money — เรา​ไม่​เก็บ​เป็น decimal ลอยๆ เพราะ​นั่น​เปิด​ช่อง​ให้ bug เช่น “บวก​เงิน​บาท​กับ​เงิน​ดอลลาร์” การ​ห่อ​ด้วย Money ที่​รู้จัก​สกุล​เงิน​ของ​ตัวเอง ทำให้​กฎ “บวก​ได้​เฉพาะ​สกุล​เดียวกัน” ถูก​บังคับ​ใน​ที่​เดียว

ดัน behavior ลง Value Object เพื่อ​ให้ Entity โล่ง

ใน​บท​ถัด​ไป (Entities) เรา​จะ​เห็น​ว่าการ​ดัน​พฤติกรรม​ลง​ใน value object ช่วย​ให้ entity โฟกัส — ถ้า​ไม่​ทำ พฤติกรรม​เรื่อง​ราคา​ทั้งหมด​คง​ถูก​ยัด​เข้าไป​ใน WinningBid จน​มัน​บวม​และ​รับผิดชอบ​หลาย​อย่าง​เกิน​ไป


สิ่ง​ที่​ทำให้ value object ทำงาน​ด้วย​ง่าย คือ​มัน​ค่อน​ข้าง self-contained เช่น​เดียว​กับ​ผู้​ที่​นิยม functional programming ชาว DDD ก็​ชอบ value object เพราะ​มัน immutable, ไม่มี side effect และ​ทดสอบ​ง่าย มา​ดู​คุณสมบัติ​เหล่า​นี้​ที​ละ​ข้อ

รายละเอียด​สำคัญ​ที่สุด​ที่​ต้อง​จำ​คือ value object ไม่มี identityIdentity“ตัวตน” ที่​ทำให้ Entity เป็น​สิ่ง​เดิม​ตลอด​อายุ แม้​ค่า​เปลี่ยน เช่น เลข​ที่​คำ​สั่ง​ซื้อ หรือ id ที่​ระบบ​สร้าง​ให้Tactical Design มัน​สำคัญ​เพราะ​มัน​บอก​บาง​อย่าง​เกี่ยว​กับ​อ็อบเจ็กต์อื่น: Person สูง​เท่าไร? InsurancePolicy ไม่มี​เคลม​มา​กี่​ปี​แล้ว? Fruit หนัก​เท่าไร? — ลอง​คิด​ดู​ว่าการ​ให้​ค่า​เหล่า​นี้​มี identity นั้น​ไม่​สม​เหตุ​สม​ผล​เลย

ID ใน​ฐาน​ข้อมูล ≠ identity ใน domain

บาง strategy การ persist อาจ​ใส่ ID ให้ value object ใน​ฐาน​ข้อมูล แต่​นั่น​เป็น รายละเอียด​เชิง​เทคนิค​ของ​การ​จัด​เก็บ ล้วนๆ ไม่​ได้​แปล​ว่า​มัน​มี identity ใน domain อย่า​สับสน​สอง​เรื่อง​นี้

Entity ถือว่า​เท่า​กัน​เมื่อ​มี ID เดียวกัน ใน​ทาง​ตรง​ข้าม value object ถือว่า​เท่า​กัน​เมื่อ​มี​ค่า​เดียวกัน ไม่​ว่า​ตัวแปร​สอง​ตัว​จะ​ชี้​ไป​ยัง​อ็อบเจ็กต์เดียวกัน​หรือ​คนละ​ตัว​ก็ตาม ถ้า Temperature สอง​ตัวแทน 30 องศา​เซลเซียส ก็​ถือว่า​เท่า​กัน

Meters.cs
public override bool Equals(object obj)
{
var m = obj as Meters;
if (m == null) return false;
// เทียบ “ค่า” ไม่ใช่ reference — ปัดเศษสองตำแหน่งตามกฎ domain
return ToTwoDecimalPlaces(m.DistanceInMeters)
== ToTwoDecimalPlaces(DistanceInMeters);
}

โดย​ปริยาย C# (และ Java) ถือว่า​สอง​อ็อบเจ็กต์เท่า​กัน​เมื่อ reference ชี้​ที่​เดียวกัน แต่​กับ value object เรา​ไม่​สนใจ reference — เรา​สนใจ​ว่า​มัน​แทน ค่า​เดียวกัน​ใน domain หรือ​ไม่ การ override Equals() ซ้ำ​ใน​ทุก class นั้น​น่า​เบื่อ​และ​เสี่ยง​ผิดพลาด จึง​นิยม​ทำ base class มา​เก็บ boilerplate ของ equality, GetHashCode(), และ operator == / != ไว้​ที่​เดียว แล้ว​ให้ class ลูก​บอก​แค่​ว่า “เอา attribute ไหน​มา​เทียบ”:

ValueObject<T>.cs (ย่อ)
public abstract class ValueObject<T> where T : ValueObject<T>
{
// class ลูกบอกว่าจะนำ field ใดมาร่วมตรวจความเท่ากัน
protected abstract IEnumerable<object> GetAttributesToIncludeInEqualityCheck();
public bool Equals(T other) =>
other != null && GetAttributesToIncludeInEqualityCheck()
.SequenceEqual(other.GetAttributesToIncludeInEqualityCheck());
// == , != , GetHashCode() ทำให้ครบในที่นี้ครั้งเดียว
}
บาง​ภาษา​มี​ของ​ให้​ฟรี

บาง​ภาษา​รองรับ class แบบ value object มา​ใน​ตัว เช่น case class ของ Scala มี attribute-based equality ใน​ตัว, record ใน C#/Java ก็​ทำนอง​เดียวกัน — คุ้ม​ค่าที่​จะ​สำรวจ​ว่า​ภาษา​ที่​คุณ​ใช้​มี​อะไร​ให้​บ้าง

value object ควร เปิดเผย​พฤติกรรม​ที่​สื่อ​ภาษา domain และ​ห่อ​หุ้ม​สถานะ​ไว้ เท่า​ที่​ทำได้ ดู class Meters ที่​เปิด method ToYards(), ToKilometers(), Add(), IsLongerThan() ขณะ​เดียวกัน​ก็​ซ่อน field DistanceInMeters ไว้ กฎ​ทั่วไป​คือ primitive ทุก​ตัว​ควร​เป็น private/protected โดย​ปริยาย จะ​เปิด public ก็​ต่อ​เมื่อ​มี​เหตุผล​ที่​ดีจริงๆ แต่​ก่อน​อื่น​ให้​ลอง​เพิ่ม method ที่​ให้ function ที่​ต้องการ​แทน — การ​โฟกัส​ที่​พฤติกรรม​เพื่อ​สร้าง domain model ที่​อุดม​ด้วย​พฤติกรรมRich Domain Modelmodel domain ที่ “รวย​พฤติกรรม” — object ที่​เก็บ​ทั้ง​ข้อมูล​และ business logic ไว้​ด้วย​กัน เป็น​คู่​ตรง​ข้าม​ของ Anemic Domain Model (ที่​มี​แต่ getter/setter แล้ว​ผลัก logic ไป​ไว้​ที่​อื่น) tactical patterns ของ DDD จะ​คุ้ม​ค่า​ก็​ต่อ​เมื่อ model “รวย” พอ​เช่น​นี้Tactical Design คือ​หัวใจ​ของ DDD

ใน​ฐานะ​แนวคิด​เชิง​พรรณนา​ที่​มัก​อธิบาย “ปริมาณ” value object มัก​ห่อ ค่าที่​วัด กับ หน่วย​ที่​วัด ไว้​ด้วย​กัน​อย่าง cohesive เช่น Height ห่อ​ขนาด​กับ​หน่วย, Money ห่อ​จำนวน​เงิน​กับ​สกุล​เงิน แต่​ก็​ไม่​จำเป็น​ต้อง​เป็น​คู่​ค่า-หน่วย​เสมอ​ไป — Color อาจ​มี Red, Green, Blue รวม​กัน​เป็น​แนวคิด​เดียว

เมื่อ​สร้าง​แล้ว value object เปลี่ยน​ค่า​ไม่​ได้​อีก หาก​ต้องการ “เปลี่ยน​ค่า” ให้​สร้าง instance ใหม่​ที่​มี​ค่า​ตาม​ต้องการ​แทน เพราะ immutabilityImmutableเปลี่ยนแปลง​ไม่​ได้​หลัง​สร้าง ถ้า​จะ “เปลี่ยน​ค่า” ต้อง​สร้าง​อ็อบเจ็กต์ใหม่​แทน หลักการ​สำคัญ​ของ Value Object เพื่อ​เลี่ยง bug จาก​การ​ใช้​อ้างอิง​ร่วม​กันTactical Design ทำให้​ใช้​เหตุผล​กับ code ได้​ง่าย​และ​มี side effect อันตราย​น้อย​ลง ตัวอย่าง​คลาสสิก​คือ DateTime ของ .NET — AddMonths() และ AddYears() ดูเหมือน​จะ​แก้​ค่า แต่จริงๆ คืน​อ็อบเจ็กต์ใหม่ ขณะ​ที่​ตัว​เดิม​ไม่​เปลี่ยน

Money.cs (immutable)
public class Money : ValueObject<Money>
{
protected readonly decimal Value;
public Money(decimal value) { Value = value; }
// ดูเหมือนแก้ค่า แต่ที่จริงคืน instance ใหม่ ตัวเดิมไม่ถูกแตะ
public Money Add(Money money) => new Money(Value + money.Value);
public Money Subtract(Money money) => new Money(Value - money.Value);
}

ค่า​มัก​ถูก​แทน​ด้วย​ตัวเลข จึง​นำ​มา รวม​กัน​เป็น​ค่า​ใหม่ ได้​บ่อย ดัง​ที่​เห็น Money + Money ได้​จำนวน​ใหม่ ความ​สามารถ​นี้​เป็น​ลักษณะ​เด่น​ของ value object — เวลา domain expert พูด​ถึง “การ​เอา​สอง​สิ่ง​มา​รวม​กัน” นั่น​เป็น​สัญญาณ​ชัด​ว่า​คุณ​อาจ​ต้อง​จำลอง​มัน​เป็น value object หลาย​ภาษา​เปิด​ให้​คุณ override operator + - * ได้ ทำให้ code สื่อ​ความ​ชัดเจน​ยิ่ง​ขึ้น (แต่​ผลลัพธ์​ยัง​เป็น instance ใหม่​เสมอ ตาม​กฎ immutable)

🚢 Value Object ใน​ระบบ​ขนส่ง​สินค้า

ใน​ระบบ Cargo Shipping มี value object อยู่​หลาย​ตัว: Route SpecificationRoute SpecificationValue Object ที่​ระบุ “ความ​ต้องการ​ของ​ลูกค้า”: ต้นทาง ปลายทาง และ​กำหนด​เวลา​ถึง — มี method isSatisfiedBy() ตรวจ​ว่า​แผน​เดินทาง​ตรง​ตาม​นี้​หรือ​ไม่Tactical Design (ต้นทาง/ปลายทาง/กำหนด​ถึง) และ ItineraryItineraryValue Object ที่​เป็น “แผนการ​เดินทาง​จริง” ของ Cargo ประกอบ​ด้วย​ลำดับ​ของ Leg (ช่วง​การ​ขนส่ง​แต่ละ​ช่วง)Tactical Design (แผน​เดินทาง​จริง​ที่​ประกอบ​ด้วย​ลำดับ​ของ Leg) ทั้ง​คู่ ไม่มี identity — เรา​ไม่​ค้นหา “แผน​เดินทาง” ด้วย ID แต่​สนใจ​ว่า​แผน​สอง​อัน​มี​ค่า​เหมือน​กัน​ไหม และ​มัน​เป็น immutable: เมื่อ re-route เรา​สร้าง Itinerary ใหม่ ไม่ใช่​ไป​แก้​ของ​เดิม

value object ต้อง​ไม่​อยู่​ใน​สถานะ​ที่​ไม่​ถูกต้อง​เด็ดขาด และ​มัน​รับผิดชอบ​รับประกัน​สิ่ง​นี้​เอง ใน​ทาง​ปฏิบัติ​คือ constructor ต้อง​โยน exception ถ้า argument ผิด​กฎ domain เช่น Money ใน​ระบบ e-commerce อาจ​มี​กฎ​ว่า “ทศนิยม​ไม่​เกิน​สอง​ตำแหน่ง” และ “ต้อง​ไม่​ติดลบ”:

Money.cs (self-validating)
public Money(decimal value)
{
// ตรวจตั้งแต่แรกเกิด — ผิดกฎเมื่อไร abort ทันที
if (value % 0.01m != 0) throw new MoreThanTwoDecimalPlacesInMoneyValueException();
if (value < 0) throw new MoneyCannotBeANegativeValueException();
Value = value;
}

อีก​แนวทาง​คือ​ตรวจ​ใน factory methodFactoryตัว​ที่​ห่อ​หุ้ม​ตรรกะ​การ “สร้าง” Aggregate ที่​ซับซ้อน และ​รับประกัน​ว่าอ็อบเจ็กต์ใหม่​ถูกต้อง​ตาม invariant ตั้งแต่​แรก​เกิด — “สร้าง​ของ​ใหม่” (repository หา “ของ​เก่า”)Tactical Design แบบ static Money Create(decimal amount) ซึ่ง​เหมาะ​เมื่อ value object สร้าง​ได้​ใน​หลาย​สถานะ​ตาม context (เช่น​บาง context ยอม​ให้​เงิน​ติดลบ​ได้) แต่​ข้อ​ควร​ระวัง​คือ factory method ทำให้ เลี่ยง​การ validate ได้ ด้วย​การ​เรียก new ตรงๆ — ถ้า​กฎ validate ต่าง​กัน​ตาม context จริงๆ ควร​พิจารณา micro types (ด้าน​ล่าง)

immutability + cohesion + combinability ทำให้ value object ทดสอบ​ง่าย​ใน​ภาษา​ที่​สื่อ domain ตรงๆ — immutability ตัด​ความ​จำเป็น​ต้อง​ใช้ mock หรือ​ตรวจ side effect, cohesion ให้​ทดสอบ​แนวคิด​เดียว​แบบ isolation ได้, combinability ให้​แสดง​ความ​สัมพันธ์​ระหว่าง​ค่า​ได้

Referential transparency

ใน​โลก functional programming คำ​หรู​ที่​ใช้​เรียก method ที่​ไร้ side effect คือ referential transparency — value object โดย​เนื้อแท้​แล้ว​เป็น​แนวคิด​เชิง functional (หรือ​อย่าง​น้อย​ก็​ใกล้​เคียง​มาก) การ​ศึกษา F#, Scala หรือ Haskell คุ้ม​ค่า​ต่อ​การ​เข้าใจ​แนวคิด​นี้​ลึก​ขึ้น


ตลอด​หลาย​ปี ชาว DDD รวบรวม pattern เล็กๆ ที่​ช่วย​ให้​ทำงาน​กับ value object ได้​ดี​ขึ้น ส่วน​ใหญ่​เพื่อ​เพิ่ม​ความ​ชัดเจน​และ​สื่อ​ความ มา​ดู​สาม​ตัว​ที่​ใช้ได้​ทันที

ใช้ static factory method ห่อ​ความ​ซับซ้อน​ของ​การ​สร้าง​อ็อบเจ็กต์ไว้​หลัง interface ที่​อ่าน​ง่าย​กว่า ตัวอย่าง​เยี่ยม​คือ TimeSpan ของ .NET ที่​มี FromDays(), FromHours(), FromMilliseconds() ซึ่ง​สื่อ​ความ​และ​กำกวม​น้อย​กว่า constructor ที่​รับ int ห้า​ตัว

Height.cs
public static Height FromFeet(int feet) => new Height(feet, MeasurementUnit.Feet);
public static Height FromMetres(int metres) => new Height(metres, MeasurementUnit.Metres);

นอกจาก​อ่าน​ง่าย​แล้ว ยัง​ดูแล​รักษา​ง่าย​ขึ้น เพราะ​ผู้​เรียก​ไม่​ต้อง​ผูก​ตัวเอง​กับ enum MeasurementUnit อีก​ต่อ​ไป

pattern นี้​ดัน​หลัก “เลี่ยง primitive” ไป​อีก​ขั้น โดย​ห่อ type ที่​สื่อ​ความ​อยู่​แล้ว ด้วย type ที่​สื่อ​ความ​ยิ่ง​กว่า เพื่อ​เพิ่ม​ความ​ชัดเจน​เชิง context ดู domain service คำนวณ OT:

OvertimeCalculator.cs
public class OvertimeCalculator
{
// ระบบ type บังคับให้ส่ง “ชั่วโมงที่ทำ” กับ “ชั่วโมงตามสัญญา” ถูกตำแหน่ง
public OvertimeHours Calculate(HoursWorked worked, ContractedHours contracted)
{
var overtime = worked.Hours - contracted.Hours;
return new OvertimeHours(overtime);
}
}

HoursWorked, ContractedHours, OvertimeHours ล้วน​เป็น micro type ที่​ห่อ value object Hours อีก​ที — มัน​ไม่​เพิ่ม​พฤติกรรม​หรือ​สถานะ​ใหม่​เลย แต่​ทำให้​ระบบ type กัน​ความ​ผิดพลาด​ของ​มนุษย์ เช่น​การ​สลับ​ลำดับ parameter

micro types เป็น​เรื่อง​ที่​ถกเถียง​กัน

micro types ไม่ใช่ best practice ที่​ทุก​คน​เห็น​พ้อง บาง​คน​มอง​ว่า​เป็น​บันได​สู่ code ที่​ชัด​และ​ประกอบ​กัน​ได้​ดี แต่​บาง​คน​มอง​ว่า​เป็น “ชั้น indirection ที่​ซ้ำซ้อน​เกิน​จำเป็น” — คุณ​ต้อง​ชั่ง​น้ำหนัก​เอง​ว่า​จะ​ใช้​หรือ​ไม่

ชาว DDD บาง​คน​เชื่อ​ว่า​คุณ ไม่​ควร​มี collection ของ value object เลย เหตุผล​คือ collection ของ primitive ไม่​สื่อ​แนวคิด domain และ​การ​มี collection ของ value object มัก​หมายความ​ว่า​คุณ​ต้อง​หยิบ​สมาชิก​เฉพาะ​ตัว​ด้วย “identity” บาง​รูปแบบ — ซึ่ง​ขัด​กับ​หลัก​ที่​ว่า value object ไม่มี identity

ดู​ตัวอย่าง Customer ที่​มี IEnumerable<PhoneNumber>:

ก่อน: collection ที่​กำกวม
public class Customer
{
public IEnumerable<PhoneNumber> PhoneNumbers { get; set; } // เบอร์ไหนเป็นบ้าน? มือถือ? ที่ทำงาน?
}

มัน​ไม่​ชัด​ว่า​เบอร์​แต่ละ​ตัว​คือ​อะไร วิธี​จำลอง​ที่​ตรง​กับ​เจตนา​มากกว่า​คือ​ใช้ โครงสร้าง สื่อ​แนวคิด domain ที่แท้​จริง: ลูกค้า​มี​เบอร์​บ้าน เบอร์​มือถือ และ​เบอร์​ที่​ทำงาน:

หลัง: PhoneBook value object
public class PhoneBook : ValueObject<PhoneBook>
{
public readonly PhoneNumber HomeNumber, MobileNumber, WorkNumber;
// เข้าถึงแต่ละเบอร์ได้โดยไม่ต้อง lookup ด้วย ID — เพราะ value object ไม่มี identity
}

เจตนา​และ​แนวคิด domain ชัดเจน​ขึ้น​มาก โครงสร้าง​ถูก​เข้า​รหัส​ลง​ใน​ระบบ type และ​บังคับ​ใช้​โดย type system


แง่​มุม​ที่​ยาก​ที่สุด​ของ​การ​ทำงาน​กับ value object คือ​การ persist มัน เพราะ​มัน​ไม่มี identity ของ​ตัวเอง

ฐาน​ข้อมูล​แบบ document (เช่น RavenDB, EventStore) ใช้ denormalization ซึ่ง​เข้า​กัน​ได้​ดี​กับ DDD เพราะ entity ทั้ง​ตัว — บางที​ทั้ง AggregateAggregateกลุ่ม​ของ Entity และ Value Object ที่​ถูก​มอง​เป็น​หนึ่ง​หน่วย​เดียว​เพื่อ​รักษา​ความ​ถูกต้อง​ของ​ข้อมูล มี​ขอบเขต​ชัดเจน และ​เป็น​หน่วย​ของ transactionTactical Design — ถูก​จำลอง​เป็น document เดียว​ได้ ปัญหา​เรื่อง join, normalize, lazy loading ผ่าน ORM จึง​หาย​ไป สำหรับ value object หมายความ​ว่า​มัน​ถูก​เก็บ ฝัง​ไป​กับ entity เลย เช่น Customer กับ Name กลาย​เป็น JSON document เดียว:

customer.json
{
"Customer": {
"Id": "…",
"Name": { "FirstName": "…", "Surname": "…" }
}
}

การ​ฝัง value object ลง document เป็น​ธรรมเนียม​ทั่วไป แต่​ก็​ยัง​เป็น​ตัว​เลือก — คุณ​เก็บ​เป็น document แยก​เพื่อ​เหตุผล​ด้าน performance ได้​ถ้า​จำเป็น

ฝั่ง SQL มี​ธรรมเนียม normalization ที่​แข็งแรง ทำให้​มี​ทาง​เลือก​หลากหลาย​กว่า สรุป​เป็น​สอง​แนวทาง​หลัก: denormalize แบบ​แบน (เก็บ​ค่าตรงๆ ใน​ตาราง​ของ entity) หรือ normalize แยก​ตาราง

Flat Denormalization — เก็บ​ค่า​ของ value object ตรงๆ ด้วย​รูปแบบ​ที่​เรา​กำหนด​เอง เหมาะ​เมื่อ​ไม่​อยาก​มี​ตาราง​และ join เพิ่ม ตัวอย่าง​ต้นแบบ​คือ DateTime ที่ SQL เก็บ​เป็น​ข้อความ ไม่มี​ตาราง DateTime แยก เรา​ทำ​กับ value object ของ​เรา​เอง​ได้​เช่น​กัน โดย​สร้าง persistence format ที่​ระบุ​ค่า​ได้​ไม่​ซ้ำ วิธี​หนึ่ง​คือ override ToString():

Name.cs
public override string ToString() =>
String.Format("firstName:{0};;surname:{1}", FirstName, Surname);
// -> "firstName:Sally;;surname:Smith"

เวลา​บันทึก คุณ​บอก ORM ให้​เก็บ​ค่า​ใน​รูปแบบ​นี้ (ใน NHibernate ใช้ IUserType.NullSafeSet()) และ​เวลา​โหลด ก็ parse กลับ​เป็น value object (IUserType.NullSafeGet()) — โดย​ควร​ให้ logic การ parse อยู่​ใน component กลาง ไม่​ปน​เข้าไป​ใน value object เพื่อ​ไม่​ให้​เรื่อง framework รั่ว​เข้าไป​ใน domain

รูปที่ 15-1: Value object ที่ถูก persist ในรูปแบบ custom — column Name เก็บเป็นข้อความ "firstName;;surname" ในตารางเดียวกับ Customer

Normalizing into Separate Tables — denormalization เป็น strategy โดย​พฤตินัย แต่​บาง​ครั้ง​มาตรฐาน​บริษัท​บังคับ​ให้ normalize หรือ​เหมาะ​กว่า​ด้วย​เหตุผล performance (โดย​เฉพาะ​เมื่อ value object มี​ขนาด​ใหญ่​ที่​ไม่​อยาก​โหลด​ทุก​ครั้ง​ที่​โหลด entity) ใน​กรณี​นี้ entity กับ value object มี​ตาราง​ของ​ตัวเอง เชื่อม​ด้วย foreign key:

CustomerNormalizedMap.cs (Fluent NHibernate)
Join("CustomerName", join =>
{
join.KeyColumn("Id"); // ใช้ Id ของ Customer เป็น foreign key
join.Component(x => x.Name, c =>
{
c.Map(x => x.FirstName);
c.Map(x => x.Surname);
});
});

รูปที่ 15-2: ORM สร้างตาราง CustomerName แยกต่างหากจาก Customer เพื่อเก็บ Name value object

รูปที่ 15-3: ข้อมูลที่ persist จริง — Id ของ Customer ถูกใช้เป็น foreign key เชื่อมตาราง Customer กับ CustomerName

ORM หลาย​ตัว​รองรับ value object มา​ใน​ตัว

NHibernate มี​แนวคิด Component, EF Core มี Owned Types ที่​ช่วย​จัดการ value object ให้​โดย​ไม่​ต้อง​คิด custom format เอง — คุ้ม​ค่าที่​จะ​สำรวจ​การ​รองรับ​เหล่า​นี้​ก่อน เพราะ​มัน​ประหยัด​เวลา​ได้​มาก


หัวใจ​ของ​บท​นี้

Value ObjectValue Objectอ็อบเจ็กต์ที่​สำคัญ​ที่ “ค่า” ของ​มัน ไม่มี​ตัวตน​เฉพาะ สอง​ตัว​ที่​ค่า​เท่า​กัน​ถือว่า​เท่า​กัน ควร​เป็น immutable (เปลี่ยน​ค่า​ไม่​ได้) เช่น เงิน, วัน​ที่, พิกัดTactical Design คือ building block ที่​แทน ค่า​เชิง​พรรณนา (ขนาด, ปริมาณ, การ​วัด) โดย ไม่มี identityIdentity“ตัวตน” ที่​ทำให้ Entity เป็น​สิ่ง​เดิม​ตลอด​อายุ แม้​ค่า​เปลี่ยน เช่น เลข​ที่​คำ​สั่ง​ซื้อ หรือ id ที่​ระบบ​สร้าง​ให้Tactical Design — เท่า​กัน​ด้วย​ค่า (immutableImmutableเปลี่ยนแปลง​ไม่​ได้​หลัง​สร้าง ถ้า​จะ “เปลี่ยน​ค่า” ต้อง​สร้าง​อ็อบเจ็กต์ใหม่​แทน หลักการ​สำคัญ​ของ Value Object เพื่อ​เลี่ยง bug จาก​การ​ใช้​อ้างอิง​ร่วม​กันTactical Design + attribute-based equality), รวม​กัน​ได้ (combinable), ตรวจสอบ​ความ​ถูกต้อง​ของ​ตัวเอง (self-validating) และ​ควร behavior-rich โดย​ห่อ primitive ให้​สื่อ​แนวคิด domain จำ​ไว้​ว่า​สิ่ง​ที่​เป็น value object ใน domain หนึ่ง อาจ​เป็น entity ใน​อีก domain หนึ่ง​ได้ และ​เลือก persist ได้​ทั้ง​แบบ denormalize (ฝัง​กับ entity) หรือ normalize (แยก​ตาราง/document)


🔗 อ้างอิง​เพิ่มเติม​ใน DevIQ

เจาะ​ลึก​แนวคิด​ใน​บท​นี้​ต่อ​ได้ที่​คลัง​อ้างอิง DevIQ:

  • Value Object — นิยาม​และ​คุณสมบัติ​ของ value object โดยตรง ครอบคลุม​ประเด็น​เดียว​กับ​ที่​บท​นี้​อธิบาย
  • Entity — ตัว​เทียบเคียง​ที่​สำคัญ​ที่สุด​ของ value object เข้าใจ identity ของ entity ให้​ชัด จะ​ช่วย​ให้​แยก​ได้​ทันที​ว่า​แนวคิด​ไหน​ควร​เป็น value object
  • Factory Method — pattern เบื้องหลัง static factory method (เช่น Height.FromFeet()) ที่​บท​นี้​แนะนำ​ให้​ใช้​แทน constructor ตรงๆ

เช็กความเข้าใจ — บทที่ 15

ข้อ 1 / 4

อะไรคือความแตกต่างหลักระหว่าง Entity กับ Value Object?