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

Pain Driven Development

อย่า​เพิ่ง​ใช้​หลักการ จนกว่า​จะ​เริ่ม “เจ็บ”

Pain Driven Development (PDD) คือ​แนว​ปฏิบัติ​ที่ รอ ที่​จะ​นำ​หลักการ pattern และ​แนว​ปฏิบัติ​ต่าง ๆ มา​ใช้​กับ code จนกว่า​แนวทาง​ที่​ใช้​อยู่​ปัจจุบัน​จะ​เริ่ม​สร้าง​ความ​เจ็บ​ปวด (pain) ที่​ต้อง​แก้ไข​จริง ๆ ไม่ใช่​เพราะ “ดูดี” หรือ “อาจ​จะ​ต้อง​ใช้​ใน​อนาคต” มัน​เป็น​รูปแบบ​หนึ่ง​ของ YAGNI ที่​ช่วย​ประกัน​ว่า​คุณ​จะ​ไม่​ทำ gold-plating กับ codebase อย่าง​สุ่ม​สี่​สุ่ม​ห้า​โดย​ไม่​ได้​เพิ่ม​คุณค่า​ให้​ลูกค้า

แนวคิด​นี้​สอดคล้อง​กับ​สิ่ง​ที่ Martin Fowler อธิบาย​ไว้​ใน​บทความ​เรื่อง Yagni ว่า feature หรือ abstraction ที่​สร้าง​ล่วงหน้า​เพื่อ “เผื่อ​อนาคต” มี​ต้นทุน​ซ่อน​อยู่​หลาย​ชั้น ทั้ง cost of build (แรง​ใน​การ​วิเคราะห์ เขียน และ​ทดสอบ​สิ่ง​ที่​ยัง​ไม่​จำเป็น) cost of delay (เวลา​ที่​เสีย​ไป​แทนที่​จะ​ส่ง​มอบ feature ที่​สร้าง​รายได้​จริง​ก่อน) cost of carry (ความ​ซับซ้อน​ที่​ถ่วง​การ​พัฒนา​ส่วน​อื่น) และ cost of repair (เมื่อ abstraction ที่​เดา​ไว้​ผิด​ทาง ต้อง​รื้อ​ทิ้ง​ทีหลัง) PDD จึง​ไม่ใช่​ข้อ​อ้าง​ให้ “ไม่​ออกแบบ” แต่​คือ​การ​เลื่อน​การ​ลงทุน​ออกแบบ​ไป​ยัง​จุด​ที่​มี​ข้อมูล​จริง​มาก​พอ นั่น​คือ ความ​เจ็บ​ปวด​ที่​เกิด​ขึ้น​ซ้ำ ๆ

ตัวอย่าง​ที่​เห็น​ภาพ​ชัด​ของ​การ​ใช้ PDD คือ​การ​ใช้​กับ Open-Closed Principle ไม่มี​ซอฟต์แวร์​ใด​ที่ “เปิด​รับ​การ​ขยาย แต่​ปิด​ต่อ​การ​แก้ไข” ได้​ใน​ทุก​ทาง​ที่​เป็น​ไป​ได้ หาก​ทำ​เช่น​นั้น​จริง มัน​จะ​นามธรรม​เกิน​กว่า​จะ​ใช้งาน​ได้ — เหมือน​กับ​การ​ส่ง​มอบ IDE ให้​ลูกค้า​ไป​สร้าง​อะไร​ก็ได้​ที่​ต้องการ แทนที่​จะ​ส่ง​มอบ​ซอฟต์แวร์​ที่​เขา​ร้องขอ​จริง ๆ

ดังนั้น​แนวทาง​ที่​แนะนำ​สำหรับ OCP คือ ปล่อย​ให้การ​เปลี่ยนแปลง​หนึ่ง​หรือ​สอง​ครั้ง​แรก​ต้อง​แก้ไข code เดิม​ไป​ก่อน เขียน conditional เพื่อ​รองรับ​พฤติกรรม​ใหม่ ทำ​สิ่ง​ที่​ง่าย​ที่สุด​ที่​ทำให้​ซอฟต์แวร์​รองรับ​ความ​ต้องการ​ใหม่​ได้ ต่อ​เมื่อ​สิ่ง​นี้​เกิด​ขึ้น​ซ้ำ​แล้ว​ซ้ำ​เล่า และ​ค่อน​ข้าง​ชัดเจน​แล้ว​ว่า​มัน​จะ​วิวัฒน์​ไป​ใน​ทิศทาง​นี้​ต่อ​ไป จึง​ค่อย​ลงมือ​ใช้ OCP เพื่อ​ป้องกัน​การ​เปลี่ยนแปลง ชนิด​ที่​คุณ​เห็น​ว่า code ต้อง​เผชิญ​จริง ๆ ตาม​ความ​ต้องการ​ที่มา​จาก​ลูกค้า

รอ​ให้​เห็น​รูปแบบ​ก่อน: abstraction ที่​ดี​เกิด​จาก​การ​เห็น​การ​เปลี่ยนแปลง​จริง​เกิด​ซ้ำ ๆ ไม่ใช่​จาก​การ​เดา​อนาคต การ​ใส่ OCP ก่อน​เวลา​อัน​ควร​มักได้ abstraction ผิด​จุด ที่​ต้อง​รื้อ​ทิ้ง​ทีหลัง​อยู่ดี

  1. ทำ​สิ่ง​ที่​ง่าย​ที่สุด​ก่อน — เขียน code ตรง​ไป​ตรง​มา​เพื่อ​ตอบ​โจทย์​ปัจจุบัน ยอม​ให้​มี if/switch หรือ code ซ้ำ​เล็กน้อย​ใน​ช่วง​แรก แทนที่​จะ​รีบ​สร้าง interface หรือ base class เผื่อ​กรณี​ใน​อนาคต​ที่​ยัง​ไม่​เกิด​ขึ้น​จริง
  2. สังเกต​สัญญาณ​ของ pain — เช่น ต้อง​แก้ file เดิม​ซ้ำ ๆ ทุก​ครั้ง​ที่​มี​ความ​ต้องการ​ใหม่​คล้าย​เดิม (ใกล้​เคียง​กับ​กลิ่น Shotgun Surgery) bug เดิม​เกิด​ซ้ำ​ใน​หลาย​จุด ทีม​ทดสอบ​หรือ​ลูกค้า​บ่น​เรื่อง​เดิม​ซ้ำ ๆ หรือ​การ​เปลี่ยนแปลง​เล็กน้อย​ใช้​เวลา​นาน​ผิด​ปกติ
  3. แยก​ความ​เจ็บ​ที่​รอ​ได้ ออก​จาก​ความ​เจ็บ​ที่​ต้อง​ป้องกัน​ล่วงหน้า — pain ส่วน​ใหญ่ (โครงสร้าง conditional, การ​จัด​กลุ่ม method) แก้​ทีหลัง​ได้​ด้วย refactor แต่ pain บาง​ประเภท​ที่ แก้​ยาก​มาก​เมื่อ project โต​แล้ว เช่น การ​วาง dependency structure หรือ layering ผิด​ตั้งแต่​ต้น ควร​ใช้​ประสบการณ์​นำทาง​และ​ป้องกัน​ไว้​ก่อน
  4. นับ​จำนวน​ครั้ง​ที่ pain เกิด​ซ้ำ — แนวทาง​ทั่วไป​คือ “rule of three”: ถ้า​เจอ pattern เดิม​ซ้ำ​เป็น​ครั้ง​ที่​สาม​จึง​ค่อย refactor ไป​สู่ abstraction ที่​เหมาะสม เพราะ​ตอน​นั้น​มี​ตัวอย่าง​พอที่​จะ​ออกแบบ abstraction ที่​ตรง​กับ​ของ​จริง ไม่ใช่​ของ​ที่​เดา​ไว้
  5. จัด​ลำดับ​ความ​สำคัญ​ของ pain ด้วย​ผลกระทบ​เทียบ​กับ​แรง​ที่​ต้อง​ลงทุน — ทีม​จำนวน​มาก​ใช้​ตาราง​สี่​ช่อง (effort สูง/ต่ำ × impact สูง/ต่ำ) เพื่อ​ตัดสิน​ใจ​ว่า​จะ​แก้ pain จุด​ไหน​ก่อน แทนที่​จะ​ไล่​แก้​ทุก​อย่าง​ตาม​ลำดับ​ที่​มัน​โผล่​มา
flowchart TD
  A[เขียน code แบบง่ายที่สุดที่ตอบโจทย์ตอนนี้] --> B{เจอ pain หรือยัง}
  B -- ยังไม่เจอ --> A
  B -- เจอแล้ว --> C{เป็น pain ที่แก้ยากภายหลังหรือเกิดซ้ำหลายครั้ง}
  C -- เกิดครั้งแรก และแก้ทีหลังได้ --> D[แก้แบบเฉพาะหน้า เช่น เพิ่ม conditional]
  D --> A
  C -- เกิดซ้ำหรือแก้ยากถ้าปล่อยไว้ --> E[นำหลักการหรือ pattern มาใช้จริง]
  E --> A

สมมติ​ว่า​ระบบ​ต้อง​คำนวณ​ส่วนลด​ราคา​สินค้า ช่วง​แรก​มี​กฎ​แค่​แบบ​เดียว การ​เขียน​แบบ​ง่าย​ที่สุด​จึง​เพียงพอ:

public decimal CalculateDiscount(Order order)
{
// กฎแรก: ลูกค้าประจำลด 10%
if (order.Customer.IsLoyaltyMember)
{
return order.Total * 0.10m;
}
return 0m;
}

เมื่อ​ความ​ต้องการ​ที่​สอง​มา​ถึง (ลด​ราคา​ช่วง​เทศกาล) ทีม​ยัง​ไม่​ต้อง​รีบ​สร้าง Strategy pattern — แค่​เพิ่ม conditional เข้าไป เพราะ​ยัง​ไม่​เห็น​รูปแบบ​ที่​ชัดเจน​พอ​จะ​ออกแบบ abstraction ได้​ถูก​จุด:

public decimal CalculateDiscount(Order order)
{
if (order.IsSeasonalPromotion)
{
return order.Total * 0.15m;
}
if (order.Customer.IsLoyaltyMember)
{
return order.Total * 0.10m;
}
return 0m;
}

ต่อ​เมื่อ​กฎ​ส่วนลด​เพิ่ม​เป็น​กฎ​ที่​สาม สี่ และ​มี​สัญญาณ​ว่า​จะ​เพิ่ม​เรื่อย ๆ ตาม business ที่​โต​ขึ้น — นั่น​คือ pain ที่​เกิด​ซ้ำ​จน​เห็น​รูปแบบ​ชัด​แล้ว — จึง​ค่อย​แตก​ออก​เป็น Strategy pattern เพื่อ​ให้​แต่ละ​กฎ​อยู่​แยก​กัน​และ​เพิ่ม​กฎ​ใหม่​ได้​โดย​ไม่​ต้อง​แก้ method เดิม (สอดคล้อง​กับ Open-Closed Principle):

public interface IDiscountRule
{
bool AppliesTo(Order order);
decimal Calculate(Order order);
}
public class DiscountCalculator
{
private readonly IEnumerable<IDiscountRule> _rules;
public DiscountCalculator(IEnumerable<IDiscountRule> rules) => _rules = rules;
public decimal CalculateDiscount(Order order) =>
_rules.FirstOrDefault(r => r.AppliesTo(order))?.Calculate(order) ?? 0m;
}

การ refactor นี้​เกิด “ตอน​ที่​เจ็บ​จริง” ไม่ใช่​ตอน​เขียน​กฎ​แรก — ทำให้ abstraction ที่​ได้​ตรง​กับ​กฎ​ส่วนลด​จริง​สี่​ห้า​แบบ​ที่​มี​อยู่ แทนที่​จะ​เป็น interface ที่​เดา​ไว้​ล่วงหน้า​และ​ใช้​ไม่​ตรง​กับ​ของ​จริง

ทีม​จำนวน​มาก​ยัง​ใช้​ตาราง effort เทียบ impact เพื่อ​จัด​คิว pain ที่​ควร​แก้​ก่อน:

Effort ใน​การ​แก้Impact ต่อ​ทีม/ลูกค้าแนวทาง
ต่ำสูงแก้​ทันที — คุ้ม​ค่า​ชัดเจน
สูงสูงวางแผน refactor เป็น​งาน​ถัด​ไป
ต่ำต่ำแก้​ระหว่าง​ทาง​ถ้า​มี​เวลา (boy scout rule)
สูงต่ำปล่อย​ไว้​ก่อน จนกว่า impact จะ​เพิ่ม​ขึ้น​จริง

ประโยชน์

  • หลีก​เลี่ยง gold-plating และ Speculative Generality — code ไม่​ถูก​ถ่วง​ด้วย abstraction ที่​ไม่มี​ใคร​ใช้​จริง
  • ทุ่ม​แรง​ไป​กับ​ปัญหา​ที่​พิสูจน์​แล้ว​ว่า​มี​คุณค่า แทนที่​จะ​เดา​อนาคต ซึ่ง Fowler เคย​อ้างอิง​งาน​วิจัย​ว่า​มี​เพียง​ราว​หนึ่ง​ใน​สาม​ของ feature ที่​วางแผน​ล่วงหน้า​อย่าง​พิถีพิถัน​เท่านั้น​ที่​ให้​ผล​ตาม​ที่​คาด​ไว้​จริง
  • abstraction ที่​ได้​มัก​ตรง​กับ​ปัญหา​จริง​มากกว่า เพราะ​ออกแบบ​จาก​รูปแบบ​ที่​เห็น​ซ้ำ ไม่ใช่​จาก​การ​คาด​เดา
  • code ยัง​คง​อ่าน​ง่าย​สำหรับ​ความ​ต้องการ​ปัจจุบัน ไม่มี indirection ที่​ทำให้​ตาม​งาน​ยาก

ข้อ​ควร​ระวัง

  • pain บาง​ชนิด​แก้​ยาก​เกิน​กว่า​จะ​รอ โดย​เฉพาะ pain จาก​โครงสร้าง dependency หรือ layering ที่​วาง​ผิด​ตั้งแต่​ต้น project — ถ้า​รู้​จาก​ประสบการณ์​ว่า​จะ​ต้อง​แยก layer แน่ ๆ การ​ป้องกัน​ไว้​ก่อน​ไม่​ถือว่า​ละเมิด YAGNI
  • ถ้า​ไม่มี​วินัย​ใน refactor เมื่อ pain ปรากฏ code จะ​สะสม​เป็น​หนี้​ทาง​เทคนิค​จนถึง​จุด​ที่​เขียน​ใหม่​ถูก​กว่า​แก้ไข — PDD ไม่ใช่​ข้อ​อ้าง​ให้ “ไม่​แก้”
  • ใน​ระดับ​องค์กร บทความ​ของ Brave New Geek ชี้​ว่า pain-driven decision ที่​แต่ละ​ทีม​เลือก​ทาง​ลด​ความ​เจ็บ​ของ​ตัวเอง​ใน​ระยะ​สั้น (เช่น เพิ่ม timeout เพิ่ม​ฮาร์ดแวร์) อาจ​เป็น​เพียง​การ “ย้าย” ความ​เจ็บ​ไป​ให้​ทีม​อื่น​หรือ​ลูกค้า​แทนที่​จะ​แก้​จริง — เป็น greedy algorithm ที่​ดี​ใน​ระดับ​ทีม​แต่​ไม่ optimal ใน​ระดับ​องค์กร ควร​มอง​ภาพ​รวม​ประกอบ​ด้วย ไม่ใช่​แก้​เฉพาะ​จุด​ที่​ตัวเอง​เจ็บ
  • ต้อง​แยก​ให้​ออก​ระหว่าง “รอ​ดู​ก่อน” กับ “เพิกเฉย​ต่อ​สัญญาณ​เตือน” — ถ้า​เพิกเฉย​นาน​เกิน​ไป กลิ่น code เล็ก ๆ อาจ​กลาย​เป็น Big Ball of Mud

ใช้​หลักการ​เมื่อ​ความ​เจ็บ​ปรากฏ ไม่ใช่​เพราะ​กลัว​ว่า​จะ​เจ็บ — เว้น​แต่​ความ​เจ็บ​นั้น​จะ​แก้​ทีหลัง​แทบ​ไม่​ได้