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

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 ที่​แตะ​ไม่​ได้ เพราะ​ไม่มี​ใคร​กล้า​แยก​ออก​อีก​ต่อ​ไป

สมมติ​มี 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 คนละ file
public 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 — การ​ห่อ​หุ้ม​ความ​รับผิดชอบ​ที่​ดี​ช่วย​ป้องกัน​กลิ่น​นี้​ตั้งแต่​ต้น