record ที่กลายเป็น Value Object
คอร์สนี้คือจุดเริ่มต้นที่แนะนำของสาย .NET/DDD ทั้งเส้น — เราจะค่อยๆ ปั้น domain Order ของ app สั่งอาหารขึ้นมาตั้งแต่ศูนย์ และเรียนไวยากรณ์ C# สมัยใหม่ไปพร้อมกัน สมมติว่าคุณเพิ่งเจอ C# เป็นครั้งแรก — ไม่เป็นไร เราจะอธิบายทุกบรรทัดที่เขียน ไม่ข้ามหัว
บทแรกนี้เริ่มที่ก้อนอิฐก้อนเล็กที่สุดของ domain: จำนวนเงิน, เลขออเดอร์, จำนวนชิ้น สิ่งเล็กๆ พวกนี้ใน code ที่เขียนไม่ดีมักเป็น decimal, Guid, int ลอยๆ กระจายเต็มไปหมด แต่ใน C# สมัยใหม่เรามีเครื่องมือที่ทำให้มันกลายเป็น type ที่ปลอดภัยและสื่อความหมายได้ในบรรทัดเดียว — เครื่องมือนั้นชื่อ record
code เต็มของคอร์สนี้อยู่ที่ repo kaen-food-ordering (กำลังจัดทำ) — บทนี้เกี่ยวข้องกับ path FoodOrdering.Domain/Orders/
record คืออะไร
หัวข้อที่มีชื่อว่า “record คืออะไร”RecordRecordtype พิเศษของ C# (ตั้งแต่ C# 9) ที่ออกแบบมาสำหรับเก็บ 'ค่า' ไม่ใช่ 'ตัวตน' — เทียบความเท่ากันด้วยค่าในทุก field ให้อัตโนมัติ ไม่ต้องเขียน Equals/GetHashCode เอง เหมาะเป็นฐานของ Value Object เช่น `record Money(decimal Amount, string Currency)`Tactical Design คือ type ชนิดหนึ่งของ C# (มีมาตั้งแต่ C# 9) ที่ออกแบบมาสำหรับเก็บ “ค่า” โดยเฉพาะ ลองดูตัวอย่างที่สั้นที่สุด:
public record Money(decimal Amount, string Currency);บรรทัดเดียวจบ แต่มีอะไรซ่อนอยู่เยอะมาก มาแกะทีละส่วน:
publicคือ access modifier บอกว่า type นี้ให้ code ที่อื่นเรียกใช้ได้ (ตรงข้ามกับprivateที่ซ่อนไว้ใช้ภายใน)recordคือ keyword ที่บอก compiler ว่า “นี่คือ type สำหรับเก็บค่า”Moneyคือชื่อ type(decimal Amount, string Currency)ส่วนในวงเล็บนี้เรียกว่า positional parameter — มันบอกว่า Money หนึ่งก้อนประกอบด้วยสองค่า:Amountเป็นชนิดdecimal(เลขทศนิยมที่แม่นยำ เหมาะกับเงิน) และCurrencyเป็นชนิดstring(ข้อความ เช่น"THB")
จากบรรทัดเดียวนี้ compiler สร้างสิ่งเหล่านี้ให้เราฟรีๆ โดยไม่ต้องเขียนเอง:
- constructor — เราสร้าง Money ได้ทันทีด้วย
new Money(20, "THB") - property แบบอ่านอย่างเดียว — เข้าถึงค่าได้ด้วย
money.Amountและmoney.Currencyแต่ แก้ไม่ได้ หลังสร้าง - การเทียบความเท่ากันด้วยค่า — 2 Money ที่มีค่าเท่ากันทุก field ถือว่า “เท่ากัน”
ToString()ที่อ่านง่าย — พิมพ์ออกมาเป็นMoney { Amount = 20, Currency = THB }ให้ debug ง่าย
สามคุณสมบัติที่ต้องจำจากบทนี้คือ positional parameter (ประกาศ field ในวงเล็บบนหัว), value equality (เทียบด้วยค่า) และ immutable โดยปริยาย (เปลี่ยนค่าไม่ได้หลังสร้าง)
ทำไม record ถึงเป็น Value Object โดยธรรมชาติ
หัวข้อที่มีชื่อว่า “ทำไม record ถึงเป็น Value Object โดยธรรมชาติ”ทีนี้มาถึงหัวใจของบทนี้ Value ObjectValue Objectอ็อบเจ็กต์ที่นิยามด้วย 'ค่า' ไม่มี identity ของตัวเอง สองตัวที่ค่าเท่ากันถือว่าเป็นสิ่งเดียวกัน ควร immutable เปลี่ยนแปลงไม่ได้หลังสร้าง — ใน C# มักสร้างด้วย record เช่น Money, OrderIdTactical Design ในภาษา DDD คืออ็อบเจ็กต์ที่นิยามด้วย “ค่า” ล้วนๆ ไม่มี identity เป็นของตัวเอง กฎของมันมีสามข้อ:
- ไม่มี identity — เราไม่สนใจว่า Money ก้อนนี้เป็น “ก้อนไหน” สนแค่ว่ามัน “เท่าไร”
- เทียบด้วยค่า — 20 บาท ก็คือ 20 บาท ไม่ว่าจะมาจากออเดอร์ไหน
- สลับกันได้ถ้าค่าเท่ากัน — ธนบัตร 20 บาทสองใบ ใช้แทนกันได้สนิท เพราะค่ามันเท่ากัน
สังเกตไหมว่ากฎสามข้อนี้ตรงกับคุณสมบัติของ record เป๊ะๆ record เทียบด้วยค่า → Value Object ก็เทียบด้วยค่า record แก้ไม่ได้หลังสร้าง → Value Object ก็ควร immutable นี่คือเหตุผลที่ record IS a Value Object — ในโลก C# สมัยใหม่ วิธีมาตรฐานที่สุดในการสร้าง Value Object คือเขียนมันเป็น record
flowchart TB
subgraph REC["record Money — เทียบด้วยค่า"]
A["a = Money(20, THB)"]
B["b = Money(20, THB)"]
A --> EQ["a เท่ากับ b ✅<br/>เพราะค่าทุก field ตรงกัน"]
B --> EQ
end
subgraph CLS["class RawMoney — เทียบด้วย reference"]
C["x = new RawMoney(20, THB)"]
D["y = new RawMoney(20, THB)"]
C --> NE["x ไม่เท่ากับ y ❌<br/>เพราะเป็นคนละก้อนในหน่วยความจำ"]
D --> NE
end
คำบรรยายภาพ: สองก้อนบน (record) มีค่าเหมือนกันทุก field C# จึงถือว่า เท่ากัน — นี่คือ value equality ที่ Value Object ต้องการ ส่วนสองก้อนล่าง (class ธรรมดา) แม้ใส่ค่า 20 THB เท่ากัน แต่ C# กลับถือว่า ไม่เท่ากัน เพราะ class เทียบด้วย reference คือถามว่า “เป็นก้อนเดียวกันในหน่วยความจำไหม” ไม่ใช่ “ค่าเท่ากันไหม” นี่คือเหตุผลที่เราไม่ใช้ class ธรรมดาทำ Value Object
เทียบกับ class ธรรมดา — version ดิบ
หัวข้อที่มีชื่อว่า “เทียบกับ class ธรรมดา — version ดิบ”ก่อน record จะมีในภาษา ถ้าจะทำ Money เราต้องเขียน class ธรรมดาแบบนี้ ซึ่งเป็น ❌ version ดิบ ที่เรากำลังจะเลิกใช้:
// ❌ version ดิบ: class ที่ field แก้ได้ และเทียบด้วย referencepublic class RawMoney{ public decimal Amount; // field แบบเปิด ใครก็แก้ได้ public string Currency;
public RawMoney(decimal amount, string currency) { Amount = amount; Currency = currency; }}version ดิบนี้มีปัญหาสองข้อใหญ่ๆ:
var x = new RawMoney(20, "THB");var y = new RawMoney(20, "THB");
bool same = x == y; // false! ทั้งที่ค่าเหมือนกันเป๊ะx.Amount = -999; // แก้ได้เฉย ๆ ตอนไหนก็ได้ ไม่มีใครห้าม- เทียบด้วยค่าไม่ได้ —
x == yได้falseเพราะ class เทียบด้วย reference (คนละก้อนในหน่วยความจำ) ทั้งที่ในเชิงธุรกิจ 20 บาทสองก้อนนี้ควรเท่ากัน - แก้ได้ตลอดเวลา —
x.Amount = -999ทำได้ทันที ไม่มีอะไรกัน เงินก้อนหนึ่งเปลี่ยนค่ากลางทางได้ ความน่าเชื่อถือหายหมด
เขียนใหม่ด้วย record บรรทัดเดียว ปัญหาทั้งสองข้อหายไปเอง: Money(20, "THB") == Money(20, "THB") เป็น true และไม่มีใครแก้ Amount ได้หลังสร้าง เพราะ record ทำ property เป็นแบบอ่านอย่างเดียวให้แล้ว
ปั้น Money ตัวจริงพร้อม guard และ factory
หัวข้อที่มีชื่อว่า “ปั้น Money ตัวจริงพร้อม guard และ factory”record เปล่าๆ ยังขาดอย่างหนึ่ง: มันยอมให้สร้าง Money(-50, "THB") ได้ ทั้งที่เงินติดลบไม่ควรมีอยู่ใน domain ของเรา เราจึงต้องเพิ่ม Guard ClauseGuard Clauseเงื่อนไขตรวจสอบข้อมูลไว้ต้น ๆ ของ constructor หรือ method แล้วโยน exception ทันทีถ้าไม่ผ่าน เช่น `if (amount < 0) throw new ArgumentException(...)` — วิธีที่ Value Object และ Entity ใช้รักษา invariant ของตัวเองตั้งแต่วินาทีที่สร้าง ไม่ปล่อยให้อ็อบเจ็กต์ผิดรูปหลุดออกไปTactical Design — เงื่อนไขที่ตรวจข้อมูลตั้งแต่ตอนสร้าง แล้วโยน exception ทันทีถ้าไม่ผ่าน เพื่อรักษากฎ “จำนวนเงินติดลบไม่ได้” ไม่ให้ Money ผิดรูปหลุดออกไปได้เลย
namespace FoodOrdering.Domain.Orders;
public record Money(decimal Amount, string Currency){ // guard: รักษากฎว่าจำนวนเงินติดลบไม่ได้ ตั้งแต่วินาทีที่สร้าง public decimal Amount { get; } = Amount >= 0 ? Amount : throw new ArgumentException("จำนวนเงินติดลบไม่ได้", nameof(Amount));
// factory: สร้าง Money สกุลบาทได้สั้น ๆ โดยไม่ต้องพิมพ์ "THB" ทุกครั้ง public static Money Thb(decimal amount) => new(amount, "THB");}มีของใหม่สองอย่าง อธิบายทีละส่วน:
namespace FoodOrdering.Domain.Orders;คือการบอกว่า file นี้อยู่ใน “folder เชิงตรรกะ” ชื่อFoodOrdering.Domain.Orders— ทุก type ของ domainOrderจะอยู่ใน namespace นี้ ช่วยจัดกลุ่ม code และกันชื่อชนกับ type อื่น- guard ในบรรทัด
public decimal Amount { get; } = ...— ตรงนี้เราเขียน propertyAmountทับตัวที่ record สร้างให้โดยอัตโนมัติ แล้วใส่ค่าเริ่มต้นเป็นนิพจน์เงื่อนไข:Amount >= 0 ? Amount : throw ...อ่านว่า “ถ้าค่าที่รับเข้ามามากกว่าหรือเท่ากับ 0 ก็ใช้ค่านั้น ไม่งั้นโยน exception ทิ้ง” เครื่องหมาย? :คือ ternary operator เขียน if/else สั้นๆ ในบรรทัดเดียว ผลคือไม่มีทางสร้างMoneyที่ติดลบได้เลย - factory
Money.Thb(...)— คำว่าstaticแปลว่า method นี้เรียกจากตัว type ตรงๆ ได้เลย ไม่ต้องมี Money ก้อนไหนอยู่ก่อน เขียนMoney.Thb(20)แทนnew Money(20, "THB")สั้นกว่าและอ่านรู้เรื่องกว่า เครื่องหมาย=>คือ expression body ย่อ method ที่มีบรรทัดเดียวให้กระชับ ส่วนnew(amount, "THB")คือการสร้าง Money โดยไม่ต้องพิมพ์ชื่อ type ซ้ำเพราะ compiler เดาได้จากบริบท
ทีนี้เราสร้าง Money ได้สองแบบ ปลอดภัยทั้งคู่:
var price = Money.Thb(120); // 120 บาทvar free = new Money(0, "THB"); // 0 บาท ก็ยังถือว่าถูกกฎ// var bad = Money.Thb(-5); // 💥 โยน ArgumentException ทันทีMoney ที่เราปั้นในบทนี้ไม่ใช่แค่ตัวอย่างสอนไวยากรณ์ — มันคือ Value Object ตัวเดียวกันที่คอร์ส Clean Architecture .NET และ DDD in Code จะหยิบไปใช้ต่อทั้งดุ้น ปั้นให้แน่นตั้งแต่ตอนนี้ แล้วบทหลังๆ จะสบาย
OrderId และ Quantity — record อีกสองตัวของ domain
หัวข้อที่มีชื่อว่า “OrderId และ Quantity — record อีกสองตัวของ domain”พอเข้าใจ record แล้ว Value Object ตัวอื่นก็ปั้นด้วย pattern เดิม OrderId ห่อ Guid (ตัวเลขสุ่มที่ไม่ซ้ำกันทั้งโลก ใช้เป็นเลขประจำออเดอร์) และ Quantity ห่อ int (จำนวนเต็ม) พร้อม guard ว่าต้องอย่างน้อย 1 ชิ้น:
namespace FoodOrdering.Domain.Orders;
public record OrderId(Guid Value);
public record Quantity(int Value){ // guard: รักษากฎว่าสั่งอย่างน้อย 1 ชิ้นเสมอ public int Value { get; } = Value >= 1 ? Value : throw new ArgumentException("จำนวนต้องอย่างน้อย 1 ชิ้น", nameof(Value));}การห่อ Guid ด้วย type ชื่อ OrderId แทนที่จะส่ง Guid เปล่าๆ ไปมา ทำให้ compiler จับได้ทันทีถ้าเราเผลอเอาเลขลูกค้าไปใส่ช่องที่ต้องการเลขออเดอร์ — สองอย่างนี้เป็น Guid เหมือนกันก็จริง แต่พอมี type คนละตัว มันสลับกันไม่ได้ ความผิดพลาดแบบนี้เลยถูกจับตั้งแต่ compile ไม่ใช่ตอน production
ทั้งสามตัว — Money, OrderId, Quantity — คือ record ที่เป็น Value Object ทั้งหมด: ไม่มี identity, เทียบด้วยค่า, แก้ไม่ได้หลังสร้าง และรักษา invariant ของตัวเองด้วย guard ตั้งแต่จุดสร้าง นี่คือฐานรากของ domain Order ที่บทต่อๆ ไปจะต่อยอดขึ้นไป
สิ่งที่จะทำต่อ
หัวข้อที่มีชื่อว่า “สิ่งที่จะทำต่อ”บทนี้วางก้อนอิฐก้อนแรก — record ที่กลายเป็น Value Object จากนี้เราจะไล่เรียนไวยากรณ์ C# สมัยใหม่ตัวอื่นๆ โดยปั้น domain Order ต่อไปเรื่อยๆ:
- บทที่ 2 —
init,requiredและwithexpression: ทำให้ Value Object immutable แบบเต็มรูปและ “แก้” ค่าด้วยการสร้างก้อนใหม่ - บทถัดๆ ไป — pattern matching ขับเคลื่อนสถานะของ
Order, nullable reference types กลายเป็น guard clause, และ async/await กลายเป็นรูปร่างของ port
ส่วนการเจาะลึกว่า Value Object ในเชิง DDD ออกแบบยังไงให้ลึกกว่านี้ (behavior, การรวมค่า, การเปรียบเทียบเชิง domain) เก็บไว้ที่คอร์ส DDD in Code — คอร์สนี้โฟกัสที่ “ไวยากรณ์ C# ที่ทำให้มันเกิดขึ้นได้” ก่อน
เจาะลึกแนวคิดในบทนี้ต่อได้ที่คลังอ้างอิง DevIQ:
- Value Object — นิยามเต็มของ Value Object ในเชิง DDD: ทำไม equality ไม่ผูกกับ identity และบทบาทของมันในการห่อ primitive กระจัดกระจายให้เป็น type ที่สื่อความหมาย
- Value Objects — เจาะลึกเชิง domain (คอร์ส DDD in Code บทที่ 2) — ต่อยอดจาก record ตัวนี้ไปสู่การออกแบบ Value Object ที่มี behavior เต็มรูป
เช็กความเข้าใจ — บทที่ 1
ข้อ 1 / 3เขียน `public record Money(decimal Amount, string Currency);` บรรทัดเดียว compiler สร้างอะไรให้เราบ้าง?