The Last 10% Trap
ประเมิน 10% สุดท้ายของ project ต่ำเกินไป จนกินเวลามหาศาล
ปัญหาคืออะไร
หัวข้อที่มีชื่อว่า “ปัญหาคืออะไร”The Last 10% Trap เกิดเมื่อทีมเลือกใช้เครื่องมือ framework หรือ platform ที่ออกแบบมาให้สร้าง feature มาตรฐานได้เร็วมาก เช่น low-code/no-code, drag-and-drop UI builder, หรือ scaffold generator แล้วพบว่า 90% แรกของงานเสร็จเร็วอย่างน่าประทับใจ แต่ 10% สุดท้าย (เคสขอบ กฎธุรกิจเฉพาะ การปรับแต่งที่ลูกค้าต้องการจริง ๆ) กลับใช้เวลาและความพยายามมากกว่าที่คาดไว้หลายเท่า เพราะเครื่องมือเหล่านั้นแก้ปัญหามาตรฐานได้ดี แต่ไม่มี extension point รองรับสิ่งที่หลุดกรอบไปจากที่ผู้ออกแบบเครื่องมือคาดไว้
แนวคิดนี้ถูกอธิบายไว้ในหนังสือ Building Evolutionary Architectures ของ Neal Ford, Rebecca Parsons และ Patrick Kua โดยยกตัวอย่างเครื่องมือ 4GL อย่าง Microsoft Access ว่า “80% ของสิ่งที่ลูกค้าต้องการสร้างได้เร็วและง่าย” ด้วย drag-and-drop และ feature สำเร็จรูป แต่ feature ที่เหลือมักหลุดออกนอกขอบเขตความสามารถของเครื่องมือ ทำให้ต้องหาทางเลี่ยงแบบแฮ็ก ๆ เช่น “แปะ script เข้าไปในจุดที่ออกแบบมาให้เป็น static” ซึ่งพาได้ไกลสุดแค่ 80% ไป 90% แต่ไปต่อไม่ได้เพราะ “เครื่องมือแก้ปัญหาให้ครบไม่ได้จริง ๆ”
รากที่มาของปรากฏการณ์นี้ย้อนไปถึง ninety-ninety rule ของ Tom Cargill แห่ง Bell Labs ที่ดังผ่าน column “Programming Pearls” ของ Jon Bentley ใน Communications of the ACM (กันยายน 1985): “90% แรกของ code กินเวลา 90% แรกของการพัฒนา ส่วน 10% ที่เหลือของ code กินเวลาอีก 90% ที่เหลือ” ซึ่งรวมกันได้ 180% โดยตั้งใจ เพื่อล้อเลียนความจริงที่ว่า project ซอฟต์แวร์มักเกินกำหนดการเสมอ
ทำไมถึงดูน่าใช้
หัวข้อที่มีชื่อว่า “ทำไมถึงดูน่าใช้”- เดโมช่วงต้นน่าประทับใจมาก — เครื่องมือ rapid application development ทำให้เห็นหน้าจอใช้งานได้จริงภายในไม่กี่วัน stakeholder เห็นความคืบหน้าไวจึงมั่นใจว่า project ไปได้สวย
- ดูเหมือน “เหลืออีกนิดเดียว” — เมื่อ progress bar ขึ้นไปถึง 80-90% ความรู้สึกคือใกล้เสร็จแล้ว ทั้งที่งานที่เหลือเป็นส่วนที่ยากที่สุดของทั้ง project
- sunk cost — ทีมลงทุนเวลาและความคุ้นเคยกับเครื่องมือไปมากแล้ว จึงอยากฝืนใช้ต่อแทนที่จะยอมรับว่าเครื่องมือไม่เหมาะกับงานที่เหลือ
- มองข้ามความไม่เชิงเส้น (non-linearity) ของความซับซ้อน — คนมักประเมินงานแบบเป็นสัดส่วนกับ “จำนวน feature ที่เหลือ” ไม่ใช่ “ความยากที่เหลือ” ทั้งที่ feature ที่เหลือมักเป็นเคสขอบและกฎธุรกิจที่ยุ่งที่สุด
ทำไมถึงเป็นปัญหา
หัวข้อที่มีชื่อว่า “ทำไมถึงเป็นปัญหา”เครื่องมือ rapid-development ส่วนใหญ่ถูกออกแบบให้ครอบคลุม “เคสทั่วไป” (common case) ได้ดีมาก แต่กฎธุรกิจเฉพาะ การ integrate กับระบบเก่า หรือ requirement ที่เปลี่ยนกลางทาง มักหลุดกรอบที่เครื่องมือรองรับ เมื่อถึงจุดนั้นทีมมีทางเลือกแค่สองทาง คือฝืนแฮ็กเข้าไปใน code ที่ generate มา (ซึ่งพังง่ายเมื่อ regenerate ใหม่) หรือหยุดแล้วเปลี่ยนแนวทางทั้งหมด ทั้งสองทางล้วนกินเวลาและงบประมาณเกินที่ประเมินไว้มาก
ผลที่ตามมาคือพลาด deadline ต้นทุนบานปลาย และ stakeholder หมดความไว้ใจ เพราะตัวเลข “90% เสร็จแล้ว” ที่รายงานไปก่อนหน้านี้กลายเป็นภาพลวงตา ปรากฏการณ์นี้เชื่อมโยงตรงกับ Hofstadter’s Law ที่ว่างานมักใช้เวลานานกว่าที่คาดเสมอ แม้จะคำนวณ buffer ไว้แล้วก็ตาม และเชื่อมโยงกับ Law of Diminishing Returns เพราะความพยายามหน่วยสุดท้ายให้ผลตอบแทน (feature ที่ใช้งานได้จริง) ต่ำลงเรื่อย ๆ เมื่อเทียบกับความพยายามหน่วยแรก ๆ
แผนภาพด้านล่างสรุปกลไกที่มักเกิดขึ้น:
flowchart LR
Start[เริ่ม project] --> Pick[เลือกเครื่องมือทำงานเร็ว]
Pick --> Fast[สร้าง feature หลักได้เร็ว]
Fast --> Check{ต้องรองรับเคสขอบหรือ customization ไหม}
Check -->|รองรับได้ในตัว| Ship[ส่งงานตามปกติ]
Check -->|หลุดกรอบเครื่องมือ| Hack[ใช้ workaround และ hack]
Hack --> Crawl[ความคืบหน้าคืบคลานช้าลง]
Crawl --> Limit[ชนขีดจำกัดของเครื่องมือ]
Limit --> Rewrite[ต้อง replatform หรือเขียนส่วนนั้นใหม่]
ตัวอย่าง
หัวข้อที่มีชื่อว่า “ตัวอย่าง”สถานการณ์ทั่วไป: ทีมใช้ scaffold generator สร้างหน้าจอ CRUD จาก database schema ให้อัตโนมัติ ครอบคลุมการสร้าง อ่าน แก้ไข ลบข้อมูล ได้ครบใน 90% ของหน้าจอทั้งหมดภายในไม่กี่วัน แต่พอถึงกฎธุรกิจเฉพาะ เช่น “ส่วนลดสะสมตามระดับสมาชิก” ที่ generator ไม่มีจุดให้ขยาย ทีมจึงเริ่มแฮ็กเข้าไปใน code ที่ generate มาโดยตรง
// code ที่ scaffold สร้างให้อัตโนมัติ จัดการ CRUD มาตรฐานได้ดีมากpublic partial class OrderController : Controller{ // method Create/Read/Update/Delete มาตรฐานถูก generate มาให้ครบ}
// พอถึงกฎธุรกิจที่ generator ไม่รองรับ ทีมแฮ็กเข้าไปใน file partial อีก filepublic partial class OrderController{ public IActionResult Update(int id, OrderDto dto) { var order = _repository.Find(id);
// ใช้ reflection เจาะ private field ที่ scaffold ไม่เปิดให้แก้จากภายนอก var field = typeof(Order).GetField("_discountRate", BindingFlags.NonPublic | BindingFlags.Instance);
// if-else ซ้อนกันเพื่อ patch กฎธุรกิจที่ generator ไม่มีที่ให้ใส่ if (dto.CustomerTier == "Gold") { field?.SetValue(order, dto.Items.Count > 10 ? 0.15m : 0.10m); } else if (dto.CustomerTier == "Silver") { // ต้อง copy-paste ตรรกะคล้ายเดิมเพราะไม่มี extension point ให้ใช้ร่วมกัน field?.SetValue(order, dto.Items.Count > 10 ? 0.08m : 0.05m); }
// ทุกครั้งที่มีคน regenerate code จาก schema code patch พวกนี้หายหมด return Ok(); }}code นี้ใช้งานได้ในระยะสั้น แต่เปราะบางมาก เพราะตรรกะทางธุรกิจถูกฝังไว้ใน code ที่ generator เป็นเจ้าของ และจะหายไปทันทีที่มีการ regenerate scaffold ใหม่
ทางแก้และการ refactor
หัวข้อที่มีชื่อว่า “ทางแก้และการ refactor”หลักการสำคัญคือ แยกตรรกะทางธุรกิจออกจาก code ที่เครื่องมือ generate ให้ เพื่อให้ regenerate ได้อย่างปลอดภัย และเพื่อให้ทดสอบ/ปรับกฎธุรกิจได้โดยไม่ต้องพึ่งความสามารถของเครื่องมือ:
// แยก business rule ออกมาเป็น domain service ของตัวเอง ไม่ผูกกับ code ที่ generate มาpublic interface IDiscountPolicy{ decimal GetRate(CustomerTier tier, int itemCount);}
public class TieredDiscountPolicy : IDiscountPolicy{ public decimal GetRate(CustomerTier tier, int itemCount) => tier switch { CustomerTier.Gold when itemCount > 10 => 0.15m, CustomerTier.Gold => 0.10m, CustomerTier.Silver when itemCount > 10 => 0.08m, CustomerTier.Silver => 0.05m, _ => 0m };}
// controller ที่ generate มา เรียกใช้ domain service แทนการแฮ็ก code ตรง ๆpublic partial class OrderController : Controller{ private readonly IDiscountPolicy _discountPolicy;
public IActionResult Update(int id, OrderDto dto) { var order = _repository.Find(id); var rate = _discountPolicy.GetRate(dto.CustomerTier, dto.Items.Count); order.ApplyDiscount(rate); _repository.Save(order); return Ok(); }}เมื่อ scaffold ถูก regenerate ใหม่ OrderController ส่วนที่ generate จะหายไปและถูกสร้างใหม่ได้ตามปกติ ในขณะที่ TieredDiscountPolicy ซึ่งเป็น code ที่ทีมเขียนเองยังอยู่ครบและไม่ได้รับผลกระทบ
แนวทางเชิงกระบวนการที่ช่วยลดความเสี่ยงของ trap นี้:
- สปาก์เคสยากที่สุดก่อน ไม่ใช่ทีหลัง — ก่อน commit กับเครื่องมือใด ให้ลองสร้าง walking skeleton ที่ครอบคลุมเคสขอบที่ซับซ้อนที่สุดที่รู้อยู่แล้วว่าลูกค้าต้องการ เพื่อทดสอบเพดานความสามารถของเครื่องมือตั้งแต่ต้น แทนที่จะรอไปเจอตอนท้าย
- ออกแบบ extension point ไว้ตั้งแต่แรก — แยก domain logic ออกจากชั้นที่เครื่องมือควบคุม (คล้ายแนวคิด hexagonal architecture) เพื่อให้ปรับกฎธุรกิจได้โดยไม่ต้องพึ่งเครื่องมือ
- อัปเดตแผนทันทีที่เจอสัญญาณเตือน — เมื่อพบว่า feature ที่เหลือเริ่มต้องใช้ workaround ให้ re-estimate และสื่อสารกับ stakeholder ทันที ดูแนวปฏิบัติ Update the Plan
- พิจารณาย้ายทีละส่วนแทนการ big-bang rewrite — ถ้าเครื่องมือชนเพดานจริง ใช้แนวทางแบบ Strangler Fig Pattern ค่อย ๆ แทนที่ส่วนที่เครื่องมือรองรับไม่ได้ด้วย code ที่เขียนเอง แทนที่จะทิ้งทุกอย่างแล้วเริ่มใหม่ทั้งหมด
- ระวังการยึดติดกับเครื่องมือเดียวเกินไป — ถ้าเลือกเครื่องมือเพราะ “มันทำได้ทุกอย่าง” โดยไม่ตรวจสอบขอบเขตจริง อาจเข้าข่าย Golden Hammer
ที่เกี่ยวข้อง
หัวข้อที่มีชื่อว่า “ที่เกี่ยวข้อง”- Hofstadter’s Law
- Law of Diminishing Returns
- Golden Hammer
- Death March
- Update the Plan
- Strangler Fig Pattern