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

record ที่​กลาย​เป็น Value Object

คอร์ส​นี้​คือ​จุด​เริ่มต้น​ที่​แนะนำ​ของ​สาย .NET/DDD ทั้ง​เส้น — เรา​จะ​ค่อยๆ ปั้น domain Order ของ app สั่ง​อาหาร​ขึ้น​มา​ตั้งแต่​ศูนย์ และ​เรียน​ไวยากรณ์ C# สมัย​ใหม่​ไป​พร้อม​กัน สมมติ​ว่า​คุณ​เพิ่ง​เจอ C# เป็น​ครั้ง​แรก — ไม่​เป็นไร เรา​จะ​อธิบาย​ทุก​บรรทัด​ที่​เขียน ไม่​ข้าม​หัว

บท​แรก​นี้​เริ่ม​ที่​ก้อน​อิฐ​ก้อน​เล็ก​ที่สุด​ของ domain: จำนวน​เงิน, เลข​ออเดอร์, จำนวน​ชิ้น สิ่ง​เล็กๆ พวก​นี้​ใน code ที่​เขียน​ไม่​ดี​มัก​เป็น decimal, Guid, int ลอยๆ กระจาย​เต็ม​ไป​หมด แต่​ใน C# สมัย​ใหม่​เรา​มี​เครื่องมือ​ที่​ทำให้​มัน​กลาย​เป็น type ที่​ปลอดภัย​และ​สื่อ​ความหมาย​ได้​ใน​บรรทัด​เดียว — เครื่องมือ​นั้น​ชื่อ record

📦 code ตัวอย่าง (กำลัง​จัด​ทำ)

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

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 สร้าง​สิ่ง​เหล่า​นี้​ให้​เราฟรีๆ โดย​ไม่​ต้อง​เขียน​เอง:

  1. constructor — เรา​สร้าง Money ได้​ทันที​ด้วย new Money(20, "THB")
  2. property แบบ​อ่าน​อย่าง​เดียว — เข้าถึง​ค่า​ได้​ด้วย money.Amount และ money.Currency แต่ แก้​ไม่​ได้ หลัง​สร้าง
  3. การ​เทียบ​ความ​เท่า​กัน​ด้วย​ค่า — 2 Money ที่​มี​ค่า​เท่า​กัน​ทุก field ถือว่า “เท่า​กัน”
  4. ToString() ที่​อ่าน​ง่าย — พิมพ์​ออก​มา​เป็น Money { Amount = 20, Currency = THB } ให้ debug ง่าย

สาม​คุณสมบัติ​ที่​ต้อง​จำ​จาก​บท​นี้​คือ positional parameter (ประกาศ field ใน​วงเล็บ​บน​หัว), value equality (เทียบ​ด้วย​ค่า) และ immutable โดย​ปริยาย (เปลี่ยน​ค่า​ไม่​ได้​หลัง​สร้าง)

ทีนี้​มา​ถึง​หัวใจ​ของ​บท​นี้ 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

ก่อน record จะ​มี​ใน​ภาษา ถ้า​จะ​ทำ Money เรา​ต้อง​เขียน class ธรรมดา​แบบ​นี้ ซึ่ง​เป็น ❌ version ดิบ ที่​เรา​กำลัง​จะ​เลิก​ใช้:

// ❌ version ดิบ: class ที่ field แก้ได้ และเทียบด้วย reference
public 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 เป็น​แบบ​อ่าน​อย่าง​เดียว​ให้​แล้ว

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 ของ domain Order จะ​อยู่​ใน namespace นี้ ช่วย​จัด​กลุ่ม code และ​กัน​ชื่อ​ชน​กับ type อื่น
  • guard ใน​บรรทัด public decimal Amount { get; } = ... — ตรง​นี้​เรา​เขียน property Amount ทับ​ตัว​ที่ 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 ก้อน​นี้​จะ​ตาม​เรา​ไป​ทั้ง​สาย

Money ที่​เรา​ปั้น​ใน​บท​นี้​ไม่ใช่​แค่​ตัวอย่าง​สอน​ไวยากรณ์ — มัน​คือ Value Object ตัว​เดียวกัน​ที่​คอร์ส Clean Architecture .NET และ DDD in Code จะ​หยิบ​ไป​ใช้​ต่อ​ทั้งดุ้น ปั้น​ให้​แน่น​ตั้งแต่​ตอน​นี้ แล้วบทหลังๆ จะ​สบาย

พอ​เข้าใจ 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 ต่อ​ไป​เรื่อยๆ:

  • บท​ที่ 2init, required และ with expression: ทำให้ Value Object immutable แบบ​เต็ม​รูป​และ “แก้” ค่า​ด้วย​การ​สร้าง​ก้อน​ใหม่
  • บทถัดๆ ไป — pattern matching ขับ​เคลื่อน​สถานะ​ของ Order, nullable reference types กลาย​เป็น guard clause, และ async/await กลาย​เป็น​รูปร่าง​ของ port

ส่วน​การ​เจาะ​ลึก​ว่า Value Object ใน​เชิง DDD ออกแบบ​ยังไง​ให้​ลึก​กว่า​นี้ (behavior, การ​รวม​ค่า, การ​เปรียบเทียบ​เชิง domain) เก็บ​ไว้​ที่​คอร์ส DDD in Code — คอร์ส​นี้​โฟกัส​ที่ “ไวยากรณ์ C# ที่​ทำให้​มัน​เกิด​ขึ้น​ได้” ก่อน


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

เจาะ​ลึก​แนวคิด​ใน​บท​นี้​ต่อ​ได้ที่​คลัง​อ้างอิง 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 สร้างอะไรให้เราบ้าง?