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 ไม่ได้ทำให้โปรแกรมพังทันที แต่มันสะสม “หนี้” หลายชั้น:
- ไม่มีที่เดียวสำหรับ invariant — กติกาอย่าง “zip ต้องเป็นตัวเลข 5 หลัก” หรือ “endDate ต้องมาหลัง startDate” ไม่มีบ้านอยู่ ทำให้ต้อง copy validation logic ซ้ำทุกจุดที่ใช้กลุ่มข้อมูลนี้ หรือแย่กว่านั้นคือลืม validate ในบางจุด
- แก้ไขยาก เปลี่ยนที่เดียวต้องตามแก้หลายที่ — ถ้าจะเพิ่ม field
countryเข้าไปในที่อยู่ ต้องไล่แก้ทุก method signature และทุก class ที่มีกลุ่มข้อมูลนี้ ซึ่งเป็นอาการที่ใกล้เคียงกับ Shotgun Surgery - Signature อ่านยาก parameter เรียงกันยาว — เมื่อ parameter กลายเป็น string, string, string, string ผู้เรียกใช้เสี่ยงส่งค่าผิดลำดับโดย compiler ตรวจไม่พบ เพราะ type ของทุกตัวเหมือนกันหมด
- โอกาสพลาดสูงขึ้น — ไม่มี type system ช่วยป้องกันการส่ง
cityไปในตำแหน่งของstateเพราะทั้งคู่เป็นแค่string - ซ่อนพฤติกรรมที่ควรอยู่ใกล้ข้อมูล — เมื่อไม่มี object เป็นเจ้าของกลุ่มข้อมูล behavior ที่ควรอยู่กับมัน (เช่น การ format ที่อยู่ หรือคำนวณระยะเวลาในช่วง) ก็ไม่มีที่อยู่ จึงกระเด็นไปแปะอยู่ตาม class อื่นที่ไม่เกี่ยวข้องโดยตรง กลายเป็นอาการของ Feature Envy หรือทำให้ class กลายเป็น Data Class ที่ไม่มี logic ของตัวเอง
ตัวอย่างและการ refactor
หัวข้อที่มีชื่อว่า “ตัวอย่างและการ refactor”ก่อน refactor — กลุ่มข้อมูลกระจายเป็น parameter และ field ซ้ำ
หัวข้อที่มีชื่อว่า “ก่อน refactor — กลุ่มข้อมูลกระจายเป็น parameter และ field ซ้ำ”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 ลอย ๆ ที่สลับกันได้โดยไม่มีใครทักท้วง
ตัวอย่างที่สอง — คู่ startDate/endDate
หัวข้อที่มีชื่อว่า “ตัวอย่างที่สอง — คู่ startDate/endDate”// ก่อน: คู่ค่าเดินทางด้วยกันในทุก 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 เข้ามาด้วย