Parse, Don't Validate
แปลงที่ขอบเขตให้เป็น type ที่การันตีความถูกต้อง
แนวคิดหลัก
หัวข้อที่มีชื่อว่า “แนวคิดหลัก”Parse, Don’t Validate เป็นหลักการที่ย้ายความรับผิดชอบเรื่องความถูกต้องจากการตรวจสอบ (validation) ที่กระจัดกระจาย ไปสู่การสร้าง type แทนที่จะรับ input ดิบแล้วคอยเช็กทุกครั้งที่ใช้ คุณ parse input ดิบเพียงครั้งเดียวที่ขอบเขต (boundary) แล้วผลิตค่าที่มี type ซึ่งการันตีความถูกต้องด้วยตัวการสร้างเอง หลัง parse สำเร็จ ก็ไม่ต้อง validate อีกเพราะ type เองแสดงสถานะที่ไม่ถูกต้องไม่ได้
หลักการนี้ถูกอธิบายไว้โดย Alexis King ในบทความ Parse, Don’t Validate (2019):
Parsing is just validation with a return value — and that return value proves you did the work.
— Alexis King
ประเด็นสำคัญคือ validation นั้น ทิ้ง หลักฐานของงานทั้งหมด: มันคืนค่า boolean หรือโยน exception แต่ปล่อยข้อมูลไว้ในรูปไร้ type เหมือนเดิม ส่วน parsing นั้น เก็บ หลักฐานไว้ด้วยการคืน type ใหม่ type นั้นพก guarantee ว่าข้อมูลถูกต้อง ผู้เรียกจึงไม่ต้องเช็กซ้ำ King เรียกแนวทางนี้ว่าการออกแบบตาม “the shape of the data you wish you had” — เขียน function ให้ทำงานกับ representation ที่ต้องการจะมี ไม่ใช่ representation ดิบที่รับเข้ามา
หลักการนี้ยังผูกกับแนวคิดจาก language-theoretic security ว่าด้วย shotgun parsing: ถ้าโปรแกรมตรวจ input ทีละส่วนกระจายไปทั่วทั้งระบบ ข้อผิดพลาดของ input อาจถูกพบช้า หลังจากส่วนหนึ่งของ input ที่ยังไม่ผ่านการยืนยันถูกประมวลผลไปแล้ว การ parse ที่ขอบเขตครั้งเดียวช่วยแบ่งโปรแกรมเป็นสองเฟสอย่างชัดเจน — เฟส parse ที่ความล้มเหลวจาก input ผิดรูปแบบเกิดขึ้นได้ทั้งหมด กับเฟส execution ที่เหลือช่องทางความล้มเหลวน้อยมาก เพราะข้อมูลผ่านการรับประกันจาก type แล้ว
flowchart LR
Raw[ข้อมูลดิบจาก HTTP request หรือ config]
Parse{Parse ที่ boundary}
Reject[คืน error ทันที]
Typed[Typed value เช่น EmailAddress]
Trust[code ชั้นในไม่ต้องตรวจซ้ำ]
Raw --> Parse
Parse -->|ไม่ผ่าน| Reject
Parse -->|ผ่าน| Typed
Typed --> Trust
หลักการนี้ได้ผลดีที่สุดเมื่อใช้ที่ ขอบเขตของระบบ — จุดที่ข้อมูลไร้โครงสร้างเข้ามา ได้แก่ การ deserialize HTTP request, การ parse command-line หรือ config, การอ่านจากฐานข้อมูล และการรับ event เมื่อผ่านขอบเขตมาแล้ว code ปลายทางเชื่อถือ type ที่ได้รับได้โดยไม่ต้องเช็กซ้ำเชิงป้องกัน
ทำไมถึงสำคัญ
หัวข้อที่มีชื่อว่า “ทำไมถึงสำคัญ”เมื่อข้อมูลดิบถูก validate ที่จุดใช้งานแทนที่จะเป็นที่ขอบเขต ปัญหาหลายอย่างจะตามมา:
- ตรรกะ validation ซ้ำซ้อน ทั่ว codebase แต่ละจุดเป็นแหล่ง bug ที่อาจคลาดเคลื่อนไม่ตรงกัน — แก้กฎที่หนึ่งจุดแต่ลืมอีกสิบจุด
- validate ซ้ำเชิงป้องกัน (defensive re-validation) method ที่รับ type ดิบต้องเช็กซ้ำเสมอ ไม่ว่าผู้เรียกจะทำมาแล้วหรือไม่ เพราะไม่มีทางรู้
- type system ไม่ช่วยอะไรเลย เพราะ string ที่ผ่านการตรวจว่าเป็นอีเมลแล้วแยกไม่ออกจาก string ที่ยังไม่ตรวจ compiler มองทั้งคู่เป็น
stringเหมือนกัน - validation ถูกลืมได้ เพราะไม่มีอะไรห้าม code ข้ามการเช็กไป โดยเฉพาะเมื่อทีมโตขึ้นและไม่มีใครจำได้ว่าต้องเช็กตรงไหนบ้าง
การ parse ที่ขอบเขตแก้ปัญหาเหล่านี้พร้อมกัน: กฎอยู่ที่เดียว compiler บังคับให้ผ่าน parse ก่อนถึงจะได้ค่าที่ type นั้น และการลืมเช็กเป็นไปไม่ได้เพราะไม่มีทางสร้างค่าประเภทนั้นด้วยวิธีอื่น หลักการนี้จึงทำงานร่วมกับ Fail Fast โดยตรง — ความล้มเหลวจาก input เสีย ถูกดักไว้ที่จุดเดียวใกล้แหล่งกำเนิดที่สุด แทนที่จะปล่อยให้ข้อมูลเสียไหลลึกเข้าไปในระบบก่อนถูกจับได้
ตัวอย่าง
หัวข้อที่มีชื่อว่า “ตัวอย่าง”parser แปลง input ที่ยังไม่ผ่านการยืนยันให้เป็นค่าที่มี type ซึ่งจะมีอยู่ได้ก็ต่อเมื่อมันถูกต้อง type ผลลัพธ์คือหลักฐานของการ validate — คุณถือค่าของ type นั้นไม่ได้เลยหากไม่ได้ parse มาก่อน:
EmailAddress สร้างได้เฉพาะเมื่อ parse ผ่านเท่านั้น
public class EmailAddress{ public string Value { get; }
private EmailAddress(string value) { Value = value; }
public static EmailAddress Parse(string raw) { if (string.IsNullOrWhiteSpace(raw) || !raw.Contains('@')) throw new ArgumentException($"'{raw}' is not a valid email address.", nameof(raw));
return new EmailAddress(raw.Trim().ToLowerInvariant()); }
public static bool TryParse(string raw, out EmailAddress? result) { if (!string.IsNullOrWhiteSpace(raw) && raw.Contains('@')) { result = new EmailAddress(raw.Trim().ToLowerInvariant()); return true; } result = null; return false; }
public override string ToString() => Value;}method ใดที่รับ EmailAddress ย่อมได้รับหลักฐานว่าค่านั้นถูก validate แล้ว การตรวจสอบเกิดขึ้นครั้งเดียว — ที่การเรียก Parse — และไม่ถูกทำซ้ำ:
เทียบก่อน/หลัง: type พก proof มาให้ ไม่ต้อง guard ซ้ำ
// ก่อน: string ดิบ validate (หรือไม่) ที่จุดใช้งานpublic void SendWelcomeEmail(string email){ if (string.IsNullOrWhiteSpace(email) || !email.Contains('@')) throw new ArgumentException("Invalid email.");
_mailer.Send(email, "Welcome!");}
// หลัง: EmailAddress พกหลักฐานความถูกต้องมาแล้ว ไม่ต้อง guardpublic void SendWelcomeEmail(EmailAddress email){ _mailer.Send(email.Value, "Welcome!");}เป้าหมายที่กว้างกว่าของ Parse, Don’t Validate คือการออกแบบ type ให้ค่าที่ไม่ควรมีอยู่ มีอยู่ไม่ได้ ซึ่งมักเรียกว่าการทำให้สถานะผิดกฎแสดงแทนไม่ได้ (Make Illegal States Unrepresentable) ลองพิจารณาออเดอร์ที่จัดส่งได้ก็ต่อเมื่อมีอย่างน้อยหนึ่งรายการและได้รับการยืนยันการชำระเงิน:
ออเดอร์ใด ๆ ก็ “จัดส่งได้” ในทางไวยากรณ์ ไม่มีอะไรกันสถานะผิด
public class Order{ public List<OrderLine> Lines { get; set; } = new(); public bool IsPaid { get; set; } public string Status { get; set; } = "Pending";}
public void Ship(Order order){ if (!order.IsPaid || !order.Lines.Any()) throw new InvalidOperationException("Order cannot be shipped.");
order.Status = "Shipped";}หลังจาก parse ณ จุดที่ออเดอร์กลายเป็น “จัดส่งได้” method ปลายทางก็ไม่ต้อง validate อีก เพราะไม่มีทางสร้าง ShippableOrder จากออเดอร์ที่ยังไม่พร้อมได้เลย:
ShippableOrder มีอยู่ในสถานะที่ไม่ถูกต้องไม่ได้
public class ShippableOrder{ public IReadOnlyList<OrderLine> Lines { get; } public PaymentConfirmation Payment { get; }
private ShippableOrder(IReadOnlyList<OrderLine> lines, PaymentConfirmation payment) { Lines = lines; Payment = payment; }
public static ShippableOrder Parse(Order order) { if (!order.Lines.Any()) throw new InvalidOperationException("Cannot ship an order with no items."); if (order.Payment is null) throw new InvalidOperationException("Cannot ship an unpaid order.");
return new ShippableOrder(order.Lines, order.Payment); }}
public void Ship(ShippableOrder order){ // ไม่ต้อง guard — ShippableOrder อยู่ในสถานะที่ไม่ถูกต้องไม่ได้ _shipper.Dispatch(order);}สังเกตว่า Ship(ShippableOrder order) ไม่มี if แม้แต่บรรทัดเดียว — การรับประกันทั้งหมดถูกโอนย้ายไปอยู่ที่ ShippableOrder.Parse เพียงจุดเดียว นี่คือหัวใจของหลักการ: ยิ่งเลื่อน parse ให้เร็วที่สุด (เข้าใกล้ boundary มากที่สุด) เท่าไร code ชั้นในก็ยิ่ง “โง่” และปลอดภัยมากขึ้นเท่านั้น
สัญญาณว่ากำลังละเมิดหลักการนี้
หัวข้อที่มีชื่อว่า “สัญญาณว่ากำลังละเมิดหลักการนี้”- code ตรวจ format เดียวกัน (อีเมล, เลขบัตร, ช่วงวันที่) ปรากฏซ้ำในหลาย method/หลาย layer แทนที่จะอยู่ที่จุดเดียว
- domain concept ถูกแทนด้วย primitive ตรง ๆ (
string,int,List<T>) โดยไม่มี constructor หรือ factory ที่บังคับ invariant — อาการของ Primitive Obsession - object มี property แบบ
IsValidหรือIsPaidที่ผู้เรียกต้องเช็กก่อนใช้งานทุกครั้ง แทนที่ type เองจะรับประกันความถูกต้องอยู่แล้ว - setter สาธารณะ (
{ get; set; }) ที่ปล่อยให้ object เปลี่ยนกลับไปอยู่ในสถานะไม่ถูกต้องได้หลังสร้างเสร็จแล้ว - คอมเมนต์ประเภท
// สมมติว่าถูก validate มาแล้วหรือ guard clause ที่ก็อปวางซ้ำ ๆ ทุกจุดที่รับ input เดียวกัน - method
Validate()หรือTryParse()ถูกเรียกลึกอยู่กลาง business logic แทนที่จะเรียกครั้งเดียวใกล้จุดที่ input เข้าสู่ระบบ
ที่เกี่ยวข้อง
หัวข้อที่มีชื่อว่า “ที่เกี่ยวข้อง”- Make Illegal States Unrepresentable
- Encapsulation
- Tell Don’t Ask
- Explicit Dependencies
- Fail Fast
- Primitive Obsession
แหล่งอ้างอิง
หัวข้อที่มีชื่อว่า “แหล่งอ้างอิง”- ที่มา · deviq.com/principles/parse-dont-validate
- Alexis King — Parse, Don’t Validate (2019)
- Microsoft Learn — Implementing value objects
- c2 wiki — Primitive Obsession
- Coding Militia — Parse, don’t validate and other type safety driven shenanigans, plus a C# wishlist