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

Data Clumps

กลุ่ม​ข้อมูล​ที่​ปรากฏ​ด้วย​กัน​ซ้ำ ๆ ทั่ว codebase

Data Clumps เกิด​เมื่อ กลุ่ม​ข้อมูล​ชุด​เดิม​ปรากฏ​ด้วย​กัน​ซ้ำ ๆ ใน​การ​ประกาศ field, parameter ของ method หรือ​ตัวแปร​ท้องถิ่น หาก​คุณ​เห็น street, city, state, zip เดินทาง​ด้วย​กัน​เสมอ หรือ startDate/endDate โผล่​มา​คู่​กัน​ใน​หลาย ๆ signature นั่น​คือ​สัญญาณ​ว่า​ข้อมูล​กลุ่ม​นี้ “อยาก​จะ” เป็น type เดียวกัน แต่ codebase ยัง​ไม่​ได้​ตั้ง​ชื่อ​ให้​มัน​อย่าง​เป็น​ทางการ

Martin Fowler อธิบาย​ไว้​ใน​หนังสือ Refactoring ด้วย​ประโยค​ติด​หู​ว่า ทุก​ครั้ง​ที่​เห็น​ค่า​สอง​สาม​ค่า​เดินทาง​มา​ด้วย​กัน ให้​เปลี่ยน​มัน​เป็น object เสีย ตัวอย่าง​คลาสสิก​ที่สุด​คือ​คู่ start/end ที่​ควร​กลาย​เป็น Range แทนที่​จะ​เป็น primitive สอง​ตัว​ลอย ๆ

กลิ่น​นี้​มัก​เกิด​จาก​การ​ออกแบบ​ที่​ยัง​ไม่​รอบคอบ​ตั้งแต่​แรก หรือ​จาก​การ copy-paste code ข้าม file ไป​เรื่อย ๆ จน​กลุ่ม​ข้อมูล​เดิม​กระจาย​อยู่​ทั่ว​ระบบ​โดย​ไม่มี​ใคร​หยุด​ตั้ง​ชื่อ​ให้​มัน​สัก​ที มัน​เป็น​ญาติ​สนิท​ของ Primitive Obsession — Primitive Obsession คือ​การ​ใช้ primitive แทนที่​จะ​ใช้ type ที่​มี​ความหมาย ส่วน Data Clumps คือ​สัญญาณ​เฉพาะ​เจาะจง​กว่า​ที่​บอกว่า primitive กลุ่ม​ไหน​บ้าง​ที่​ควร​ถูกรวม​ร่าง​กัน

refactoring.guru และ SourceMaking เสนอ​วิธี​ทดสอบ​ง่าย ๆ ที่​ใช้ได้​ผล​จริง: ลอง​ลบ​ค่าตัว​ใด​ตัว​หนึ่ง​ใน​กลุ่ม​ออก แล้ว​ดู​ว่า​ค่าที่​เหลือ​ยัง​มี​ความหมาย​สมบูรณ์​อยู่​ไหม ถ้า​คำ​ตอบ​คือ “ไม่” — เช่น ลบ city ออก​จาก (street, city, state, zip) แล้ว​ที่​อยู่​ที่​เหลือ​ใช้งาน​ไม่​ได้​จริง — นั่น​คือ​หลักฐาน​ว่า​ค่า​ชุด​นี้​ควร​ถูกรวม​เป็น object เดียว

สัญญาณ​อื่น ๆ ที่​ควร​จับตา:

  • field ชุด​เดิม​ประกาศ​ซ้ำ​ใน​หลาย class (เช่น​ทั้ง Customer และ Order ต่าง​ก็​มี street, city, zip ของ​ตัวเอง)
  • signature ของ​หลาย method มี parameter ชุด​เดียวกัน​เรียง​ต่อ​กัน​ซ้ำ ๆ (อาการ​นี้​ทับซ้อน​กับ Long Parameter List)
  • เวลา​แก้ไข logic ที่​เกี่ยว​กับ​กลุ่ม​ข้อมูล​นี้ ต้อง​ไล่​แก้​หลาย​จุด​พร้อม​กัน​เสมอ
  • มี​การ validate ความ​สัมพันธ์​ระหว่าง​ค่า​เหล่า​นี้ (เช่น startDate <= endDate) กระจาย​ซ้ำ​อยู่​หลาย​ที่​แทนที่​จะ​อยู่​ที่​เดียว

Data Clumps ไม่​ได้​ทำให้​โปรแกรม​พัง​ทันที แต่​มัน​สะสม “หนี้” หลาย​ชั้น:

  1. ไม่มี​ที่​เดียว​สำหรับ invariant — กติกา​อย่าง “zip ต้อง​เป็น​ตัวเลข 5 หลัก” หรือ “endDate ต้อง​มา​หลัง startDate” ไม่มี​บ้าน​อยู่ ทำให้​ต้อง copy validation logic ซ้ำ​ทุก​จุด​ที่​ใช้​กลุ่ม​ข้อมูล​นี้ หรือ​แย่​กว่า​นั้น​คือ​ลืม validate ใน​บาง​จุด
  2. แก้ไข​ยาก เปลี่ยน​ที่​เดียว​ต้อง​ตาม​แก้​หลาย​ที่ — ถ้า​จะ​เพิ่ม field country เข้าไป​ใน​ที่​อยู่ ต้อง​ไล่​แก้​ทุก method signature และ​ทุก class ที่​มี​กลุ่ม​ข้อมูล​นี้ ซึ่ง​เป็น​อาการ​ที่​ใกล้​เคียง​กับ Shotgun Surgery
  3. Signature อ่าน​ยาก parameter เรียง​กัน​ยาว — เมื่อ parameter กลาย​เป็น string, string, string, string ผู้​เรียก​ใช้​เสี่ยง​ส่ง​ค่า​ผิด​ลำดับ​โดย compiler ตรวจ​ไม่​พบ เพราะ type ของ​ทุก​ตัว​เหมือน​กัน​หมด
  4. โอกาส​พลาด​สูง​ขึ้น — ไม่มี type system ช่วย​ป้องกัน​การ​ส่ง city ไป​ใน​ตำแหน่ง​ของ state เพราะ​ทั้ง​คู่​เป็น​แค่ string
  5. ซ่อน​พฤติกรรม​ที่​ควร​อยู่​ใกล้​ข้อมูล — เมื่อ​ไม่มี object เป็น​เจ้าของ​กลุ่ม​ข้อมูล behavior ที่​ควร​อยู่​กับ​มัน (เช่น การ format ที่​อยู่ หรือ​คำนวณ​ระยะ​เวลา​ใน​ช่วง) ก็​ไม่มี​ที่​อยู่ จึง​กระเด็น​ไป​แปะ​อยู่​ตาม class อื่น​ที่​ไม่​เกี่ยวข้อง​โดยตรง กลาย​เป็น​อาการ​ของ Feature Envy หรือ​ทำให้ class กลาย​เป็น Data Class ที่​ไม่มี logic ของ​ตัวเอง
public class Customer
{
public string Street { get; set; }
public string City { get; set; }
public string State { get; set; }
public string Zip { get; set; }
}
public class Order
{
public string ShippingStreet { get; set; }
public string ShippingCity { get; set; }
public string ShippingState { get; set; }
public string ShippingZip { get; set; }
// street, city, state, zip เดินทางด้วยกันซ้ำในทุก method ที่เกี่ยวกับที่อยู่
public decimal CalculateShippingCost(
string street, string city, string state, string zip, decimal weight)
{
// ต้องรู้ทั้งสี่ค่าพร้อมกันจึงจะคำนวณได้จริง — ลบตัวใดตัวหนึ่งออก
// ที่เหลือก็ไม่มีความหมายพอสำหรับการคำนวณค่าส่ง
bool isRemote = state == "AK" || state == "HI";
return isRemote ? weight * 3.5m : weight * 1.5m;
}
public string FormatShippingLabel(string street, string city, string state, string zip)
{
return $"{street}\n{city}, {state} {zip}";
}
}

การ​ทดสอบ​แบบ “ลบ​หนึ่ง​ค่า​แล้ว​ดู​ว่า​ยัง​มี​ความหมาย​ไหม” ยืนยัน​ชัดเจน: ถ้า​ลบ city ออก ที่​เหลือ (street, state, zip) ก็​ใช้​ระบุ​ที่​อยู่​จริง​ไม่​ได้​อีก​ต่อ​ไป — นี่​คือ Data Clumps ชัดเจน

หลัง refactor — ใช้ Extract Class สร้าง value object แล้ว​ตาม​ด้วย Introduce Parameter Object / Preserve Whole Object

หัวข้อ​ที่​มีชื่อ​ว่า “หลัง refactor — ใช้ Extract Class สร้าง value object แล้ว​ตาม​ด้วย Introduce Parameter Object / Preserve Whole Object”

ขั้น​แรก​ดึง field ที่​ซ้ำ​ออก​มา​เป็น class เดียว (Extract Class) ให้​เป็น value object ที่ immutable และ validate ตัวเอง​ได้:

public sealed class Address
{
public string Street { get; }
public string City { get; }
public string State { get; }
public string Zip { get; }
public Address(string street, string city, string state, string zip)
{
if (string.IsNullOrWhiteSpace(zip) || zip.Length != 5)
throw new ArgumentException("Zip ต้องเป็นตัวเลข 5 หลัก", nameof(zip));
Street = street;
City = city;
State = state;
Zip = zip;
}
// ย้าย behavior ที่เคยกระจัดกระจายมาไว้ที่เดียวกับข้อมูล
public bool IsRemoteRegion() => State == "AK" || State == "HI";
public override string ToString() => $"{Street}\n{City}, {State} {Zip}";
}

จาก​นั้น​ใช้ Preserve Whole Object — ส่ง​ทั้ง object เข้าไป​แทนที่​จะ​แยก​ส่ง​ที​ละ field — และ Introduce Parameter Object ใน​จุด​ที่ parameter เดิม​ยาว​เกิน​ไป:

public class Customer
{
public Address Address { get; set; }
}
public class Order
{
public Address ShippingAddress { get; set; }
public decimal CalculateShippingCost(Address address, decimal weight)
{
return address.IsRemoteRegion() ? weight * 3.5m : weight * 1.5m;
}
public string FormatShippingLabel(Address address)
{
return address.ToString();
}
}

ผลลัพธ์: signature สั้น​ลง, validation ของ zip อยู่​ที่​เดียว​และ​รัน​ทุก​ครั้ง​ที่​สร้าง Address, การ​คำนวณ IsRemoteRegion ย้าย​ไป​อยู่​ใกล้​ข้อมูล​ที่​มัน​ใช้​จริง และ compiler ป้องกัน​การ​สลับ​ลำดับ city/state ไม่​ให้​เกิด​ขึ้น​ได้​อีก เพราะ​ทั้ง​สอง​ไม่ใช่ string ลอย ๆ ที่​สลับ​กัน​ได้​โดย​ไม่มี​ใคร​ทักท้วง

// ก่อน: คู่ค่าเดินทางด้วยกันในทุก signature ที่เกี่ยวกับช่วงเวลา
public bool IsWithinRange(DateTime startDate, DateTime endDate, DateTime target)
{
return target >= startDate && target <= endDate;
}
public int CountBusinessDays(DateTime startDate, DateTime endDate) { /* ... */ }
// หลัง: รวมเป็น DateRange value object ที่ validate invariant ของตัวเอง
public sealed class DateRange
{
public DateTime Start { get; }
public DateTime End { get; }
public DateRange(DateTime start, DateTime end)
{
if (end < start)
throw new ArgumentException("End ต้องไม่มาก่อน Start");
Start = start;
End = end;
}
public bool Contains(DateTime target) => target >= Start && target <= End;
public int CountBusinessDays() { /* ... */ return 0; }
}

แผนภาพ​สั้น ๆ สรุป​การ​เคลื่อนย้าย​ความ​รับผิดชอบ:

flowchart LR
    A[street city state zip กระจายในหลาย method] -->|Extract Class| B[Address value object]
    B -->|Preserve Whole Object| C[Method รับ Address เดียว]
    B -->|ย้าย behavior เข้ามา| D[IsRemoteRegion ToString อยู่ใน Address]
  • Primitive Obsession — กลิ่น​แม่​ที่ Data Clumps เป็น​อาการ​เฉพาะ​เจาะจง​หนึ่ง​ของ​มัน
  • Long Parameter List — อาการ​ที่​มัก​ปรากฏ​คู่​กับ Data Clumps ใน method signature
  • Value Object — ทาง​แก้​มาตรฐาน​สำหรับ​รวม​กลุ่ม​ข้อมูล​ให้​เป็น type เดียว​ที่ validate ตัวเอง​ได้
  • Feature Envy — เกิด​เมื่อ behavior ที่​ควร​อยู่​กับ​กลุ่ม​ข้อมูล​ถูก​แยก​ไป​อยู่ class อื่น
  • Shotgun Surgery — ผลกระทบ​เวลา​ต้อง​แก้ไข​กลุ่ม​ข้อมูล​ที่​กระจาย​อยู่​หลาย​จุด​พร้อม​กัน
  • Data Class — ความ​เสี่ยง​ที่ object ใหม่​จะ​กลาย​เป็น​แค่​ที่​เก็บ​ข้อมูล​ถ้า​ไม่​ย้าย behavior เข้า​มา​ด้วย