Divergent Change
class เดียวถูกแก้ด้วยเหตุผลหลายอย่างที่ต่างกัน
กลิ่นนี้คืออะไร
หัวข้อที่มีชื่อว่า “กลิ่นนี้คืออะไร”Divergent Change เกิดเมื่อ class เดียวเป็นเป้าของการเปลี่ยนแปลงหลายชนิด ที่ขับเคลื่อนด้วยเหตุผลต่างกันโดยสิ้นเชิง เช่น class หนึ่งต้องถูกแก้ทุกครั้งที่ schema ของฐานข้อมูลเปลี่ยน ทุกครั้งที่กฎธุรกิจเปลี่ยน และทุกครั้งที่รูปแบบการแสดงผลบน UI เปลี่ยน — สามเหตุผลที่ไม่เกี่ยวข้องกันเลย แต่ดันมาบรรจบกันใน class เดียว
ชื่อ “divergent” (แตกกระจาย) สื่อถึงทิศทางของการเปลี่ยนแปลง ไม่ใช่จำนวน file: การแก้ไข “แตกกระจายออกไปหลายทิศ” จากจุดศูนย์กลางเดียวกัน class นั้นไม่ได้ทำหน้าที่เดียวที่ชัดเจน แต่กลายเป็นที่รวมของหลายความรับผิดชอบที่บังเอิญมาอยู่ด้วยกัน
Martin Fowler อธิบายไว้ตรงไปตรงมาว่า เมื่อคุณต้องเปลี่ยน class หนึ่ง คุณอยากเข้าไปแก้เฉพาะส่วนที่เกี่ยวกับการเปลี่ยนแปลงนั้น โดยไม่ต้องพะวงกับ code ข้างเคียงที่ไม่เกี่ยวข้อง แต่ถ้า class นั้นเจอ Divergent Change ทุกครั้งที่แก้ คุณจะต้องงมหาว่า method ไหนเกี่ยวกับอะไร เพราะเหตุผลของการเปลี่ยนแปลงแต่ละครั้งพันกันอยู่ใน file เดียว
กลิ่นนี้เป็นด้านตรงข้ามของ Shotgun Surgery: Divergent Change คือ “การเปลี่ยนแปลงหลายชนิดกระทบ class เดียว” ส่วน Shotgun Surgery คือ “การเปลี่ยนแปลงชนิดเดียวกระทบหลาย class” ทั้งสองเป็นอาการของปัญหาเดียวกันคือ ไม่มีความสอดคล้องแบบหนึ่งต่อหนึ่งระหว่าง “เหตุผลที่ต้องเปลี่ยน” กับ “class ที่ต้องแก้” — และทั้งคู่ล้วนเป็นการละเมิด Single Responsibility Principle (SRP) ที่ว่า class ควรมีเหตุผลให้เปลี่ยนแปลงเพียงเหตุผลเดียว
วิธีสังเกต
หัวข้อที่มีชื่อว่า “วิธีสังเกต”- เมื่อต้องเพิ่ม feature เล็ก ๆ อย่างหนึ่ง (เช่น product type ใหม่) กลับต้องไปแก้ method หลายตัวที่ไม่เกี่ยวข้องกันใน class เดียว เช่น method find, display และ order ของสินค้า
- ชื่อของ commit หรือ pull request ที่แก้ class นี้ มีเหตุผลหลากหลายไม่ซ้ำกันเลยตลอดประวัติ — บางครั้งเพราะ “แก้ schema” บางครั้งเพราะ “ปรับกฎคำนวณ” บางครั้งเพราะ “เปลี่ยน format การแสดงผล”
- เวลาอธิบายว่า class นี้ “รับผิดชอบอะไร” คำตอบต้องใช้คำว่า “และ” ต่อกันหลายรอบ (เช่น “อ่านข้อมูล และ คำนวณ และ จัดรูปแบบ”)
- class มีขนาดใหญ่ขึ้นเรื่อย ๆ เพราะทุก feature ใหม่ถูกเสียบเข้าไปใน class เดิม แทนที่จะแยกออกเป็น class ใหม่
- เวลาจะแก้ไขส่วนหนึ่ง นักพัฒนาต้องอ่านทำความเข้าใจทั้ง class เพราะ method ต่าง ๆ พึ่งพา field ร่วมกันแบบไม่มีเหตุผลเชิง domain (สัญญาณของ low cohesion)
ทำไมถึงเป็นปัญหา
หัวข้อที่มีชื่อว่า “ทำไมถึงเป็นปัญหา”สาเหตุที่พบบ่อยของ Divergent Change คือโครงสร้างโปรแกรมที่ไม่ดีตั้งแต่แรก หรือการเขียน code แบบ “copy-paste programming” ที่ทำให้ความรับผิดชอบใหม่ ๆ ถูกแปะเพิ่มเข้าไปใน class เดิมเรื่อย ๆ แทนที่จะแยกออก
ผลกระทบที่ตามมา:
- เสี่ยงต่อ regression สูง — การแก้เพื่อเหตุผลหนึ่ง (เช่น เปลี่ยน UI format) มีโอกาสไปกระทบ code ส่วนที่รับผิดชอบเหตุผลอื่น (เช่น การคำนวณ) โดยไม่ได้ตั้งใจ เพราะทุกอย่างอยู่ใน scope เดียวกัน
- test เปราะบาง — test ของ class นี้ต้อง setup บริบทของทุกความรับผิดชอบพร้อมกัน ทำให้ยากต่อการเขียน test ที่โฟกัสเฉพาะจุด
- Merge conflict บ่อย — ถ้าเป็นทีมที่มีหลายคน แต่ละคนแก้ class เดียวกันด้วยเหตุผลคนละเรื่อง โอกาสชนกันใน file เดียวกันจึงสูง
- Cohesion ต่ำ — method และ field ใน class ไม่ได้เกี่ยวข้องกันในเชิงแนวคิดเดียว ทำให้ตั้งชื่อ class ให้สื่อความหมายได้ยาก และเข้าใจภาพรวมได้ยากขึ้นตามเวลา
- ในทางระยะยาว class แบบนี้มักเติบโตกลายเป็น Blob หรือ Large Class ที่แตะไม่ได้ เพราะไม่มีใครกล้าแยกออกอีกต่อไป
ตัวอย่างและการ refactor
หัวข้อที่มีชื่อว่า “ตัวอย่างและการ refactor”สมมติมี class OrderReport ที่ต้องถูกแก้ทุกครั้งที่ query ข้อมูลจากฐานข้อมูลเปลี่ยน ทุกครั้งที่สูตรคำนวณส่วนลดเปลี่ยน และทุกครั้งที่รูปแบบการแสดงผลเปลี่ยน — นี่คือ Divergent Change ชัด ๆ
// ก่อน refactor: 1 class สามเหตุผลให้เปลี่ยนpublic class OrderReport{ private readonly SqlConnection _connection;
public OrderReport(SqlConnection connection) { _connection = connection; }
// เหตุผลที่ 1: schema ฐานข้อมูลเปลี่ยน ต้องแก้ตรงนี้ public List<OrderRow> FetchOrders(DateTime from, DateTime to) { var rows = new List<OrderRow>(); using var cmd = new SqlCommand( "SELECT Id, CustomerId, Total, CreatedAt FROM Orders " + "WHERE CreatedAt BETWEEN @from AND @to", _connection); cmd.Parameters.AddWithValue("@from", from); cmd.Parameters.AddWithValue("@to", to); using var reader = cmd.ExecuteReader(); while (reader.Read()) { rows.Add(new OrderRow(reader.GetInt32(0), reader.GetInt32(1), reader.GetDecimal(2), reader.GetDateTime(3))); } return rows; }
// เหตุผลที่ 2: กฎธุรกิจเรื่องส่วนลดเปลี่ยน ต้องแก้ตรงนี้ public decimal ApplyLoyaltyDiscount(decimal total, int ordersCountThisYear) { if (ordersCountThisYear > 20) return total * 0.85m; if (ordersCountThisYear > 10) return total * 0.90m; return total; }
// เหตุผลที่ 3: รูปแบบรายงานเปลี่ยน ต้องแก้ตรงนี้ public string RenderAsHtml(List<OrderRow> rows) { var sb = new StringBuilder("<table><tr><th>Id</th><th>Total</th></tr>"); foreach (var row in rows) { sb.Append($"<tr><td>{row.Id}</td><td>{row.Total:C}</td></tr>"); } sb.Append("</table>"); return sb.ToString(); }}ปัญหา: การเปลี่ยน query SQL, การปรับสูตรส่วนลด และการเปลี่ยน HTML template ล้วนแก้ file เดียวกัน คนละทีมอาจต้องมาชนกันใน file นี้ตลอดเวลา
ใช้ Extract Class แยกแต่ละเหตุผลออกเป็น class ของตัวเอง ตามหลักที่ Fowler สรุปไว้ว่า “การเปลี่ยนแปลงเพื่อรองรับกรณีหนึ่ง ควรเปลี่ยนเพียง class เดียว”:
// หลัง refactor: แยกตามเหตุผลที่เปลี่ยน — คนละ class คนละ filepublic class OrderRepository{ private readonly SqlConnection _connection;
public OrderRepository(SqlConnection connection) => _connection = connection;
// เหตุผลเดียว: การเข้าถึงข้อมูล public List<OrderRow> FetchOrders(DateTime from, DateTime to) { var rows = new List<OrderRow>(); using var cmd = new SqlCommand( "SELECT Id, CustomerId, Total, CreatedAt FROM Orders " + "WHERE CreatedAt BETWEEN @from AND @to", _connection); cmd.Parameters.AddWithValue("@from", from); cmd.Parameters.AddWithValue("@to", to); using var reader = cmd.ExecuteReader(); while (reader.Read()) { rows.Add(new OrderRow(reader.GetInt32(0), reader.GetInt32(1), reader.GetDecimal(2), reader.GetDateTime(3))); } return rows; }}
public class LoyaltyDiscountPolicy{ // เหตุผลเดียว: กฎธุรกิจเรื่องส่วนลด public decimal Apply(decimal total, int ordersCountThisYear) { if (ordersCountThisYear > 20) return total * 0.85m; if (ordersCountThisYear > 10) return total * 0.90m; return total; }}
public class OrderReportHtmlRenderer{ // เหตุผลเดียว: รูปแบบการแสดงผล public string Render(List<OrderRow> rows) { var sb = new StringBuilder("<table><tr><th>Id</th><th>Total</th></tr>"); foreach (var row in rows) { sb.Append($"<tr><td>{row.Id}</td><td>{row.Total:C}</td></tr>"); } sb.Append("</table>"); return sb.ToString(); }}ตอนนี้ OrderRepository, LoyaltyDiscountPolicy และ OrderReportHtmlRenderer แต่ละตัวมีเหตุผลให้เปลี่ยนเพียงเหตุผลเดียว ตรงตาม SRP — ทีมที่ดูแล schema ฐานข้อมูล ทีมที่ดูแลกฎธุรกิจ และทีมที่ดูแล UI สามารถทำงานคนละ file โดยแทบไม่ชนกันเลย
ถ้าความแตกต่างเกิดจาก “หลาย class ที่พฤติกรรมคล้ายกันแต่ต่างกันในรายละเอียดย่อย” (เช่น รายงานหลายชนิดที่ fetch/format ต่างกันเล็กน้อย) ให้ใช้ Extract Superclass หรือ Extract Subclass เพื่อดึงส่วนที่เหมือนกันขึ้นเป็น base class แล้วปล่อยให้ subclass แต่ละตัวรับผิดชอบเฉพาะส่วนที่ต่าง
classDiagram
class OrderReport {
+FetchOrders()
+ApplyLoyaltyDiscount()
+RenderAsHtml()
}
class OrderRepository {
+FetchOrders()
}
class LoyaltyDiscountPolicy {
+Apply()
}
class OrderReportHtmlRenderer {
+Render()
}
OrderReport ..> OrderRepository : Extract Class
OrderReport ..> LoyaltyDiscountPolicy : Extract Class
OrderReport ..> OrderReportHtmlRenderer : Extract Class
ที่เกี่ยวข้อง
หัวข้อที่มีชื่อว่า “ที่เกี่ยวข้อง”- Single Responsibility Principle — หลักการที่ Divergent Change ละเมิดโดยตรง
- Shotgun Surgery — กลิ่นตรงข้าม: การเปลี่ยนแปลงชนิดเดียวกระทบหลาย class
- Separation of Concerns — หลักการที่ใช้ตัดสินว่าอะไรควรแยกออกจากกัน
- Blob — จุดจบของ class ที่ปล่อยให้ Divergent Change สะสมนานเกินไป
- Feature Envy — อีกกลิ่นที่มักโผล่คู่กันเมื่อ cohesion ต่ำ
- Encapsulation — การห่อหุ้มความรับผิดชอบที่ดีช่วยป้องกันกลิ่นนี้ตั้งแต่ต้น