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

Value Object — สร้าง​ค่าที่ "ผิด​ไม่​ได้"

ใน Course A บท​ที่ 2 เรา​ได้​เจอ Value ObjectValue Objectอ็อบเจ็กต์ที่​นิยาม​ด้วย 'ค่า' ไม่มี identity ของ​ตัวเอง สอง​ตัว​ที่​ค่า​เท่า​กัน​ถือว่า​เท่า​กัน ควร immutable เช่น Money, Address, QuantityTactical Design สอง​ตัว​แรก​ของ domain Order ไป​แล้ว: Money กับ Quantity — ทั้ง​คู่​เป็น record ที่​ห่อ​ค่า​ดิบ (decimal, int) ให้​กลาย​เป็น type ที่​รักษา​ตัวเอง​ด้วย guard clause บท​นั้น​แนะนำ​มัน​แบบ​เบาๆ พอ​ให้​เห็น​ภาพ บท​นี้​เรา​จะ​กลับ​มา​ขุด​ลึก​กว่า​เดิม: ทำไม value object ต้อง immutableImmutableเปลี่ยนแปลง​ไม่​ได้​หลัง​สร้าง ถ้า​จะ 'เปลี่ยน​ค่า' ต้อง​สร้าง​อ็อบเจ็กต์ใหม่​แทน หลักการ​สำคัญ​ของ Value Object เพื่อ​เลี่ยง bug จาก​การ​ใช้​อ้างอิง​ร่วม​กันTactical Design, primitive obsession มี​อาการ​ยังไง และ​กรณี​จริง​ที่​ต้อง​ใช้ value object แน่ๆ — การ​ปัดเศษ​เงิน​เป็น​สตางค์ แล้ว​ประกอบ​หลาย Money เข้า​ด้วย​กัน​เป็น PriceBreakdown ที่​มี VAT

📦 code ตัวอย่าง

code เต็ม​ของ​บท​นี้​อยู่​ที่ repo kaen-food-ordering (กำลัง​จัด​ทำ) — path: FoodOrdering.Domain/Orders/

ทวน​ของ​เดิม​ให้​ครบ​ก่อน เพราะ​บท​นี้​จะ​ต่อยอด​จาก​มันตรงๆ Money ห่อ Amount กับ Currency และ​รักษา​ตัวเอง​ผ่าน primary constructor body:

// FoodOrdering.Domain/Orders/Money.cs (จาก Course A บทที่ 2)
public sealed record Money(decimal Amount, string Currency)
{
public Money
{
if (Amount < 0)
throw new ArgumentException("จำนวนเงินต้องไม่ติดลบ", nameof(Amount));
if (Currency != "THB")
throw new ArgumentException("ตอนนี้รองรับเฉพาะสกุลเงิน THB", nameof(Currency));
}
public static Money Thb(decimal amount) => new(amount, "THB");
public static Money operator +(Money a, Money b)
{
if (a.Currency != b.Currency)
throw new InvalidOperationException("บวกเงินต่างสกุลกันไม่ได้");
return new Money(a.Amount + b.Amount, a.Currency);
}
public static Money operator *(Money unitPrice, int quantity) =>
new(unitPrice.Amount * quantity, unitPrice.Currency);
}

และ Quantity ห่อ int เปล่าๆ ให้​รักษา​กฎ “ต้อง​ไม่​ต่ำ​กว่า 1” ด้วย​ตัวเอง:

// FoodOrdering.Domain/Orders/Quantity.cs (จาก Course A บทที่ 2)
public sealed record Quantity(int Value)
{
public Quantity
{
if (Value < 1)
throw new ArgumentException("จำนวนต้องมีอย่างน้อย 1", nameof(Value));
}
}

สอง​ตัว​นี้​คือ value object ครบ​สูตร​อยู่​แล้ว — แต่ Course A หยุด​แค่​นี้ บท​นี้​จะ​ไป​ต่อ​ใน​สอง​ทิศทาง: (1) ทำไม pattern นี้​สำคัญ​พอที่​จะ​ใช้​ทุก​ที่​ที่​มี “ค่า” ใน domain ไม่ใช่​แค่​เงิน​กับ​จำนวน และ (2) กรณี​ที่ Money เดี่ยวๆ ยัง​ไม่​พอ — ต้อง​ปัดเศษ​ให้​ถูก​และ​ประกอบ​เข้า​ด้วย​กัน​เป็น​โครงสร้าง​ใหญ่​ขึ้น

ลอง​จินตนาการ​ว่า​เรา​ไม่​เคย​รู้จัก Money/Quantity มา​ก่อน แล้ว​อยาก​เก็บ​ยอด​รวม​ของออเดอร์ — สัญชาตญาณ​ทั่วไป​มัก​ไป​จบ​ที่ type พื้น​ฐานตรงๆ:

❌ version ดิบ — ราคา​ที่​ไม่มี type ป้องกัน​อะไร​เลย
// ❌ version ดิบ — ใช้ decimal/string เปล่า ๆ แทนทุกอย่าง
public class PriceBreakdownDraft
{
public decimal Subtotal { get; set; }
public decimal Discount { get; set; }
public decimal DeliveryFee { get; set; }
public decimal Vat { get; set; }
public decimal Total { get; set; }
public string Currency { get; set; } = "THB";
}

ใช้งาน​จริง​แล้ว bug โผล่​แบบ​ไม่มี compiler ช่วย​เตือน​สัก​บรรทัด:

var draft = new PriceBreakdownDraft
{
Subtotal = 149.50m,
Discount = 0m,
DeliveryFee = 20m,
Vat = 149.50m * 0.07m, // = 10.465 — เงินบาทไม่มี "สามตำแหน่งทศนิยม" แต่ไม่มีใครห้าม
Currency = "USD" // พิมพ์ผิด — ร้านนี้ขายบาทเท่านั้น แต่ไม่มีอะไรเช็ก
};
draft.Total = draft.Subtotal + draft.DeliveryFee + draft.Vat; // ลืมหัก Discount ก็ยัง compile ผ่านสบาย ๆ

สาม bug ซ้อน​กัน​อยู่​ใน code สี่​บรรทัด​นี้: Vat มี​ทศนิยม​เกิน​หน่วย​สตางค์​จริง, Currency พิมพ์​ผิด​เป็น "USD" โดย​ไม่มี​ใคร​ทักท้วง, และ​สูตร Total ลืม​หัก Discount ไป​เลย — compiler มอง​ไม่​เห็น​สัก​ข้อ เพราะ decimal และ string ไม่รู้จัก​กฎ​ของ domain นี้​เลย​สัก​นิด นี่​คือ​อาการ​ของ Primitive ObsessionPrimitive Obsessioncode smell ที่​ใช้ type พื้นฐาน (decimal, string, int) แทน​แนวคิด​ใน domain ที่​ควร​มี type ของ​ตัวเอง เช่น decimal เปล่า​แทน​เงิน ทำให้​กฎ/หน่วย/การ​ตรวจสอบ​หล่น​หาย — Value Object คือ​ทาง​แก้Tactical Design — code smell ที่​ใช้ type พื้นฐาน (decimal, string, int) แทน​แนวคิด domain ที่​ควร​มี type ของ​ตัวเอง ผล​คือ​หน่วย/ช่วง​ค่า/กฎ​ทั้งหมด​หล่น​หาย​ไป​กับ​พื้น​ดิน เหลือ​แค่​ตัวเลขลอยๆ ที่​ใคร​จะ​เอา​ไป​บวก​ลบ​คูณ​หาร​ยังไง​ก็ได้ (ดู​เพิ่ม​ที่ Primitive Obsession ใน DevIQ)

ทาง​แก้​ไม่ใช่​การ​เขียน if เพิ่ม​อีก​สิบ​บรรทัด​กระจาย​อยู่​ทุก​ที่​ที่​แตะ​ราคา แต่​คือ​การ​ห่อ​ทุก​อย่าง​ด้วย value object ที่​รับผิดชอบ​กฎ​ของ​ตัวเอง​แบบ​เดียว​กับ​ที่ Money/Quantity ทำ​อยู่​แล้ว

จุด​ที่​ทำให้ value object ต่าง​จาก object ทั่วไป​มี​สอง​ข้อ​ที่มา​คู่​กัน​เสมอ:

ความ​เท่า​กัน​แบบ value equalityMoney.Thb(100) สอง​ตัว​ที่​สร้าง​แยก​กัน​คนละ​ที่ ถือว่า “เท่า​กัน” เสมอ​ถ้า Amount และ Currency ตรง​กัน ไม่​ต้อง​เช็ก reference เลย เพราะ C# record ให้ value equality มา​ฟรี​ตั้งแต่​ใน​ตัว (==, !=, Equals ถูก generate ให้​อัตโนมัติ​จาก field ทุก​ตัว) ต่าง​จาก Order ที่​ต่อ​ให้2 instance มี​ข้อมูล​เหมือน​กัน​ทุก​ตัว​อักษร ก็​ยัง​เป็น​คน​ละ​ออเดอร์​ถ้า​คนละ OrderId

immutable — เมื่อ​สร้าง Money หรือ Quantity เสร็จ​แล้ว ไม่มี​ทาง​แก้​ค่า​ข้าง​ใน​ได้​อีก จะ “เปลี่ยน​ค่า” ต้อง​สร้าง instance ใหม่​เท่านั้น (เช่น operator + ของ Money คืน Money ก้อน​ใหม่​เสมอ ไม่​แตะ​ก้อน​เดิม) เหตุผล​ไม่ใช่​แค่​สไตล์ functional — มัน​คือ​สิ่ง​ที่​ทำให้ guard clause ตอน​สร้าง​มี “ความหมาย” ตลอด​อายุ​ของ object เพราะ​ถ้า​ค่า​เปลี่ยน​ไม่​ได้​หลัง​ผ่าน guard ไป​แล้ว เรา​ก็​มั่นใจ​ได้ ตลอด​เวลา ว่า instance ที่​ถือ​อยู่​ใน​มือ​ยัง​ถูกต้อง​ตาม​กฎ​เสมอ ไม่​ต้อง re-validate ซ้ำ​ทุก​ครั้ง​ที่​มี​คน​อ่าน​ค่า ตรง​ข้าม​กับ version ดิบ​ด้าน​บน​ที่ Total เป็น public setter — ใคร​จะ set อะไร​ก็ได้​ทุกเมื่อ ไม่มี​อะไร​รับประกัน​ว่า​มัน​ยัง​ถูก​อยู่

ผลพลอยได้​ที่​สำคัญ​คือ value object ทำให้ สถานะ​ที่​ผิด​กฎ​กลาย​เป็น​สิ่ง​ที่​สร้าง​ไม่​ได้​เลย (illegal states unrepresentable) — ไม่ใช่​แค่ “เช็ก​แล้ว​โยน error ทีหลัง” แต่​คือ​ไม่มี​ทาง compile code ที่​สร้าง Money ติดลบ​หรือ Quantity เป็น 0 หลุด​ออก​ไป​ใน​ระบบ​ได้​ตั้งแต่​ต้น ลอง​ดู​อีก​ตัวอย่าง​ที่​ยัง​ไม่​เคย​มี​ใน domain นี้ — ที่​อยู่​จัด​ส่ง:

// FoodOrdering.Domain/Orders/Address.cs (ใหม่ในบทนี้)
public sealed record Address(string Line1, string District, string Postcode)
{
public Address
{
if (string.IsNullOrWhiteSpace(Line1))
throw new ArgumentException("ที่อยู่ต้องมีบรรทัดที่อยู่อย่างน้อยหนึ่งบรรทัด", nameof(Line1));
if (string.IsNullOrWhiteSpace(District))
throw new ArgumentException("ที่อยู่ต้องระบุเขต/อำเภอ", nameof(District));
if (Postcode.Length != 5 || !Postcode.All(char.IsDigit))
throw new ArgumentException("รหัสไปรษณีย์ต้องเป็นตัวเลข 5 หลัก", nameof(Postcode));
}
}

Address ไม่มี​ทาง​ถือ​ค่า Postcode ที่​ไม่ใช่​เลข 5 หลัก หรือ Line1 ว่างเปล่า​ได้​เลย — ต่าง​จาก​ถ้า​เรา​เก็บ​ที่​อยู่​เป็น string สามตัวลอยๆ ใน Order ที่​ต้อง​คอย​เช็ก​เอง​ทุก​จุด​ที่​ใช้งาน (แล้ว​ก็​มี​จุด​ที่​ลืม​เช็ก​แน่ๆ สัก​จุด​หนึ่ง)

นี่​คือ​จุด​ที่ value object เดี่ยวๆ อย่าง Money ยัง​ไม่​พอ ลอง​กลับ​ไป​ดู bug ตัว​แรก​ใน version ดิบ​ด้าน​บน: 149.50m * 0.07m ได้ 10.465 — เงิน​บาท​มี​หน่วย​เล็ก​สุด​คือ สตางค์ (สอง​ตำแหน่ง​ทศนิยม) เลข​ทศนิยม​ตำแหน่ง​ที่​สาม​แบบ​นี้​ไม่มี​ความหมาย​ใน​โลก​จริง แต่ decimal ของ C# ไม่รู้เรื่อง​นี้​เลย มัน​เก็บ​ได้​เรื่อยๆ ตาม​ที่​คำนวณ​มา ปัญหา​คือ Money เอง​ก็​ไม่​ได้​ตรวจ​เรื่อง​นี้​ใน​ตอน​สร้าง (guard clause ของมันเช็ก​แค่ Amount >= 0 กับ Currency == "THB" เท่านั้น) — เพราะ​การ​ปัดเศษ​ไม่ใช่​กฎ​ที่ “ต้อง​ผ่าน​เสมอ” แบบ invariant ของ Amount/Currency แต่​เป็น​สิ่ง​ที่​ต้อง​ทำ หลัง​การ​คำนวณ ทุก​ครั้ง​ที่​อาจ​เกิด​เศษ​สตางค์ เช่น​ตอน​คิด VAT

เรา​จึง​เพิ่ม method ใหม่​ให้ Money:

// FoodOrdering.Domain/Orders/Money.cs — เพิ่มใหม่ในบทนี้
// (Amount, Currency, guard clause, Thb, operator +, operator * เหมือนเดิมทุกตัวอักษรตามที่ทบทวนไว้ด้านบน)
public sealed record Money(decimal Amount, string Currency)
{
// ... เหมือนเดิม
// ใหม่ในบทนี้: ปัดเศษเป็นสตางค์ (สองตำแหน่งทศนิยม) แบบปัดออกจากศูนย์เมื่อเจอเลข 5 พอดี
public Money Round() =>
new(Math.Round(Amount, 2, MidpointRounding.AwayFromZero), Currency);
}

Round() ไม่ใช่ guard clause — มัน​คือ​พฤติกรรม​ที่​นัก​พัฒนา​ต้อง เรียก​เอง หลัง​คำนวณ​ที่​อาจ​สร้าง​เศษ​สตางค์ เช่น​คูณ​ด้วย​เปอร์เซ็นต์ VAT (สังเกต​ว่า operator * ของ Money รับ​แค่ int เท่านั้น ไม่มี operator * ที่​รับ decimal — เรา​จึง​ต้อง​คำนวณ​จาก .Amount ตรงๆ แล้ว​ห่อ​กลับ​เป็น Money ผ่าน Thb(...) และ Round()):

var subtotal = Money.Thb(149.50m);
var discount = Money.Thb(0m);
var deliveryFee = Money.Thb(20m);
// VAT 7% ของ Subtotal — คูณ decimal ตรง ๆ แล้วปัดกลับเป็นสตางค์
var vat = Money.Thb(subtotal.Amount * 0.07m).Round(); // 149.50 * 0.07 = 10.465 -> ปัดเป็น 10.47
var total = subtotal + deliveryFee + vat; // ไม่มี Discount เพราะ Discount = 0 บาท

ตอน​นี้​เรา​มี Money ห้า​ก้อน​ที่​ต้อง​สัมพันธ์​กัน​อย่าง​ถูกต้อง​เสมอ — Subtotal, Discount, DeliveryFee, Vat, Total — และ​นี่​คือ​แบบฉบับ​ของ​สิ่ง​ที่ value object เดี่ยว​ห่อ​ไม่​ได้ เรา​จึง​ต้อง​มี value object อีก​ชั้น​ที่​ประกอบ​พวก​มัน​เข้า​ด้วย​กัน​และ​รักษา​ความ​สัมพันธ์​นั้น:

// FoodOrdering.Domain/Orders/PriceBreakdown.cs (ใหม่ในบทนี้)
public sealed record PriceBreakdown(Money Subtotal, Money Discount, Money DeliveryFee, Money Vat, Money Total)
{
public PriceBreakdown
{
// invariant ⑧: Total ต้องเท่ากับ Subtotal หักด้วย Discount บวก DeliveryFee บวก Vat เสมอ
// จัดสมการใหม่ให้ใช้ operator + ที่ Money มีอยู่แล้วเท่านั้น (Money ยังไม่มี operator -):
// Total == Subtotal - Discount + DeliveryFee + Vat
// <=> Total + Discount == Subtotal + DeliveryFee + Vat
if (Total + Discount != Subtotal + DeliveryFee + Vat)
throw new InvalidOperationException(
"PriceBreakdown ไม่ถูกต้อง: Total ต้องเท่ากับ Subtotal หักด้วย Discount บวก DeliveryFee บวก VAT เสมอ");
}
}

Total + Discount != Subtotal + DeliveryFee + Vat ใช้ != ของ record ได้ตรงๆ (value equality ที่​พูด​ถึง​ไป​ข้าง​บน) และ​ใช้ operator + ของ Money ที่​มี​อยู่​แล้ว​ล้วนๆ — ไม่​ต้อง​เพิ่ม operator - ให้ Money เลย​ด้วย​ซ้ำ สร้าง PriceBreakdown จาก​ตัวอย่าง​ด้าน​บน​ได้​สำเร็จ เพราะ 179.97 + 0 == 149.50 + 20 + 10.47:

var breakdown = new PriceBreakdown(subtotal, discount, deliveryFee, vat, total); // ผ่าน guard

แต่​ถ้า​มี​ใคร​ลืม​หัก Discount แบบ​เดียว​กับ version ดิบ​ตอน​ต้นบท:

var brokenDiscount = Money.Thb(15m);
var broken = new PriceBreakdown(subtotal, brokenDiscount, deliveryFee, vat, total);
// throws InvalidOperationException: "PriceBreakdown ไม่ถูกต้อง: Total ต้องเท่ากับ..."
// เพราะ 179.97 + 15 (194.97) != 149.50 + 20 + 10.47 (179.97)

PriceBreakdown โยน exception ทันที​ตอน​สร้าง ไม่​ปล่อย​ให้​ค่าที่​ผิด​หลุด​เข้าไป​ใน​ระบบ​เงียบๆ แบบ​ที่ version ดิบ​ทำ — สังเกต​ด้วย​ว่า invariant ⑧ รักษา​แค่ ความ​สัมพันธ์​ระหว่าง​ห้า​จำนวน เท่านั้น ไม่​ได้​รักษา​ว่า​แต่ละ​ก้อน​ถูก​ปัด​เป็น​สตางค์​แล้ว​หรือ​ยัง (นั่น​เป็น​หน้าที่​ของ Round() แยก​ต่างหาก) — สอง​กฎ​นี้​ตั้งใจ​แยก​จาก​กัน เพราะ​เป็น​คนละ​ความ​รับผิดชอบ

classDiagram
  class PriceBreakdown {
    +Money Subtotal
    +Money Discount
    +Money DeliveryFee
    +Money Vat
    +Money Total
  }
  class Money {
    +decimal Amount
    +string Currency
  }
  PriceBreakdown --> Money : Subtotal
  PriceBreakdown --> Money : Discount
  PriceBreakdown --> Money : DeliveryFee
  PriceBreakdown --> Money : Vat
  PriceBreakdown --> Money : Total

คำ​บรรยาย​ภาพ: PriceBreakdown ประกอบ​ด้วย Money ห้า​ก้อน (Subtotal, Discount, DeliveryFee, Vat, Total) — ทุก​ครั้ง​ที่​สร้าง guard clause จะ​เช็ก invariant ⑧: Total ต้อง​เท่ากับ Subtotal หัก​ด้วย Discount บวก DeliveryFee บวก Vat เสมอ ผิด​กฎ​เมื่อไร​โยน InvalidOperationException ทันที

สังเกต​ว่า DeliveryFee ใน​โครง​นี้​ก็​เป็น Money เหมือน​กัน ไม่ใช่ decimal ลอยๆ — แต่​บท​นี้​ยัง​ไม่​คำนวณ​ว่า​ค่า​ส่ง​ควร​เป็น​เท่าไร​กัน​แน่ (ระยะ​ทาง, โซน, โปรโมชัน​ฟรี​ค่า​ส่ง ฯลฯ) เรา​จะ​กลับ​มาสร้าง​สูตร​นั้น​แบบ​เต็มๆ ใน DeliveryFeeCalculator ที่​บท​ที่ 7 (Domain Service & Specification) ตอน​นี้​ขอ​แค่​ให้​มัน​เป็น Money ที่​ปลอดภัย​ไป​ก่อน ไม่ใช่​ตัวเลข​ลอย​ที่​ใคร​จะ​ใส่​ค่า​อะไร​ก็ได้

บท​นี้​เจาะ​ลึก​สิ่ง​ที่ Course A แนะนำ​ไว้​แบบ​เบาๆ: Money/Quantity เป็น value object เพราะ​เทียบ​กัน​ด้วย​ค่า​และ immutable ทำให้ guard clause ตอน​สร้าง​มี​ความหมาย​ตลอด​อายุ​ของ​มัน ตรง​ข้าม​กับ primitive obsession ที่​ปล่อย​ให้ decimal/string ดิบๆ แบก​กฎ​ธุรกิจ​แทน — แล้ว​เรา​ก็​เจอ​กรณี​จริง​ที่ value object เดี่ยว​ยัง​ไม่​พอ: Money ต้อง​มี Round() แยก​ต่างหาก​สำหรับ​เศษ​สตางค์ และ​ต้อง​มี PriceBreakdown มา​ประกอบ Money ห้า​ก้อน​เข้า​ด้วย​กัน​พร้อม​รักษา invariant ⑧ ใน​ตัวเอง

บท​ถัด​ไป (บท​ที่ 3) เรา​จะ​ยก​ระดับ OrderLine จาก record ที่​เทียบ​กัน​ด้วย​ค่า ให้​กลาย​เป็น entityEntityอ็อบเจ็กต์ที่​มี 'ตัวตน' (identity) ต่อ​เนื่อง​ตาม​เวลา แม้​ค่า​ภายใน​เปลี่ยน​ก็​ยัง​เป็น​สิ่ง​เดิม เทียบ​กัน​ด้วย id ไม่ใช่​ด้วย​ค่า เช่น Order, OrderLineTactical Design ตัว​จริง​ที่​เทียบ​กัน​ด้วย identity — จุด​ที่ value object กับ entity เริ่ม​มี​เส้น​แบ่ง​ชัดเจน​ขึ้น​ใน code


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

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

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

ข้อ 1 / 3

อาการของ Primitive Obsession คือข้อใด?