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

Enum, struct และ type system — ทำให้​สถานะ​ที่​ผิด​เป็น​ไป​ไม่​ได้

บท​ที่ 1 เรา​ปั้น record สาม​ตัว — Money, OrderId, Quantity — เป็น Value ObjectValue Objectอ็อบเจ็กต์ที่​นิยาม​ด้วย 'ค่า' ไม่มี identity ของ​ตัวเอง สอง​ตัว​ที่​ค่า​เท่า​กัน​ถือว่า​เป็น​สิ่ง​เดียวกัน ควร immutable เปลี่ยนแปลง​ไม่​ได้​หลัง​สร้าง — ใน C# มัก​สร้าง​ด้วย record เช่น Money, OrderIdTactical Design ก้อน​แรก​ของ domain Order บท​นี้​เรา​จะ​หยิบ​เครื่องมือ​อีก​สอง​สาม​ชิ้น​ของ type system มา​ใช้ ทุก​ชิ้น​มี​เป้าหมาย​เดียวกัน​หมด: ทำให้ “สถานะ​ที่​ผิด​กฎ” ไม่มี​ทาง​เขียน​แทน​ได้​ใน code — แนวคิด​ที่​ฝรั่ง​เรียก​ว่า make illegal states unrepresentable

เรา​จะ​เริ่ม​จาก​วิธี​จำกัด​ชุด​สถานะ​ของออเดอร์​ให้​เหลือ​เท่า​ที่​เป็น​ไป​ได้​จริง จาก​นั้น​ทำความ​เข้าใจ​ว่า​เมื่อไร​ควร​เลือก class, record หรือ struct แล้ว​ปิด​ท้าย​ด้วย​การ​ห่อ id ดิบ​ให้ compiler ช่วย​กัน bug ที่ id สอง​ชนิด “สลับ​กัน​ได้” โดย​ไม่มี​ใคร​ทัก

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

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

ตอน​เรียน pattern matching (บท​ที่ 4) เรา​แอบ​หยิบ OrderStatus มา​ใช้​ไป​แล้ว บท​นี้​เรา​จะ​มอง​มัน​ใน​ฐานะ “เครื่องมือ​ของ type system” อย่าง​เป็น​ทางการ ออเดอร์​หนึ่ง​ใบ​มี “สถานะ” ที่​เดิน​ไป​ตาม​เวลา: เพิ่ง​สั่ง, ร้าน​รับ​แล้ว, กำลัง​ทำ, ไร​เด​อร์รับ​ของ, ส่ง​ถึง​มือ ฯลฯ คำถาม​คือ​เรา​จะ​เก็บ​สถานะ​นี้​ด้วย type อะไร ถ้า​เก็บ​เป็น string ดิบ เช่น status = "preparing" เรา​พิมพ์​ผิด​ตัว​เดียว​เป็น "prepairing" compiler ก็​เงียบ​สนิท แล้ว bug ไป​โผล่​ตอน run

EnumEnumtype ที่​จำกัด​ค่า​ไว้​เป็น​ชุด​คงที่​ที่​ตั้ง​ชื่อ​ไว้​ล่วงหน้า เช่น `enum OrderStatus { Placed, Confirmed, Preparing, PickedUp, Delivered }` — วิธี​พื้นฐาน​ที่สุด​ใน​การ​แทน​สถานะ​จำกัด​จำนวน​ของ state machine ใน domainTactical Design ปิด​ช่อง​นั้น มัน​คือ type ที่​จำกัด​ค่า​ไว้​เป็น “ชุด​คงที่​ที่​ตั้ง​ชื่อ​ไว้​ล่วงหน้า” — มี​แค่​ชื่อ​ที่​เรา​ประกาศ​ไว้​เท่านั้น​ที่​เขียน​ได้ อย่าง​อื่น​เขียน​ไม่​ได้​เลย นี่​คือ make illegal states unrepresentable version ที่​ง่าย​ที่สุด:

namespace FoodOrdering.Domain.Orders;
public enum OrderStatus
{
Placed, // ลูกค้ากดสั่งแล้ว รอร้านรับ
Confirmed, // ร้านรับออเดอร์
Preparing, // กำลังทำอาหาร
PickedUp, // ไรเดอร์รับของแล้ว
Delivered, // ส่งถึงมือลูกค้า
Rejected, // ร้านปฏิเสธออเดอร์
Cancelled // ยกเลิก
}

แกะ​ที​ละ​ส่วน:

  • enum คือ keyword ที่​บอก compiler ว่า “type นี้​มี​ค่า​ได้​แค่​ใน​รายการ​ที่​ตาม​มา​เท่านั้น”
  • ใน​วงเล็บ​ปีกกา​คือ​รายชื่อ​ค่าที่​เป็น​ไป​ได้​ทั้งหมด — เจ็ด​ค่า​นี้​คือ​ชุด​สถานะ​ทั้งหมด​ของออเดอร์​ใน domain เรา ไม่มี​ค่าที่​แปด
  • เบื้องหลัง​แต่ละ​ชื่อ​มี​เลข​กำกับ​อยู่ (Placed = 0, Confirmed = 1, ไล่​ไป​เรื่อยๆ) แต่​เรา​แทบ​ไม่​ต้อง​สนใจ​ตัวเลข — เขียน​ด้วย​ชื่อ​ที่​อ่าน​ออก​ได้​เลย เช่น OrderStatus.Preparing

ประโยชน์​ทันที​คือ ถ้า​มี​ใคร​เขียน OrderStatus.Frozen compiler จะ​ฟ้อง​ทันที​ว่า​ไม่มี​ค่า​นี้ สถานะ​ที่​ไม่มี​อยู่​จริง​ใน​ธุรกิจ​จึง “เขียน​แทน​ไม่​ได้” ตั้งแต่​แรก และ​เวลา​เรา​เขียน code ตัดสิน​ใจ​ตาม​สถานะในบทหลังๆ compiler ยัง​ช่วย​เตือน​ได้​ด้วย​ว่า​เรา​ลืม​จัดการ​เคส​ไหน​ไป​บ้าง

จนถึง​ตอน​นี้​เรา​เห็น type มา​แล้ว​สาม​แบบ — class (บท​ที่ 1 ตอน​เทียบ​กับ RawMoney), record, และ​เดี๋ยว​จะ​เจอ struct ทั้ง​สาม​ใช้​สร้าง type ได้​เหมือน​กัน แต่​ต่าง​กัน​ที่​สอง​แกน: เก็บ​เป็น​ค่า​หรือ​เก็บ​เป็น reference และ เทียบ​ด้วย​ค่า​หรือ​เทียบ​ด้วย reference

ก่อน​อื่น​ต้อง​เข้าใจ​คำ​คู่​หนึ่ง:

  • value type — ตัวแปร​ถือ “ค่า” ไว้​กับ​ตัว​โดยตรง เวลา​ส่ง​ต่อ​หรือ assign จะ คัด​ลอก​ทั้ง​ก้อน เมื่อ​เป็น​ตัวแปร​ท้องถิ่น​หรือ parameter ก็​มัก​อยู่​บน stack ไม่​ต้อง allocate object แยก​บน heap เบา​และ​เร็ว int, decimal, Guid และ struct ทั้งหลาย​เป็น value type
  • reference type — ตัวแปร​ถือ​แค่ “ที่​อยู่” ที่​ชี้​ไป​ยัง​ก้อน​ข้อมูล​จริง​บน heap หลาย​ตัวแปร​ชี้​ก้อน​เดียวกัน​ได้ class และ record (แบบ​ปกติ) เป็น reference type

ทีนี้ struct คือ​วิธี​สร้าง value type ของ​เรา​เอง และ​ถ้า​เติม readonly เข้าไป​จะ​ได้ Readonly StructReadonly Structstruct ที่​ทุก field เป็น readonly ทำให้ instance เปลี่ยนแปลง​ไม่​ได้​หลัง​สร้าง — เป็น value type ที่ readonly ทั้ง​ก้อน คัด​ลอก​ด้วย​ค่า ไม่มี identity ตัว​เลือก​ที่​เบา​กว่า record สำหรับ Value Object ขนาด​เล็ก​ที่​ต้องการ performance เช่น strongly-typed IDTactical Design — struct ที่​ทุก field แก้​ไม่​ได้​หลัง​สร้าง (immutable) พอ C# 10 เพิ่ม record struct เข้า​มา เรา​ก็​รวม​ข้อดี​ของ​ทั้ง​คู่​ได้​ใน​บรรทัด​เดียว: เป็น value type ที่​เบา และ ได้การ​เทียบ​ด้วย​ค่า​มา​ฟรี​แบบ record

// readonly record struct = value type (คัดลอกด้วยค่า มักอยู่บน stack)
// + เทียบด้วยค่า + แก้ไม่ได้หลังสร้าง
public readonly record struct SeatCount(int Value);

เกร็ด​ที่​คน​มัก​เข้าใจ​ผิด: struct ไม่​ได้อยู่​บน stack เสมอ​ไป — ถ้า​มัน​เป็น field ของ class (เช่น typed ID ที่​ฝัง​อยู่​ใน Order) มัน​ก็​ไป​นั่ง​อยู่​ใน​ก้อน object นั้น​บน heap ด้วย สิ่ง​ที่ struct รับประกันจริงๆ คือ คัด​ลอก​ด้วย​ค่า ไม่มี identity ต่างหาก ไม่ใช่​ตำแหน่ง​ของ​มัน​ใน​หน่วย​ความ​จำ (ใน​คอร์ส​นี้​เรา​ใช้ record ห่อ id อยู่​แล้ว จุด​นี้​จึง​ไม่​กระทบ​อะไร)

สรุป​เป็น​ตาราง​ว่า​จะ​เลือก​อัน​ไหน​เมื่อไร โดย​โยง​กับ type จริง​ใน domain Order:

อยาก​ได้ใช้ตัวอย่าง​ใน domain
ค่าที่​ไม่มี identity เทียบ​ด้วย​ค่า แก้​ไม่​ได้​หลัง​สร้างrecord (เป็น reference type)Money, Quantity, OrderId
ตัวตน​ที่​มี identity เปลี่ยน​สถานะ​ได้​ตาม​เวลาclassOrderLine, Order
ค่า​เล็ก​มาก ใช้​ถี่ อยาก​เลี่ยง​การ allocate object แยก​บน heapstruct / record structตัว​ห่อ id ที่​เน้น performance

จุด​ที่​ต้อง​จำ: record เหมาะ​กับ Value Object (ค่า​ล้วนๆ ไม่มี​ตัวตน) ส่วน class เหมาะ​กับ​สิ่ง​ที่​มี “ตัวตน” ติดตัว เช่น OrderLine แต่ละ​บรรทัด​ใน​ตะกร้า​มี identity ของ​มัน​เอง (เป็น​บรรทัด​คนละ​บรรทัด​ถึง​แม้​จะ​สั่ง​เมนู​เดียวกัน) — เพราะ​แบบ​นี้ OrderLine จึง​เป็น class ไม่ใช่ record เรา​จะ​ปั้น OrderLine และ Order เป็น class จริงๆ ใน​บท​ถัด​ไป ตอน​นี้​แค่​รู้​ว่า “มี identity → class, เป็น​ค่า → record” ก็พอ

ทีนี้​มา​ถึง​หัวใจ​ของ​บท ใน​บท​ที่ 1 เรา​ห่อ Guid ด้วย OrderId ไป​แล้ว บท​นี้​จะ​อธิบาย​ว่า​ทำไม​มัน​สำคัญ อาการ​ที่​เรา​กำลัง​รักษา​ชื่อ​ว่า primitive obsession — การ​พึ่ง​ชนิด​พื้นฐาน (Guid, int, string) แทน​แนวคิด​ของ domain ที่​มี​ความหมาย​ของ​ตัวเอง

สมมติ​มี method ยกเลิก​สินค้า​บาง​รายการ​ใน​ออเดอร์ ถ้า​เรา​ส่ง Guid ดิบ​ไป​ทั่ว code จะ​หน้าตา​แบบ​นี้ ซึ่ง​เป็น ❌ version ดิบ ที่​ซ่อน​กับดัก​ไว้:

// ❌ version ดิบ: parameter เป็น Guid ดิบทั้งคู่
public class OrderService
{
public void CancelItem(Guid orderId, Guid productId)
{
// ...ใช้ orderId หา order, ใช้ productId หาสินค้าในนั้น...
}
}
var service = new OrderService();
var orderId = Guid.NewGuid();
var productId = Guid.NewGuid();
// เผลอสลับลำดับตอนเรียก — orderId กับ productId อยู่คนละที่
service.CancelItem(productId, orderId); // 😱 compiler เงียบสนิท!

บรรทัด​สุดท้าย​สลับ​ลำดับ argument แต่ compiler ไม่​ทัก​อะไร​เลย เพราะ​ใน​สายตา​มัน​ทั้ง​สอง​ตัว “เป็น Guid เหมือน​กัน” จะ​เอา​ตัว​ไหน​ใส่​ช่อง​ไหน​ก็​ถูก type หมด bug แบบ​นี้​เลย​หลุด​ไป​ถึง production แล้วไป​ยกเลิก​ผิด​ออเดอร์ กว่า​จะ​รู้ตัว​ก็​เสียหาย​ไป​แล้ว

Strongly-typed IDStrongly-typed IDการ​ห่อ id ดิบ (เช่น Guid หรือ int) ด้วย type เฉพาะ​ของ​มัน​เอง เช่น `OrderId`, `CustomerId` แทน​การ​ส่ง Guid เปล่า ๆ ไปมา — compiler จะ​จับ​ได้​ทันที​ถ้า​เผลอ​ส่ง CustomerId ไป​ที่ parameter ที่​ต้องการ OrderIdTactical Design คือ​ยา​แก้: แทนที่​จะ​ส่ง Guid เปล่าๆ เรา​ห่อ​มัน​ด้วย type เฉพาะ​ของ​แต่ละ​ความหมาย — OrderId, OrderLineId, ProductId แม้​ข้าง​ใน​จะ​เป็น Guid เหมือน​กัน​หมด แต่​พอ​เป็น​คนละ type มัน​ก็ สลับ​กัน​ไม่​ได้ และ compiler จับ​ได้​ตั้งแต่​ตอน compile

flowchart TB
  subgraph RAW["❌ version ดิบ: ส่ง Guid ดิบไปทั่ว"]
    R1["CancelItem(Guid orderId, Guid productId)"]
    R2["เผลอสลับลำดับ:<br/>CancelItem(productId, orderId)"]
    R3["compiler เงียบ ✅<br/>เพราะทั้งคู่เป็น Guid เหมือนกัน"]
    R4["💥 bug หลุดไป production<br/>ยกเลิกผิดออเดอร์"]
    R1 --> R2 --> R3 --> R4
  end
  subgraph TYPED["✅ typed ID: ห่อ Guid ด้วย type ของมันเอง"]
    T1["CancelItem(OrderId orderId, ProductId productId)"]
    T2["เผลอสลับลำดับ:<br/>CancelItem(productId, orderId)"]
    T3["compiler ฟ้องทันที ❌<br/>ProductId ใส่ช่องที่ต้องการ OrderId ไม่ได้"]
    T4["🛡️ bug ถูกจับตั้งแต่ compile<br/>ไม่มีทางหลุดไป production"]
    T1 --> T2 --> T3 --> T4
  end

คำ​บรรยาย​ภาพ: เส้นทาง​บน (Guid ดิบ) พื้น​ผิว bug กว้าง — สลับ​ลำดับ argument แล้ว compiler ยัง​ปล่อย​ผ่าน​เพราะ​ทุก​ช่อง​เป็น Guid เหมือน​กัน bug เลย​ไหล​ลง​ไป​ถึง production ส่วน​เส้นทาง​ล่าง (typed ID) พอ OrderId กับ ProductId เป็น​คนละ type การ​สลับ​ลำดับ​กลาย​เป็น compile error ทันที bug ทั้ง class นี้​ถูก​ปิดตาย​ตั้งแต่​ก่อน​โปรแกรม​จะ​รัน​ด้วย​ซ้ำ

code ของ​ยา​แก้​นั้น​สั้น​มาก แค่​ห่อ Guid เป็น record ตัว​ละ​บรรทัด (pattern เดียว​กับ OrderId ที่​เรา​เจอ​ใน​บท​ที่ 1):

namespace FoodOrdering.Domain.Orders;
public record OrderId(Guid Value);
public record OrderLineId(Guid Value);
public record ProductId(Guid Value);

สาม​บรรทัด​นี้​ทำให้ OrderId, OrderLineId, ProductId เป็น​คนละ type กัน​โดย​สมบูรณ์ ถึง​ข้าง​ใน​จะ​ถือ Guid เหมือน​กัน เขียน method ใหม่​เป็น CancelItem(OrderId orderId, ProductId productId) แล้ว​ลอง​สลับ​ลำดับ​ดู compiler จะ​ฟ้อง​ว่า “เอา ProductId มา​ใส่​ช่อง​ที่​ต้องการ OrderId ไม่​ได้” — bug ที่​เมื่อกี้​เงียบ​สนิท​กลาย​เป็น error ที่​หน้า​จอ​ทันที

อยาก​ได้ id ที่​เบา​ที่สุด? ใช้ readonly record struct

id พวก​นี้​เป็น value ตัว​จิ๋ว​ที่​ถูก​สร้าง​และ​ส่ง​ต่อ​บ่อย​มาก ถ้า​ต้องการ​รีด​ให้​เบา​ที่สุด เลี่ยง​การ allocate object แยก​บน heap เขียน​เป็น value type ได้​ด้วย public readonly record struct OrderId(Guid Value); — ได้การ​เทียบ​ด้วย​ค่า​และ immutability เหมือน record ทุก​อย่าง เพียง​แต่​เป็น value type ใน​คอร์ส​นี้​เรา​ใช้ record ธรรมดา​เพื่อ​ความ​สม่ำเสมอ​กับ Money/Quantity จาก​บท​ที่ 1 แต่​รู้​ไว้​ว่า readonly record struct คือ​ทาง​เลือก​ที่​เบา​กว่า​เมื่อ​ต้อง​เน้น performance จริงๆ

สังเกต​ว่า enum กับ strongly-typed ID เป็น​เรื่อง​เดียวกัน​เมื่อ​มอง​จาก​ที่​สูง: ทั้ง​คู่​คือ​การ​ออกแบบ type ให้ แสดง​ได้​เฉพาะ​ค่าที่​ถูกต้อง — enum ปิด​สถานะ​ที่​ไม่มี​อยู่​จริง, typed ID ปิด​การ​เอา id ผิด​ชนิด​มาสลับ​กัน นี่​คือ​หัวใจ​ของ​ทั้ง​บท: ยก​ภาระ​การ​ตรวจสอบ​จาก “ความ​ระวัง​ของ​คน​เขียน” ขึ้น​ไป​ให้ compiler รับผิดชอบ​แทน

บท​นี้​เพิ่ม​เครื่องมือ type system สาม​ชิ้น — enum จำกัด​ชุด​สถานะ, การ​เลือก class/record/struct ให้​ถูก​งาน, และ strongly-typed ID กัน bug id สลับ​กัน — ทั้งหมด​รับใช้​เป้าหมาย​เดียว: ทำให้​สถานะ​ที่​ผิด​เป็น​ไป​ไม่​ได้​ตั้งแต่​ระดับ type

typed ID ทั้ง​สาม​ตัว​นี้​จะ​ถูก​หยิบ​ไป​ใช้​ต่อ​ทันที​ใน​บท​หน้า:

  • บท​ถัด​ไป — เอา OrderLine (class ที่​มี OrderLineId) หลายๆ บรรทัด​มา​ประกอบ​เป็น Order หนึ่ง​ใบ ด้วย backing field และ LINQ พร้อม​รักษา​กฎ​ว่า​ตะกร้า​ห้าม​ว่าง
  • บทถัดๆ ไป — interface กับ generics สำหรับ base class Entity<TId>, repository และ port (เช่น IPaymentGateway), แล้ว​ปิด​ท้าย​ด้วย async/await เมื่อ domain ต้อง​คุย​กับ​โลก​ภายนอก

ส่วน​แนวคิด “make illegal states unrepresentable” ใน​เชิง​ลึก (การ​ออกแบบ type ให้​แทน​ได้​เฉพาะ​สถานะ​ที่​ถูก​กฎ ทั้ง sum type และ state machine) เก็บ​ไว้​เจาะ​ที่​คอร์ส DDD in Code — คอร์ส​นี้​โฟกัส​ที่ “ไวยากรณ์ C# ที่​ทำให้​มัน​เกิด​ขึ้น​ได้” ก่อน


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

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

  • Make Illegal States Unrepresentable — หลักการ​ที่​อยู่​เบื้องหลัง​ทั้ง​บท: ออกแบบ type ให้​สถานะ​ที่​ผิด​กฎ ไม่มี​ทาง​เขียน​แทน​ได้​ใน code เลย ตั้งแต่ enum จนถึง typed ID
  • Primitive Obsession — code smell ที่ strongly-typed ID มา​แก้: การ​พึ่ง Guid/int/string ดิบ แทน​แนวคิด domain ที่​มี​กฎ​ของ​ตัวเอง

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

ข้อ 1 / 3

strongly-typed ID (เช่น การห่อ Guid เป็น OrderId, ProductId) ช่วยกัน bug อะไร?