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 เต็มของคอร์สนี้อยู่ที่ repo kaen-food-ordering (กำลังจัดทำ) — บทนี้เกี่ยวข้องกับ path FoodOrdering.Domain/Orders/
enum — จำกัดชุดสถานะของออเดอร์
หัวข้อที่มีชื่อว่า “enum — จำกัดชุดสถานะของออเดอร์”ตอนเรียน 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 ยังช่วยเตือนได้ด้วยว่าเราลืมจัดการเคสไหนไปบ้าง
class, record หรือ struct — เลือกใช้เมื่อไร
หัวข้อที่มีชื่อว่า “class, record หรือ struct — เลือกใช้เมื่อไร”จนถึงตอนนี้เราเห็น 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 เปลี่ยนสถานะได้ตามเวลา | class | OrderLine, Order |
| ค่าเล็กมาก ใช้ถี่ อยากเลี่ยงการ allocate object แยกบน heap | struct / record struct | ตัวห่อ id ที่เน้น performance |
จุดที่ต้องจำ: record เหมาะกับ Value Object (ค่าล้วนๆ ไม่มีตัวตน) ส่วน class เหมาะกับสิ่งที่มี “ตัวตน” ติดตัว เช่น OrderLine แต่ละบรรทัดในตะกร้ามี identity ของมันเอง (เป็นบรรทัดคนละบรรทัดถึงแม้จะสั่งเมนูเดียวกัน) — เพราะแบบนี้ OrderLine จึงเป็น class ไม่ใช่ record เราจะปั้น OrderLine และ Order เป็น class จริงๆ ในบทถัดไป ตอนนี้แค่รู้ว่า “มี identity → class, เป็นค่า → record” ก็พอ
primitive obsession — เมื่อ Guid สลับกันได้
หัวข้อที่มีชื่อว่า “primitive obsession — เมื่อ Guid สลับกันได้”ทีนี้มาถึงหัวใจของบท ในบทที่ 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 นี้ถูกปิดตายตั้งแต่ก่อนโปรแกรมจะรันด้วยซ้ำ
strongly-typed ID — ยาแก้ที่ต้นทาง
หัวข้อที่มีชื่อว่า “strongly-typed ID — ยาแก้ที่ต้นทาง”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 พวกนี้เป็น 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:
- Make Illegal States Unrepresentable — หลักการที่อยู่เบื้องหลังทั้งบท: ออกแบบ type ให้สถานะที่ผิดกฎ ไม่มีทางเขียนแทนได้ใน code เลย ตั้งแต่ enum จนถึง typed ID
- Primitive Obsession — code smell ที่ strongly-typed ID มาแก้: การพึ่ง
Guid/int/stringดิบ แทนแนวคิด domain ที่มีกฎของตัวเอง
เช็กความเข้าใจ — บทที่ 5
ข้อ 1 / 3strongly-typed ID (เช่น การห่อ Guid เป็น OrderId, ProductId) ช่วยกัน bug อะไร?