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

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 อีก file
public 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 ใหม่

หลักการ​สำคัญ​คือ แยก​ตรรกะ​ทาง​ธุรกิจ​ออก​จาก 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