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 เต็มของบทนี้อยู่ที่ repo kaen-food-ordering (กำลังจัดทำ) — path: FoodOrdering.Domain/Orders/
ทบทวน Money และ Quantity จาก Course A
หัวข้อที่มีชื่อว่า “ทบทวน Money และ Quantity จาก Course A”ทวนของเดิมให้ครบก่อน เพราะบทนี้จะต่อยอดจากมันตรงๆ 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 เดี่ยวๆ ยังไม่พอ — ต้องปัดเศษให้ถูกและประกอบเข้าด้วยกันเป็นโครงสร้างใหญ่ขึ้น
Primitive Obsession — version ดิบที่ compile ผ่านแต่พังเงียบๆ
หัวข้อที่มีชื่อว่า “Primitive Obsession — version ดิบที่ compile ผ่านแต่พังเงียบๆ”ลองจินตนาการว่าเราไม่เคยรู้จัก Money/Quantity มาก่อน แล้วอยากเก็บยอดรวมของออเดอร์ — สัญชาตญาณทั่วไปมักไปจบที่ 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 เทียบกันด้วยค่า ไม่ใช่ reference
หัวข้อที่มีชื่อว่า “Value Object เทียบกันด้วยค่า ไม่ใช่ reference”จุดที่ทำให้ value object ต่างจาก object ทั่วไปมีสองข้อที่มาคู่กันเสมอ:
ความเท่ากันแบบ value equality — Money.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 ที่ต้องคอยเช็กเองทุกจุดที่ใช้งาน (แล้วก็มีจุดที่ลืมเช็กแน่ๆ สักจุดหนึ่ง)
ปัดเศษสตางค์ และประกอบเป็น PriceBreakdown
หัวข้อที่มีชื่อว่า “ปัดเศษสตางค์ และประกอบเป็น PriceBreakdown”นี่คือจุดที่ 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:
- Value Object — นิยามและคุณสมบัติของ value object โดยตรง
- Primitive Obsession — code smell ที่บทนี้ใช้เป็นจุดตั้งต้นของ naive version
- Value Objects (คอร์ส DDD Patterns บทที่ 15) — ฉบับเต็มของ value object equality, immutability และการ persist
เช็กความเข้าใจ — บทที่ 2
ข้อ 1 / 3อาการของ Primitive Obsession คือข้อใด?