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 ที่จำเป็น สะสมเป็นหนี้ทางเทคนิคเพิ่มขึ้นเรื่อย ๆ
- ทดสอบยาก — ต้องเขียนเทสครอบคลุมทุกจุดที่กระจายอยู่ เพื่อจับให้ได้ว่าจุดใดจุดหนึ่งลืมอัปเดต
ตัวอย่างและการ refactor
หัวข้อที่มีชื่อว่า “ตัวอย่างและการ refactor”สมมติระบบมีกฎ “ราคาสมาชิก VIP ได้ส่วนลด 15%” แต่กฎนี้ถูกกระจาย (duplicate) ไว้ในสามที่: หน้าตะกร้าสินค้า, ใบแจ้งหนี้ และรายงานยอดขาย เมื่อธุรกิจเปลี่ยนส่วนลดเป็น 20% ต้องไล่แก้ทั้งสามจุด — นี่คือ Shotgun Surgery
// ก่อนแก้: กฎส่วนลด VIP กระจายซ้ำใน3 classpublic 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 Methodpublic 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 แนะนำ:
- Move Method / Move Field — ย้าย behavior ที่กระจัดกระจายให้มารวมอยู่ใน class เดียว หากยังไม่มี class ที่เหมาะสม ให้สร้าง class ใหม่ขึ้นมารองรับ (เช่น
VipPricingPolicyด้านบน) - Inline Class — ถ้าหลังย้ายแล้ว class เดิมแทบว่างเปล่า (มีแต่ field/method ที่ถูกย้ายออกไปหมดแล้ว) ให้ยุบ class นั้นรวมเข้ากับ class อื่นเพื่อลด indirection ที่ไม่จำเป็น
- หากทีมกำลังไล่แก้ 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 กระจายคล้ายกัน