Pattern matching — สถานะและการตัดสินใจโดยไม่ต้อง if ซ้อน
สามบทที่แล้วเราปั้นก้อนอิฐที่ “อยู่นิ่ง” ของ domain Order มาแล้ว — Money, OrderId, Quantity ล้วนเป็น Value ObjectValue Objectอ็อบเจ็กต์ที่นิยามด้วย 'ค่า' ไม่มี identity ของตัวเอง สองตัวที่ค่าเท่ากันถือว่าเป็นสิ่งเดียวกัน ควร immutable เปลี่ยนแปลงไม่ได้หลังสร้าง — ใน C# มักสร้างด้วย record เช่น Money, OrderIdTactical Design ที่ค่ามันคงที่ตั้งแต่วินาทีที่สร้าง แต่ตัว Order เองไม่ได้อยู่นิ่ง มันมี “ชีวิต”: ลูกค้ากดสั่ง ร้านรับ ครัวเริ่มทำ ไรเดอร์มารับ แล้วส่งถึงบ้าน ทุกก้าวคือการเปลี่ยน สถานะ และแทบทุกการตัดสินใจใน app (“ยกเลิกได้ไหม” “คืนเงินเท่าไร”) ล้วนขึ้นอยู่กับว่า ณ ตอนนี้ออเดอร์อยู่สถานะไหน
พอ logic ผูกกับสถานะแบบนี้ code ที่เขียนไม่ดีจะกลายเป็น if ซ้อน if ซ้อน if เป็นพีระมิดที่อ่านไม่ออก บทนี้เราจะเรียนเครื่องมือสองชิ้นของ C# สมัยใหม่ที่ทำให้เรื่องนี้กลายเป็น “ตาราง” ที่อ่านง่าย — Pattern MatchingPattern Matchingไวยากรณ์ที่เช็ค 'รูปร่าง' ของค่าและดึงส่วนย่อยออกมาใช้ในขั้นตอนเดียว เช่น `is OrderStatus.Placed` หรือจับคู่ type/ค่าใน switch — เครื่องมือหลักในการเขียนกฎเปลี่ยนสถานะของ Order ให้ครบทุกกรณีอย่างปลอดภัยTactical Design (การเช็ก “รูปร่าง” ของค่า) และ Switch ExpressionSwitch Expressionรูปแบบ `switch` ที่ 'คืนค่า' ได้โดยตรง ต่างจาก switch statement แบบเดิมที่สั่งงานเป็นคำสั่ง กระชับกว่าและ compiler ช่วยเตือนถ้าลืมบางเคส — ใช้เขียนการเปลี่ยนสถานะของ Order ให้ครบทุก branch เช่น `state switch { Placed => ..., Confirmed => ... }`Tactical Design (switch ที่คืนค่าได้โดยตรง)
code เต็มของคอร์สนี้อยู่ที่ repo kaen-food-ordering (กำลังจัดทำ) — บทนี้เกี่ยวข้องกับ path FoodOrdering.Domain/Orders/
OrderStatus — 7 สถานะของออเดอร์
หัวข้อที่มีชื่อว่า “OrderStatus — 7 สถานะของออเดอร์”ก่อนอื่นเราต้องมี type ที่แทน “สถานะที่เป็นไปได้” ของออเดอร์ ใน domain ของเรามีอยู่ 7 สถานะ ไม่มากไปกว่านี้ ไม่น้อยไปกว่านี้ เมื่อค่าที่เป็นไปได้ถูกจำกัดเป็นชุดคงที่แบบนี้ เครื่องมือที่เหมาะที่สุดของ C# คือ EnumEnumtype ที่จำกัดค่าไว้เป็นชุดคงที่ที่ตั้งชื่อไว้ล่วงหน้า เช่น `enum OrderStatus { Placed, Confirmed, Preparing, PickedUp, Delivered }` — วิธีพื้นฐานที่สุดในการแทนสถานะจำกัดจำนวนของ state machine ใน domainTactical Design — type ที่ระบุค่าที่ตั้งชื่อไว้ล่วงหน้าเป็นชุด:
namespace FoodOrdering.Domain.Orders;
public enum OrderStatus{ Placed, // ลูกค้ากดสั่งแล้ว รอร้านตอบรับ Confirmed, // ร้านรับออเดอร์ Preparing, // ครัวกำลังทำอาหาร PickedUp, // ไรเดอร์รับของแล้ว Delivered, // ส่งถึงลูกค้าเรียบร้อย Rejected, // ร้านปฏิเสธออเดอร์ Cancelled, // ลูกค้ายกเลิก}แกะทีละส่วนสำหรับคนที่เพิ่งเจอ C#:
enumคือ keyword ที่บอกว่า “นี่คือ type ที่มีค่าได้แค่ในรายการนี้เท่านั้น” ค่าอื่นนอกรายการเอามาใส่ไม่ได้- ชื่อในวงเล็บปีกกาแต่ละตัว (
Placed,Confirmed, …) คือค่าที่ถูกต้องหนึ่งค่า เขียนอ้างถึงได้ด้วยOrderStatus.Placed - ข้อดีทันทีคือ compiler กันเราจากการพิมพ์
"placd"ผิดๆ แบบที่ string ทำไม่ได้ — สถานะที่ไม่มีจริงจะ compile ไม่ผ่านตั้งแต่แรก
สถานะทั้ง 7 ไม่ได้กระโดดถึงกันมั่วๆ มันไหลตามเส้นทางที่กำหนด เช่น ออเดอร์ต้องผ่าน Confirmed ก่อนถึงจะ Preparing ได้ กระโดดจาก Placed ไป Delivered ตรงๆ ไม่ได้ ภาพนี้คือ ตารางการเปลี่ยนสถานะ ที่เราจะเขียนเป็น code ในบทนี้:
stateDiagram-v2
[*] --> Placed
Placed --> Confirmed: ร้านรับออเดอร์
Placed --> Rejected: ร้านปฏิเสธ
Placed --> Cancelled: ลูกค้ายกเลิก
Confirmed --> Preparing: เริ่มทำอาหาร
Confirmed --> Cancelled: ลูกค้ายกเลิก
Preparing --> PickedUp: ไรเดอร์รับของ
PickedUp --> Delivered: ส่งถึงลูกค้า
Rejected --> [*]
Delivered --> [*]
Cancelled --> [*]
คำบรรยายภาพ: เส้นทางชีวิตของออเดอร์หนึ่งใบ เริ่มที่ Placed เสมอ จากนั้นแตกได้เป็น Confirmed (ร้านรับ), Rejected (ร้านปฏิเสธ) หรือ Cancelled (ลูกค้ายกเลิกทัน) เมื่อเข้าครัวแล้ว (Preparing) จะยกเลิกไม่ได้อีก ต้องไหลตรงไป PickedUp แล้ว Delivered สังเกตว่าลูกศรมีทิศทางชัดเจน — นี่คือกฎที่ code ของเราต้องรักษาไว้ ไม่ให้ใครข้ามขั้น
version ดิบ — if/else ซ้อนกันหลายชั้น
หัวข้อที่มีชื่อว่า “version ดิบ — if/else ซ้อนกันหลายชั้น”ลองดูงานจริงงานหนึ่ง: “ถ้ายกเลิกออเดอร์ตอนนี้ ต้องคืนเงินเท่าไร” กฎคือ ยกเลิกก่อนเข้าครัว (Placed/Confirmed) คืนเต็ม, ระหว่างทำอาหาร (Preparing) คืนครึ่ง (หักค่าวัตถุดิบที่ลงมือไปแล้ว), ถ้าร้านปฏิเสธเอง (Rejected) คืนเต็ม, ส่วนที่เลยไปแล้ว (PickedUp/Delivered/Cancelled) ไม่คืน
เขียนด้วย if/else ตรงๆ แบบที่มือใหม่มักเขียน จะออกมาเป็น ❌ version ดิบ หน้าตาแบบนี้:
// ❌ version ดิบ: if/else ซ้อนกันเป็นพีระมิด อ่านยาก แก้ยากpublic static Money RefundFor(OrderStatus status, Money total){ if (status == OrderStatus.Placed) { return total; } else { if (status == OrderStatus.Confirmed) { return total; } else { if (status == OrderStatus.Preparing) { return Money.Thb(total.Amount * 0.5m); } else { if (status == OrderStatus.Rejected) { return total; } else { // PickedUp, Delivered, Cancelled → ไม่คืนเงิน return Money.Thb(0); } } } }}ปัญหาไม่ใช่แค่ “ยาว” แต่มันซ่อนกับดักไว้:
- ยิ่งลึกยิ่งอ่านไม่ออก — กว่าจะรู้ว่า
Rejectedคืนเต็ม ต้องไล่สายตาผ่านelseสี่ชั้น - ลืมเคสได้ง่าย — ถ้าเราเพิ่มสถานะใหม่ใน enum วันหลัง ไม่มีอะไรเตือนเลยว่ายังไม่ได้มาเติมกฎตรงนี้ มันจะตกลงไปที่
elseก้อนสุดท้ายเงียบๆ (คืน 0) ทั้งที่อาจผิด - แผนที่กระจาย — “กฎการคืนเงินทั้งหมด” ไม่ได้อยู่รวมกันเป็นตารางให้อ่านรวดเดียว มันถูกฝังอยู่ในโครงพีระมิด
เดี๋ยวเราจะเขียนก้อนนี้ใหม่ให้กลายเป็นตารางบรรทัดต่อบรรทัด แต่ก่อนอื่นต้องรู้จักเครื่องมือที่ทำให้มันเกิดขึ้นได้ก่อน
Pattern matching — เช็ก “รูปร่าง” ของค่า
หัวข้อที่มีชื่อว่า “Pattern matching — เช็ก “รูปร่าง” ของค่า”pattern matching คือการถามค่าหนึ่งว่า “เข้ากับรูปแบบนี้ไหม” แล้วดึงส่วนที่ต้องการออกมาใช้ในขั้นตอนเดียว หัวใจคือ keyword is มีหลายแบบ ค่อยๆ ไล่จากง่ายไปยาก:
1. Constant pattern — เทียบกับค่าคงที่ตรงๆ เขียนสั้นกว่า ==:
if (status is OrderStatus.Delivered){ // ออเดอร์ส่งแล้ว}2. Type pattern — ถามว่า “ค่านี้เป็น type อะไร” แล้วถ้าใช่ ก็จับใส่ตัวแปรใหม่ให้พร้อมใช้เลย:
object something = Money.Thb(120);
if (something is Money m){ // ถ้า something เป็น Money จริง → m คือ Money ก้อนนั้น ใช้ m.Amount ได้ทันที var amount = m.Amount; // 120}ตรง is Money m ทำสองอย่างพร้อมกัน: เช็กว่า something เป็น Money ไหม และ ถ้าใช่ก็ประกาศตัวแปร m ที่เป็น Money แล้วให้เลย ไม่ต้อง cast เองอีกรอบ
3. Property pattern — ตรวจ ค่าของ property ข้างในอ็อบเจ็กต์ โดยเขียนในวงเล็บปีกกา จำได้ไหมว่า Money มี property ชื่อ Amount เราถามค่าข้างในมันได้เลย:
// total is { Amount: >= 500 } อ่านว่า "total เป็นอ็อบเจ็กต์ที่ Amount มากกว่าหรือเท่ากับ 500 ไหม"public static bool QualifiesForFreeDelivery(Money total) => total is { Amount: >= 500 };4. Relational pattern — คือส่วน >= 500 ข้างบนนั่นเอง มันเทียบด้วย >, >=, <, <= ได้ตรงๆ ในตัว pattern และเอามาต่อกันได้ด้วย and / or:
// ตัวเลขในช่วง 500 ถึง 2000public static bool IsMidRange(Money total) => total is { Amount: >= 500 and <= 2000 };ทั้งสี่แบบนี้ผสมกันได้ในบรรทัดเดียว เช่น ถามพร้อมกันว่า “ยังเป็นแค่ Placed และยอดเกิน 2000 บาทไหม” (ออเดอร์ก้อนใหญ่ที่ต้องให้ผู้จัดการอนุมัติ):
public static bool NeedsManagerApproval(OrderStatus status, Money total) => status is OrderStatus.Placed && total is { Amount: > 2000 };Switch expression — ตารางที่คืนค่าได้
หัวข้อที่มีชื่อว่า “Switch expression — ตารางที่คืนค่าได้”pattern matching เก่งขึ้นอีกมากเมื่ออยู่ใน switch expression — switch แบบใหม่ที่ เป็นนิพจน์ (expression) คือมัน “คืนค่า” ออกมาได้โดยตรง ต่างจาก switch statement แบบเดิมที่เป็น “คำสั่ง” ต้องมี case, break และมักต้องตั้งตัวแปรไว้คอยรับค่าเอง
เขียนกฎการคืนเงินก้อนเดิม (version ดิบข้างบน) ใหม่ด้วย switch expression จะกลายเป็นตารางที่อ่านรวดเดียวจบ:
public static Money RefundFor(OrderStatus status, Money total) => status switch{ OrderStatus.Placed or OrderStatus.Confirmed => total, // ก่อนเข้าครัว → คืนเต็ม OrderStatus.Preparing => Money.Thb(total.Amount * 0.5m), // กำลังทำ → คืนครึ่ง OrderStatus.Rejected => total, // ร้านปฏิเสธ → คืนเต็ม OrderStatus.PickedUp or OrderStatus.Delivered => Money.Thb(0), // ส่งแล้ว → ไม่คืน OrderStatus.Cancelled => Money.Thb(0), // ยกเลิกไปแล้ว → ไม่คืน _ => throw new ArgumentOutOfRangeException(nameof(status)),};แกะไวยากรณ์ใหม่:
status switch { ... }— เอาค่าstatusมา “แมตช์” กับแต่ละบรรทัดในวงเล็บปีกกา เจอบรรทัดไหนตรงก่อนก็คืนค่าฝั่งขวาของ=>บรรทัดนั้น- แต่ละบรรทัดคือ
pattern => ค่าที่คืนเช่นOrderStatus.Preparing => Money.Thb(...) orเชื่อมสองสถานะที่ให้ผลเหมือนกันไว้บรรทัดเดียว (Placed or Confirmed) ไม่ต้องเขียนซ้ำ_(ขีดล่าง) คือ “เคสที่เหลือทั้งหมด” กันไว้เผื่อมีค่าแปลกปลอมหลุดเข้ามา ตรงนี้เราเลือกโยน exception เพราะมันไม่ควรเกิด
สังเกตว่า code 30 กว่าบรรทัดของ version ดิบ ยุบเหลือ “ตาราง” ที่อ่านจากซ้ายไปขวาได้เลย — สถานะไหน คืนเท่าไร จบในบรรทัดเดียว
ตารางการเปลี่ยนสถานะ ก็เขียนแบบเดียวกัน
หัวข้อที่มีชื่อว่า “ตารางการเปลี่ยนสถานะ ก็เขียนแบบเดียวกัน”กลับไปที่ไดอะแกรมสถานะตอนต้นบท เราเขียนกฎ “จาก A ไป B ได้ไหม” เป็น switch expression บน คู่สถานะ ได้ตรงๆ ตรงนี้ใช้ tuple pattern — จับคู่ค่าสองตัวในวงเล็บพร้อมกัน (from, to):
public static bool CanTransition(OrderStatus from, OrderStatus to) => (from, to) switch{ (OrderStatus.Placed, OrderStatus.Confirmed) => true, (OrderStatus.Placed, OrderStatus.Rejected) => true, (OrderStatus.Placed or OrderStatus.Confirmed, OrderStatus.Cancelled) => true, (OrderStatus.Confirmed, OrderStatus.Preparing) => true, (OrderStatus.Preparing, OrderStatus.PickedUp) => true, (OrderStatus.PickedUp, OrderStatus.Delivered) => true, _ => false, // คู่ไหนไม่อยู่ในตาราง = เปลี่ยนไม่ได้};code ก้อนนี้คือไดอะแกรมเมื่อกี้ที่แปลงเป็น C# แบบตรงตัว — ลูกศรแต่ละเส้นบนภาพ คือหนึ่งบรรทัดที่คืน true ส่วนคู่อื่นที่ไม่มีลูกศร ตกไปที่ _ แล้วคืน false การเปลี่ยนสถานะข้ามขั้น (เช่น Placed → Delivered) ถูกปฏิเสธอัตโนมัติเพราะไม่มีบรรทัดไหนรับมัน
ทำไมถึงจัดการ complexity ได้ดีกว่า if ซ้อน
หัวข้อที่มีชื่อว่า “ทำไมถึงจัดการ complexity ได้ดีกว่า if ซ้อน”พอเทียบ version ดิบกับ switch expression วางข้างกัน จุดที่ดีกว่ามีสามข้อชัดๆ:
- อ่านเป็นตาราง input → output — ทุกกฎอยู่รวมกันเป็นชุด อ่านจากซ้าย (เงื่อนไข) ไปขวา (ผลลัพธ์) รวดเดียวจบ ไม่ต้องไล่
elseทีละชั้นเพื่อหาว่าเคสหนึ่งให้ผลอะไร - ลดความซับซ้อนของเงื่อนไข — พีระมิด
ifซ้อนกันคือสิ่งที่นักออกแบบเรียกว่า conditional complexity ยิ่งซ้อนลึกยิ่งพลาดง่าย switch expression ทำให้ทุกกิ่งอยู่ระดับเดียวกันหมด ไม่มีชั้น - compiler ช่วยตามงาน — เพราะ switch expression ต้องครอบคลุมทุกความเป็นไปได้ ถ้าเราลืมจับบางเคส compiler จะเตือน (ใน version ดิบ ลืมเคสไปก็เงียบ ไม่มีใครบอก) นี่คือความต่างสำคัญ — มันเปลี่ยน “bug ตอน production” ให้กลายเป็น “คำเตือนตอน compile”
หัวใจของบทนี้คือ: เมื่อ logic ผูกกับสถานะ อย่าเขียนเป็น if ซ้อน ให้เขียนเป็นตาราง pattern matching กับ switch expression คือไวยากรณ์ที่ทำให้ “ตาราง” นั้นเป็น code จริงที่รันได้และ compiler ช่วยดูแลให้ครบ
สิ่งที่จะทำต่อ
หัวข้อที่มีชื่อว่า “สิ่งที่จะทำต่อ”ตอนนี้ domain Order ของเรามีทั้งก้อนอิฐที่อยู่นิ่ง (Money, OrderId, Quantity) และ OrderStatus ที่บอกว่าออเดอร์กำลังอยู่ช่วงไหนของชีวิต พร้อมกฎการเปลี่ยนสถานะที่เขียนเป็นตารางแล้ว บทต่อๆ ไปจะประกอบร่างพวกนี้เข้าด้วยกัน:
- บทถัดไป — type system:
enumจำกัดชุดสถานะให้เป็นทางการ, การเลือกclass/record/structให้ถูกงาน และ strongly-typed ID ที่ห่อGuidกันไม่ให้OrderIdกับProductIdสลับกัน — เครื่องมือที่ทำให้ field ของOrderLine/Orderที่เราปั้นไว้แล้วปลอดภัยขึ้นอีกชั้น - บทหลังๆ — collection กับ LINQ ห่อกลุ่มของบรรทัดใน
Order, interface/generics/DI ทำให้ domain พึ่งแค่ port และ async/await กลายเป็นรูปร่างของ port ที่คุยกับโลกภายนอก
ส่วนการออกแบบ state machine ของ aggregate ในเชิง DDD ให้ลึกกว่านี้ (ใครมีสิทธิ์สั่งเปลี่ยนสถานะ, domain event ที่เกิดตามมา) เก็บไว้ที่คอร์ส DDD in Code — บทนี้โฟกัสที่ “ไวยากรณ์ C# ที่ทำให้ตารางสถานะเป็น code จริงได้” ก่อน
เจาะลึกแนวคิดในบทนี้ต่อได้ที่คลังอ้างอิง DevIQ:
- Conditional Complexity — code smell ของ
if/elseที่ซ้อนกันจนอ่านยาก และทำไมมันเป็นสัญญาณว่าควรยุบเป็นตารางหรือแยกความรับผิดชอบ - State (design pattern) — pattern สำหรับจัดการอ็อบเจ็กต์ที่พฤติกรรมเปลี่ยนตามสถานะ เจาะลึกกว่า enum + switch เมื่อ state machine ของออเดอร์ซับซ้อนขึ้น
เช็กความเข้าใจ — บทที่ 4
ข้อ 1 / 3switch expression ต่างจาก switch statement แบบเดิมอย่างไร?