Primitive Obsession
พึ่งชนิดพื้นฐานมากเกินไป แทนแนวคิด domain ที่มีกฎของตัวเอง
กลิ่นนี้คืออะไร
หัวข้อที่มีชื่อว่า “กลิ่นนี้คืออะไร”Primitive Obsession คือกลิ่น code ที่เกิดจากการ พึ่งชนิดพื้นฐาน (primitive types) อย่าง bool, int, decimal, string มากเกินไป เพื่อแทนแนวคิด domain ที่จริง ๆ แล้วมีกฎ ข้อจำกัด และพฤติกรรมของตัวเอง เช่น ใช้ string แทนอีเมล เบอร์โทร หรือรหัสไปรษณีย์ ใช้ decimal แทนจำนวนเงิน หรือใช้ int เป็นรหัสสถานะ (type code) แทนที่จะเป็น enum หรือ class เฉพาะ
ตาม refactoring.guru กลิ่นนี้ครอบคลุมสามรูปแบบหลัก: (1) ใช้ primitive แทนที่จะเป็น object เล็ก ๆ สำหรับงานง่าย ๆ เช่น สกุลเงิน ช่วงค่า (range) หรือ string พิเศษอย่างเบอร์โทร (2) ใช้ค่าคงที่แทนข้อมูลเชิงธุรกิจ เช่น USER_ADMIN_ROLE = 1 และ (3) ใช้ string คงที่เป็นชื่อ field สำหรับ index เข้าถึง array ของข้อมูล
รากของปัญหามักมาจาก “ความขี้เกียจชั่วครู่” — ตอนเริ่มเขียน code การเพิ่ม field primitive ง่ายกว่าการสร้าง class ใหม่มาก จึงค่อย ๆ สะสมทีละนิดโดยไม่มีการวางแผนล่วงหน้า จน class ใหญ่ขึ้นเรื่อย ๆ และ primitive เหล่านั้นถูกใช้ “จำลอง” ชนิดข้อมูลจริง ด้วย magic number หรือ magic string แทนที่จะมีชนิดข้อมูลเฉพาะของตัวเอง
DevIQ สรุปสั้น ๆ ว่า ปัญหาคือ primitive รองรับค่าที่ไม่สมเหตุสมผลสำหรับแนวคิดนั้น (เช่น string ใด ๆ ก็ผ่านเป็น “อีเมล” ได้ทั้งที่ไม่มี @) ทางแก้คือสร้าง value object เพื่อ ทำให้สถานะที่ผิดกฎเป็นไปไม่ได้
วิธีสังเกต
หัวข้อที่มีชื่อว่า “วิธีสังเกต”สัญญาณที่บ่งบอกว่า code มี Primitive Obsession:
- parameter หลายตัวเป็น
string/intติดกันเป็นพรวน (เช่นCreateOrder(string street, string city, string zip, string country)) ซึ่งมักเป็นอาการร่วมกับ Data Clumps - มี validation logic แบบเดียวกัน (regex ตรวจอีเมล, ตรวจช่วงตัวเลข) กระจายซ้ำอยู่หลายจุดใน codebase เพราะไม่มีที่ “บ้าน” เดียวให้ logic นั้นอยู่
- ใช้
int,string, หรือenumดิบ ๆ เป็น type code แล้วมีswitch/if-elseตรวจค่านั้นซ้ำ ๆ หลายที่ — อาการร่วมกับ Switch Statements - ค่าคงที่ (constant) แทนความหมายเชิงธุรกิจ เช่น
const int UserAdminRole = 1 - เข้าถึง array หรือ dictionary ด้วย string literal เป็น “ชื่อ field” เช่น
row["CustomerEmail"] - comparison ระหว่าง2 primitive ที่ควรจะมีความหมายเชิง domain เช่น เทียบ
decimalสองตัวว่าสกุลเงินตรงกันหรือไม่ทั้งที่ไม่มีอะไรการันตีว่าทั้งคู่เป็นสกุลเดียวกัน - unit test ต้องสร้างข้อมูลปลอมจำนวนมาก (string เปล่า ตัวเลขสุ่ม) เพราะ compiler ไม่ช่วยบังคับกฎอะไรเลย
ทำไมถึงเป็นปัญหา
หัวข้อที่มีชื่อว่า “ทำไมถึงเป็นปัญหา”primitive ไม่สามารถ “ฝัง” กฎของ domain ไว้กับตัวมันเองได้ ผลที่ตามมาคือ:
- code ซ้ำซ้อนและ verbose — logic ตรวจสอบความถูกต้อง (validation) หรือการคำนวณที่เกี่ยวกับแนวคิดนั้นต้องเขียนซ้ำทุกที่ที่ใช้ primitive ตัวนั้น เพราะไม่มีจุดเดียวให้รวม behavior ไว้
- สถานะที่ผิดกฎเป็นไปได้เสมอ — ไม่มีอะไรห้าม
string email = "not-an-email"หรือint age = -5ตั้งแต่ compile time bug จึงไปโผล่ตอน runtime แทนที่จะถูกจับตั้งแต่จุดสร้างค่า ขัดกับหลัก Make Illegal States Unrepresentable และ Parse, Don’t Validate - สูญเสีย type safety — parameter ประเภทเดียวกันหลายตัว (เช่น
string,string,string) สลับตำแหน่งกันได้ง่ายโดย compiler ไม่เตือน ต่างจากการมีชนิดEmailAddress,PhoneNumberที่แยกกันชัดเจน - domain concept หายไปจากภาษาของ code — เมื่อ “เงิน” เป็นแค่
decimalcode จะไม่มีที่ให้พูดถึงกฎอย่าง “ห้ามบวกเงินคนละสกุล” ทำให้ Ubiquitous Language ของทีมหายไปจาก source code - ขยายยาก — เมื่อ type code เป็น
int/stringธรรมดา การเพิ่มพฤติกรรมใหม่ตามแต่ละค่า (เช่น กฎการคำนวณราคาต่างกันตาม tier ลูกค้า) มักจบลงด้วยswitchก้อนใหญ่กระจายหลายที่ แทนที่จะใช้ polymorphism
ตัวอย่างและการ refactor
หัวข้อที่มีชื่อว่า “ตัวอย่างและการ refactor”ตัวอย่างที่ 1 — ค่าเดี่ยวที่มีกฎ ใช้ Replace Primitive with Object
หัวข้อที่มีชื่อว่า “ตัวอย่างที่ 1 — ค่าเดี่ยวที่มีกฎ ใช้ Replace Primitive with Object”code smelly: อีเมลเป็นแค่ string ไม่มีอะไรการันตีความถูกต้อง และ logic ตรวจสอบต้องเขียนซ้ำทุกจุดที่รับอีเมล
public class Customer{ public string Email { get; set; }}
// ที่อื่นในระบบ ต้องคอย validate เองซ้ำ ๆpublic void Register(string email){ if (string.IsNullOrWhiteSpace(email) || !email.Contains("@")) throw new ArgumentException("อีเมลไม่ถูกต้อง");
var customer = new Customer { Email = email }; // ...}refactor ด้วยเทคนิค Replace Primitive with Object (หรือชื่อเดิมใน Fowler คือ Replace Data Value with Object) — ห่อ string ด้วย value object ที่บังคับกฎไว้ที่จุดสร้างค่าเพียงจุดเดียว ตามหลัก Parse, Don’t Validate:
public sealed class EmailAddress : IEquatable<EmailAddress>{ public string Value { get; }
private EmailAddress(string value) => Value = value;
public static EmailAddress Parse(string input) { if (string.IsNullOrWhiteSpace(input) || !input.Contains("@")) throw new ArgumentException("อีเมลไม่ถูกต้อง", nameof(input));
return new EmailAddress(input.Trim().ToLowerInvariant()); }
public bool Equals(EmailAddress other) => other is not null && Value == other.Value;
public override string ToString() => Value;}
public class Customer{ public EmailAddress Email { get; }
public Customer(EmailAddress email) => Email = email;}
// ที่อื่นในระบบ ไม่ต้อง validate ซ้ำอีกต่อไปpublic void Register(EmailAddress email){ var customer = new Customer(email); // ถ้า code มาถึงตรงนี้ ตัวแปร email รับประกันแล้วว่าถูกต้อง}ตอนนี้ “อีเมลที่ไม่ถูกต้อง” เป็นสถานะที่เป็นไปไม่ได้ — สอดคล้องกับหลัก Make Illegal States Unrepresentable
ตัวอย่างที่ 2 — type code เปลี่ยนเป็น Replace Type Code with Class/Subclasses
หัวข้อที่มีชื่อว่า “ตัวอย่างที่ 2 — type code เปลี่ยนเป็น Replace Type Code with Class/Subclasses”code smelly: ใช้ int เป็นรหัสระดับสมาชิก แล้วมี logic กระจายตรวจค่านั้นหลายที่
public class Member{ public int Tier { get; set; } // 0 = Bronze, 1 = Silver, 2 = Gold
public decimal GetDiscount(decimal amount) { if (Tier == 0) return amount * 0.0m; if (Tier == 1) return amount * 0.05m; if (Tier == 2) return amount * 0.10m; throw new InvalidOperationException("ไม่รู้จัก tier นี้"); }}refactor ด้วย Replace Type Code with Subclasses ผูก behavior ไว้กับแต่ละ tier โดยตรง:
public abstract class MembershipTier{ public abstract decimal GetDiscount(decimal amount);
public static readonly MembershipTier Bronze = new BronzeTier(); public static readonly MembershipTier Silver = new SilverTier(); public static readonly MembershipTier Gold = new GoldTier();
private sealed class BronzeTier : MembershipTier { public override decimal GetDiscount(decimal amount) => amount * 0.0m; }
private sealed class SilverTier : MembershipTier { public override decimal GetDiscount(decimal amount) => amount * 0.05m; }
private sealed class GoldTier : MembershipTier { public override decimal GetDiscount(decimal amount) => amount * 0.10m; }}
public class Member{ public MembershipTier Tier { get; set; }
public decimal GetDiscount(decimal amount) => Tier.GetDiscount(amount);}เพิ่ม tier ใหม่ในอนาคตก็แค่เพิ่ม class ใหม่ ไม่ต้องไล่แก้ switch/if ทุกจุดในระบบ — ลดความเสี่ยงของ Shotgun Surgery
ตัวอย่างที่ 3 — parameter เป็นพวงเดียวกัน ใช้ Introduce Parameter Object
หัวข้อที่มีชื่อว่า “ตัวอย่างที่ 3 — parameter เป็นพวงเดียวกัน ใช้ Introduce Parameter Object”code smelly:
public void ScheduleShipment(string street, string city, string zip, string country){ // ...}refactor ด้วย Introduce Parameter Object รวมกลุ่ม primitive ที่เดินทางด้วยกันเสมอเข้าเป็น value object เดียว (ดู Data Clumps):
public sealed record Address(string Street, string City, string Zip, string Country){ public Address { if (string.IsNullOrWhiteSpace(Zip)) throw new ArgumentException("ต้องระบุรหัสไปรษณีย์", nameof(Zip)); }}
public void ScheduleShipment(Address destination){ // ...}เมื่อรวมกันเป็น Address แล้ว การเพิ่ม validation หรือ behavior ใหม่ (เช่น FormatForLabel()) ทำได้ที่จุดเดียว และลายเซ็น method สั้นลง อ่านง่ายขึ้นมาก
ที่เกี่ยวข้อง
หัวข้อที่มีชื่อว่า “ที่เกี่ยวข้อง”- Value Object
- Make Illegal States Unrepresentable
- Parse, Don’t Validate
- Data Clumps
- Switch Statements
- Shotgun Surgery