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

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 — เมื่อ “เงิน” เป็น​แค่ decimal code จะ​ไม่มี​ที่​ให้​พูด​ถึง​กฎ​อย่าง “ห้าม​บวก​เงิน​คนละ​สกุล” ทำให้ Ubiquitous Language ของ​ทีม​หาย​ไป​จาก source code
  • ขยาย​ยาก — เมื่อ type code เป็น int/string ธรรมดา การ​เพิ่ม​พฤติกรรม​ใหม่​ตาม​แต่ละ​ค่า (เช่น กฎ​การ​คำนวณ​ราคา​ต่าง​กัน​ตาม tier ลูกค้า) มัก​จบ​ลง​ด้วย switch ก้อน​ใหญ่​กระจาย​หลาย​ที่ แทนที่​จะ​ใช้ polymorphism

ตัวอย่าง​ที่ 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

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 สั้น​ลง อ่าน​ง่าย​ขึ้น​มาก