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

Value Object

type ที่ immutable และ​แยกแยะ​ได้​ด้วย​ค่า​ของ property เท่านั้น

Value Object คือ type ที่ immutable (เปลี่ยนแปลง​สถานะ​ภายใน​ไม่​ได้​หลัง​สร้าง​เสร็จ) และ​แยกแยะ​ได้​ด้วย​ค่า​ของ property เท่านั้น ต่าง​จาก Entity ที่​มี identity เฉพาะ​ตัว​ติดตาม​ข้าม​เวลา — Value Object สอง​ตัว​ที่​มี property เหมือน​กัน​ทุก​ประการ​ถือว่า “เท่า​กัน” (equal) โดย​ไม่​สนใจ​ว่า​จะ​เป็น​คนละ instance ใน​หน่วย​ความ​จำ​หรือ​ไม่ แนวคิด​นี้ Eric Evans อธิบาย​ไว้​เป็น​ครั้ง​แรก​อย่าง​เป็น​ระบบ​ใน​หนังสือ Domain-Driven Design และ Martin Fowler ก็​บันทึก​ไว้​ใน PoEAA (Patterns of Enterprise Application Architecture) ใน​ชื่อ​เดียวกัน​ตั้งแต่​ก่อนหน้า​นั้น โดย​นิยาม​สั้น ๆ ว่า “a small object that represents an entity whose equality is not based on identity”

ตัวอย่าง​ที่​พบ​บ่อย: จำนวน​เงิน (Money — ตัวเลข + สกุล​เงิน), ที่​อยู่ (Address), ช่วง​วัน​ที่ (DateRange), พิกัด (Coordinate), เบอร์​โทรศัพท์ Money สอง​ก้อน​ที่​มี​จำนวน 100 บาท​เท่า​กัน ถือว่า​เป็น​ค่า​เดียวกัน​เสมอ ไม่​ว่า​จะ​มา​จาก​ธุรกรรม​ไหน — นี่​คือ​สิ่ง​ที่​แยก Value Object ออก​จาก Entity อย่าง Order หรือ Customer ที่​แม้ property จะ​เหมือน​กัน​ทุก​ตัว​แต่​ก็​ยัง​ถือ​เป็น​คนละ​รายการ​ถ้า identity ต่าง​กัน

ใน DDD tactical patterns Value Object เป็น​หน่วย​ประกอบ​สำคัญ​ของ Aggregate คู่​กับ Entity: Aggregate root มัก​เป็น Entity ที่​มี identity ส่วน attribute จำนวน​มาก​ภายใน​ควร​ถูก “ยก” ออก​มา​เป็น Value Object แทนที่​จะ​ปล่อย​เป็น primitive กระจัดกระจาย (ดู Primitive Obsession) เช่น Order entity ที่​มี ShippingAddress เป็น Address value object แทนที่​จะ​มี string ห้า​ตัว​ลอย ๆ

เหตุผล​ที่ DDD ผลักดัน​ให้​ใช้ Value Object ให้​มาก​ที่สุด​เท่า​ที่​จะ​ทำได้ (Vaughn Vernon เรียก​แนวทาง​นี้​ว่า “prefer Value Objects to Entities”):

  • ปลอดภัย​จาก​การ​แก้ไข​ข้าม​ที่ (aliasing bug) — เพราะ immutable, การ​ส่ง Value Object ไป​ให้ code ส่วน​อื่น​ใช้​ร่วม​กัน​จะ​ไม่มี​ทาง​ถูก​แก้ไข​จน​กระทบ​ที่​อื่น​โดย​ไม่​ตั้งใจ ต่าง Entity ที่ reference เดียวกัน​อาจ​ถูก​แก้​จาก​หลาย​จุด
  • Side-effect-free function — method บน Value Object ที่ “ดูเหมือน​แก้ไข” ที่​จริง​คืน​ค่า​เป็น instance ใหม่​เสมอ (เหมือน string.ToUpper() ใน .NET ที่​ไม่​แก้ string เดิม) ทำให้​ทดสอบ​และ​ให้​เหตุผล​ได้​ง่าย​กว่า​มาก เพราะ​ไม่มี state เปลี่ยนแปลง​แอบแฝง
  • Equality ที่​ตรง​ไป​ตรง​มา — เปรียบเทียบ​ด้วย​ค่า​รวม​ของ​ทุก property ไม่ใช่ reference จึง​เทียบ, เก็บ​ใน HashSet, หรือ​ใช้​เป็น key ได้​อย่าง​ปลอดภัย
  • บังคับ invariant ให้​ถูกต้อง​ตั้งแต่​สร้าง — การ​รับ​ค่า​ทั้งหมด​ผ่าน constructor เดียว​เปิด​โอกาส​ให้ validate ค่าที่​ผิด (เช่น ZipCode รูปแบบ​ผิด) ตั้งแต่​จุด​กำเนิด ทำให้ state ที่​ผิด​กฎ “สร้าง​ไม่​ได้​เลย” (ดู Make Illegal States Unrepresentable)

ผล​คือ model domain ที่​มี Entity น้อย​ลง มี Value Object มาก​ขึ้น ซึ่ง​มัก​ทำให้ domain model แสดง​เจตนา​ชัดเจน​ขึ้น​และ​มี bug จาก​การ mutate โดย​ไม่​ตั้งใจ​น้อย​ลง

แผนภาพ​ต่อ​ไป​นี้​แสดง Order (Entity ซึ่ง​มี identity) ที่​ประกอบ​ด้วย Value Object สอง​ชนิด คือ Address และ Money — สังเกต​ว่า OrderItem ก็​ใช้ Money ซ้ำ​ได้​เพราะ Value Object ไม่มี​เจ้าของ​แต่​ผู้​เดียว:

classDiagram
  class Order
  class Address
  class OrderItem
  class Money

  Order o-- Address
  Order o-- OrderItem
  OrderItem o-- Money
  Order o-- Money

โครง​ร่าง​มาตรฐาน​ของ Value Object ใน C# ต้อง​รับ​สถานะ​ทั้งหมด​เข้า​มา​ตอน​สร้าง (constructor) และ property ต้อง​เป็น read-only (private set หรือ get-only) เมื่อ immutable แล้ว การ “แก้ไข” value object จึง​เทียบ​เท่ากับ​การ​ทิ้ง​ของ​เก่า​แล้ว​สร้าง​ใหม่:

public class Address : ValueObject
{
public string Street { get; }
public string City { get; }
public Address(string street, string city) // รับค่าครบตอนสร้าง
{
Street = street;
City = city;
}
}

ใน​ทาง​ปฏิบัติ C# ไม่มี equality-by-value ให้​ฟรี​สำหรับ class ธรรมดา (ต่าง​จาก record ใน C# 9 ขึ้น​ไป​ที่ generate ให้​อัตโนมัติ) จึง​มัก​เขียน base class กลาง​ให้ subclass ทุก​ตัว inherit เพื่อ​ไม่​ต้อง​เขียน Equals/GetHashCode ซ้ำ​ทุก​ที่ — แนวทาง​นี้​ปรากฏ​ใน eShopOnContainers ของ Microsoft:

public abstract class ValueObject
{
protected abstract IEnumerable<object> GetEqualityComponents();
public override bool Equals(object obj)
{
if (obj == null || obj.GetType() != GetType())
return false;
var other = (ValueObject)obj;
return GetEqualityComponents()
.SequenceEqual(other.GetEqualityComponents());
}
public override int GetHashCode()
{
return GetEqualityComponents()
.Select(x => x != null ? x.GetHashCode() : 0)
.Aggregate((x, y) => x ^ y);
}
}
public class Money : ValueObject
{
public decimal Amount { get; }
public string Currency { get; }
public Money(decimal amount, string currency)
{
Amount = amount;
Currency = currency;
}
// side-effect-free function: คืนค่าใหม่เสมอ ไม่แก้ this
public Money Add(Money other)
{
if (other.Currency != Currency)
throw new InvalidOperationException("สกุลเงินไม่ตรงกัน");
return new Money(Amount + other.Amount, Currency);
}
protected override IEnumerable<object> GetEqualityComponents()
{
yield return Amount;
yield return Currency;
}
}

เมื่อ persist ด้วย Entity Framework Core (ตั้งแต่ 2.0 ขึ้น​ไป) Value Object อย่าง Address มัก​ถูก map เป็น “owned entity type” ผ่าน OwnsOne(o => o.Address) — EF จะ​สร้าง shadow key ให้​เอง​ภาย​ใต้​ผ้า​คลุม แต่​จาก​มุมมอง​ของ domain model จะ​ไม่มี Id field ปรากฏ​ให้​เห็น​เลย ซึ่ง​ตรง​กับ​เจตนา​ของ pattern นี้

  • Entity — คู่​ตรง​ข้าม​ที่​นิยาม​ซึ่ง​กัน​และ​กัน: Entity แยกแยะ​ด้วย identity ที่​คง​อยู่​ตลอด​วงจร​ชีวิต​แม้ attribute จะ​เปลี่ยน ส่วน Value Object แยกแยะ​ด้วย​ค่า​เท่านั้น​และ​ไม่​ควร​มี identity เลย เวลา​ออกแบบ model คำถาม​คือ “ต้อง​ติดตาม identity ของ​สิ่ง​นี้​ข้าม​เวลา​ไหม” ถ้า​ไม่​ต้อง ให้​เป็น Value Object
  • AggregateAggregate คือ​กลุ่ม​ของ Entity และ Value Object ที่​ถูก​ปฏิบัติ​เป็น​หน่วย​เดียว​เพื่อ consistency; Value Object มัก​เป็น​ส่วน​ใหญ่​ของ attribute ภายใน aggregate
  • Primitive Obsession — code smell ที่​ใช้ primitive (string, int) แทน​แนวคิด domain ที่​ควร​ห่อ​เป็น Value Object; การ “เลื่อน​ขั้น” primitive ให้​เป็น Value Object คือ​วิธี​แก้​ตรง​จุด
  • Make Illegal States Unrepresentable — constructor ของ Value Object ที่ validate ครบ​เป็น​กลไก​หลัก​ที่​ทำให้​หลักการ​นี้​เกิด​ขึ้น​จริง​ใน code
  • immutable object (แนวคิด​ทั่วไป​นอก DDD) — Value Object คือ​การ​ประยุกต์​แนวคิด immutability ของ functional programming เข้า​กับ domain model; หลักการ​เดียว​กับ​ที่​ทำให้ string ใน .NET หรือ tuple ใน F# ปลอดภัย​ต่อ​การ share