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

Shotgun Surgery

การ​เปลี่ยน​เชิง​ตรรกะ​ครั้ง​เดียว บังคับ​ให้​แก้​กระจาย​ไป​หลาย file

Shotgun Surgery คือ​กลิ่น​ที่ การ​เปลี่ยนแปลง​เชิง​ตรรกะ​ครั้ง​เดียว (แก้​กฎ​ธุรกิจ​หนึ่ง​ข้อ อัปเดต​รูปแบบ​ข้อมูล​หนึ่ง​อย่าง) บังคับ​ให้​ต้อง แก้​เล็ก ๆ กระจาย​ไป​ทั่ว​หลาย class หรือ file พร้อม​กัน — เหมือน​กระสุน​ลูกซอง​ที่​ยิง​ที​เดียว​เป็น​รู​พรุน​ไป​ทั่ว​ทั้ง​ลำ​ตัว ไม่ใช่​กระสุนปืน​ไรเฟิล​นัด​เดียว​ที่​เจาะ​จุด​เดียว

Martin Fowler นิยาม​ไว้​ใน​หนังสือ Refactoring ว่า “a single change that needs to be applied to multiple classes at the same time” (การ​เปลี่ยนแปลง​ครั้ง​เดียว​ที่​ต้อง​นำ​ไป​ใช้​กับ​หลาย class พร้อม​กัน) และ​จัด​อยู่​ใน​กลุ่ม Change Preventers — กลิ่น​ที่​ทำให้การ​เปลี่ยนแปลง code ใน​อนาคต​ยาก​และ​เสี่ยง​กว่า​ที่​ควร

กลิ่น​นี้​เป็น ด้าน​ตรง​ข้าม​ของ Divergent Change โดยตรง:

  • Divergent Change: 1 class เปลี่ยน​บ่อย​ด้วย หลาย​เหตุผล ที่​ไม่​เกี่ยว​กัน
  • Shotgun Surgery: หนึ่ง​เหตุผล บังคับ​ให้ หลาย class ต้อง​เปลี่ยน​พร้อม​กัน

ทั้ง​สอง​บ่ง​ชี้​ปัญหา​เดียวกัน​คือ​การ​จัดสรร responsibility ที่​ไม่​ดี เพียง​มอง​จาก​คนละ​มุม และ​น่า​สนใจ​ที่​ทั้ง​สอง​อาจ​เกิด​จาก​กัน​และ​กัน — การ​ไล่​แก้ Divergent Change แบบ​สุด​โต่ง (แตก class ใหญ่​ออก​เป็น class เล็ก​มาก​เกิน​ไป) มัก​ทิ้ง​ร่องรอย​เป็น Shotgun Surgery ตาม​มา เพราะ responsibility เดียว​ถูก​กระจาย​ไป​อยู่​ใน class ย่อย​หลาย​ตัว​เกิน​ไป

สัญญาณ​ที่​บอกว่า​กำลัง​เจอ Shotgun Surgery:

  • ทุก​ครั้ง​ที่​ต้อง “แก้​กฎ​ธุรกิจ​ข้อ​เดียว” คุณ​ต้อง​เปิด file มากกว่า 3-4 file ที่​ไม่​ได้​อยู่​ใน folder เดียวกัน
  • Pull Request เล็ก ๆ ที่​ควร​จะ​เป็น diff บรรทัด​เดียว กลับ​กลาย​เป็น diff กระจาย​อยู่​ใน 6-7 file ด้วย​การ​เปลี่ยนแปลง​คล้าย ๆ กัน​ซ้ำ ๆ
  • ทีม​มี checklist ที่​ต้อง​ท่องจำ​ว่า “เวลา​เพิ่ม field ใหม่ ต้อง​ไป​แก้​ที่ A, B, C, D, E ด้วย​นะ” — ถ้า​ลืม​ข้อ​ใด​ข้อ​หนึ่ง​จะ​พัง
  • code รีวิว​มัก​พบ​ว่า​มี​คน​ลืม​แก้​ที่​ใด​ที่​หนึ่ง​จน​เกิด bug (inconsistency) เพราะ​จุด​ที่​ต้อง​แก้​กระจาย​เกิน​จะ​จำ​ได้
  • มี logic เดียวกัน (เช่น validation, mapping, format) copy-paste ซ้ำ​อยู่​ใน​หลาย class ทำให้​ต้อง​แก้​ทุก​ที่​พร้อม​กัน​เมื่อ​กฎ​เปลี่ยน — ทับซ้อน​กับ Duplicate Code
  • เสี่ยง​ต่อ​ความ​ไม่​สอดคล้อง​กัน​ของ​ข้อมูล (inconsistency) — ถ้า​แก้​ไม่​ครบ​ทุก​จุด ระบบ​จะ​มี​พฤติกรรม​ขัดแย้ง​กันเอง เช่น การ​คำนวณ​ราคา​ที่​จุด​หนึ่ง​ใช้​กฎ​เก่า อีก​จุด​ใช้​กฎ​ใหม่
  • เพิ่ม​ต้นทุน​และ​ความ​เสี่ยง​ของ​ทุก​การ​เปลี่ยนแปลง — งาน​ที่​ควร​ใช้​เวลา 5 นาที กลาย​เป็น​ต้อง​ไล่​หา file ที่​เกี่ยวข้อง​ทั่ว​ทั้ง codebase และ​ทดสอบ regression ใน​หลาย​จุด
  • ขัด​กับ​หลักการ Single Responsibility — ถ้า responsibility หนึ่ง​เดียว (เช่น “กฎ​การ​คิด​ส่วนลด”) ถูก​จัด​วาง​ไว้​ใน​ที่​เดียว การ​เปลี่ยน​กฎ​นั้น​ควร​แก้​ที่​เดียว การ​ที่​ต้อง​แก้​หลาย​ที่​แปล​ว่า responsibility ถูก​กระจาย​ผิด​ที่​ตั้งแต่​แรก
  • บั่นทอน​ความ​มั่นใจ​ของ​ทีม — เมื่อ​รู้​ว่าการ​แก้​เล็ก ๆ มี​โอกาส​พลาด​สูง ทีม​จะ​กลัว​การ​เปลี่ยนแปลง (change aversion) และ​อาจ​นำ​ไป​สู่​การ​หลีก​เลี่ยง refactor ที่​จำเป็น สะสม​เป็น​หนี้​ทาง​เทคนิค​เพิ่ม​ขึ้น​เรื่อย ๆ
  • ทดสอบ​ยาก — ต้อง​เขียน​เทส​ครอบคลุม​ทุก​จุด​ที่​กระจาย​อยู่ เพื่อ​จับ​ให้​ได้​ว่า​จุด​ใด​จุด​หนึ่ง​ลืม​อัปเดต

สมมติ​ระบบ​มี​กฎ “ราคา​สมาชิก VIP ได้​ส่วนลด 15%” แต่​กฎ​นี้​ถูก​กระจาย (duplicate) ไว้​ใน​สาม​ที่: หน้า​ตะกร้า​สินค้า, ใบ​แจ้ง​หนี้ และ​รายงาน​ยอด​ขาย เมื่อ​ธุรกิจ​เปลี่ยน​ส่วนลด​เป็น 20% ต้อง​ไล่​แก้​ทั้ง​สาม​จุด — นี่​คือ Shotgun Surgery

// ก่อนแก้: กฎส่วนลด VIP กระจายซ้ำใน3 class
public class ShoppingCart
{
public decimal CalculateTotal(Customer customer, decimal subtotal)
{
if (customer.MembershipLevel == MembershipLevel.Vip)
{
return subtotal * 0.85m; // ลด 15%
}
return subtotal;
}
}
public class InvoiceGenerator
{
public decimal GetInvoiceAmount(Customer customer, decimal subtotal)
{
if (customer.MembershipLevel == MembershipLevel.Vip)
{
return subtotal * 0.85m; // ลด 15% (ก็อปมาจาก ShoppingCart)
}
return subtotal;
}
}
public class SalesReportBuilder
{
public decimal ProjectedRevenue(Customer customer, decimal subtotal)
{
if (customer.MembershipLevel == MembershipLevel.Vip)
{
return subtotal * 0.85m; // ลด 15% (ก็อปอีกรอบ)
}
return subtotal;
}
}

ถ้า​วัน​หนึ่ง​ฝ่าย​ธุรกิจ​เปลี่ยน​ส่วนลด​เป็น 20% นัก​พัฒนา​ต้อง​จำ​ให้​ได้​ว่า​มี​อยู่ 3 จุด (หรือ​มากกว่า​นั้น​ถ้า codebase ใหญ่​ขึ้น) — พลาด​จุด​เดียว​ก็​เกิด​ราคา​ไม่​ตรง​กัน​ระหว่าง​หน้า​เว็บ​กับ​ใบ​แจ้ง​หนี้​ทันที

ใช้ Move Method และ Extract Class รวม logic ของ “นโยบาย​ราคา VIP” ให้​อยู่​ใน​ที่​เดียว แล้ว​ให้​ทุก​จุด​เรียก​ใช้​จาก​ที่​นั่น:

// หลังแก้: รวมกฎส่วนลดไว้ใน class เดียว ด้วย Move Method
public class VipPricingPolicy
{
private const decimal VipDiscountRate = 0.20m; // แก้ที่เดียว จุดเดียว
public decimal ApplyDiscount(Customer customer, decimal subtotal)
{
return customer.MembershipLevel == MembershipLevel.Vip
? subtotal * (1 - VipDiscountRate)
: subtotal;
}
}
public class ShoppingCart
{
private readonly VipPricingPolicy _pricingPolicy;
public ShoppingCart(VipPricingPolicy pricingPolicy) => _pricingPolicy = pricingPolicy;
public decimal CalculateTotal(Customer customer, decimal subtotal) =>
_pricingPolicy.ApplyDiscount(customer, subtotal);
}
public class InvoiceGenerator
{
private readonly VipPricingPolicy _pricingPolicy;
public InvoiceGenerator(VipPricingPolicy pricingPolicy) => _pricingPolicy = pricingPolicy;
public decimal GetInvoiceAmount(Customer customer, decimal subtotal) =>
_pricingPolicy.ApplyDiscount(customer, subtotal);
}
public class SalesReportBuilder
{
private readonly VipPricingPolicy _pricingPolicy;
public SalesReportBuilder(VipPricingPolicy pricingPolicy) => _pricingPolicy = pricingPolicy;
public decimal ProjectedRevenue(Customer customer, decimal subtotal) =>
_pricingPolicy.ApplyDiscount(customer, subtotal);
}

ตอน​นี้​เมื่อ​กฎ​ส่วนลด​เปลี่ยน แก้​ที่ VipPricingPolicy จุด​เดียว​ก็​พอ ทั้ง3 class ที่​เรียก​ใช้​ไม่​ต้อง​แตะ​เลย

ขั้นตอน​แนวทาง​แก้​ตาม​ที่ refactoring.guru และ SourceMaking แนะนำ:

  1. Move Method / Move Field — ย้าย behavior ที่​กระจัดกระจาย​ให้​มา​รวม​อยู่​ใน class เดียว หาก​ยัง​ไม่มี class ที่​เหมาะสม ให้​สร้าง class ใหม่​ขึ้น​มารองรับ (เช่น VipPricingPolicy ด้าน​บน)
  2. Inline Class — ถ้า​หลัง​ย้าย​แล้ว class เดิม​แทบ​ว่างเปล่า (มี​แต่ field/method ที่​ถูก​ย้าย​ออก​ไป​หมด​แล้ว) ให้​ยุบ class นั้น​รวม​เข้า​กับ class อื่น​เพื่อ​ลด indirection ที่​ไม่​จำเป็น
  3. หาก​ทีม​กำลัง​ไล่​แก้ Divergent Change มา​ก่อนหน้า​นี้ ให้​ระวัง​ไม่​แตก class ละเอียด​เกิน​ไป​จน​ทำให้​เกิด Shotgun Surgery สลับ​ข้าง — เป้าหมาย​คือ “หนึ่ง​เหตุผล​ใน​การ​เปลี่ยนแปลง คู่​กับ​หนึ่ง​จุด​ที่​ต้อง​แก้”

แผนภาพ​สรุป​การ​รวม responsibility:

flowchart LR
    Rule[กฎ VIP Discount หนึ่งเดียว]
    Cart[ShoppingCart]
    Invoice[InvoiceGenerator]
    Report[SalesReportBuilder]
    Policy[VipPricingPolicy]

    Cart -.ก่อนแก้ มีกฎซ้ำ.-> Rule
    Invoice -.ก่อนแก้ มีกฎซ้ำ.-> Rule
    Report -.ก่อนแก้ มีกฎซ้ำ.-> Rule

    Cart --> Policy
    Invoice --> Policy
    Report --> Policy
    Policy --> Rule
  • Divergent Change — กลิ่น​ด้าน​ตรง​ข้าม (1 class เปลี่ยน​ด้วย​หลาย​เหตุผล)
  • Duplicate Code — สาเหตุ​ที่​พบ​บ่อย​ของ Shotgun Surgery คือ logic เดียวกัน​ถู​กก็อปกระจาย
  • Single Responsibility — หลักการ​ที่ Shotgun Surgery ละเมิด​อยู่
  • Feature Envy — อีก​กลิ่น​ที่​ชี้​ว่า behavior อยู่​ผิด class
  • Facade — รูปแบบ​ที่​ช่วย​รวม​จุด​เรียก​ใช้​ให้​เหลือ​ทาง​เดียว ลด​จำนวน​จุด​ที่​ต้อง​แก้​เมื่อ logic ภายใน​เปลี่ยน
  • Flags Over Objects — antipattern ที่​มัก​ทำให้​เกิด​การ​แตก​แขนง logic กระจาย​คล้าย​กัน