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

Replace Primitive with Object

เอาค่า primitive อย่าง string หรือตัวเลข ที่เริ่มสะสมกฎว่าจะ validate อย่างไร parse อย่างไร หรือจัดรูปแบบอย่างไร มาห่อไว้ใน type เล็ก ๆ ที่ทำหน้าที่นี้โดยเฉพาะ value object ตัวใหม่จะถือทั้งข้อมูลดิบ และ behavior กฎทั้งหมดจึงมารวมอยู่จุดเดียว และชื่อ type ก็ประกาศออกมาเลยว่าค่านั้นคืออะไรกันแน่

นี่คือยารักษาอาการ Primitive Obsession: การแทนแนวคิดเชิงโดเมนที่มีความหมายด้วย primitive ดิบ ๆ หมายเลขโทรศัพท์เป็น string จำนวนเงินเป็น number อุณหภูมิเป็น float เปลือย ๆ อาการเหมือนกัน คือ logic เดียวกันกระจัดกระจายไปทุกที่ที่มีการใช้ primitive — regex เดิมในการตรวจสอบหมายเลขโทรศัพท์ การปัดเศษเดิมในการจัดการเซนต์ การแปลงเดิมที่ทำซ้ำที่แต่ละจุด การทำซ้ำแต่ละครั้งคือจุดที่จะลืมกฎและสร้างบั๊กขึ้นมา

หมายเลขโทรศัพท์ที่ถูกพกพาเป็น string ดิบ พร้อมการตรวจสอบความถูกต้องและการจัดรูปแบบที่ทำซ้ำในทุกการใช้งาน หลังจากนั้น value type PhoneNumber ขนาดเล็กเป็นเจ้าของตัวเลขและ behavior

// Before
function callCustomer(phone: string): void {
const digits = phone.replace(/\D/g, '');
if (digits.length !== 10) throw new Error('invalid phone');
const pretty = `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`;
dial(pretty);
}
// After
class PhoneNumber {
private readonly digits: string;
constructor(raw: string) {
this.digits = raw.replace(/\D/g, '');
if (this.digits.length !== 10) throw new Error('invalid phone');
}
areaCode(): string {
return this.digits.slice(0, 3);
}
formatted(): string {
return `(${this.digits.slice(0, 3)}) ${this.digits.slice(3, 6)}-${this.digits.slice(6)}`;
}
}
function callCustomer(phone: PhoneNumber): void {
dial(phone.formatted());
}
flowchart LR
  subgraph Before["Before — Primitive Obsession"]
    A["phone: string"]
    A --> B["validate() copy-pasted"]
    A --> C["format() copy-pasted"]
    A --> D["areaCode() copy-pasted"]
  end
  subgraph After["After — a value type"]
    E["class PhoneNumber"]
    E --> F["holds the digits"]
    E --> G["isValid()"]
    E --> H["formatted()"]
    E --> I["areaCode()"]
  end
  Before -.->|"Replace Primitive with Object"| After
primitive ดิบที่มีกฎกระจัดกระจาย กลายเป็น value type ที่เป็นเจ้าของ behavior เอง
  1. สร้าง class ใหม่ที่มี field เดียวถือ primitive ไว้ ให้ constructor (หรือ function parse) ที่รับค่าดิบและตรวจสอบความถูกต้องเพียงครั้งเดียว
  2. ย้าย behavior ที่กระจัดกระจายชิ้นหนึ่ง — การตรวจสอบความถูกต้อง การจัดรูปแบบ การ parse — เข้าไปเป็น method บน type ใหม่
  3. แทนที่การใช้งาน primitive ดิบหนึ่งจุดด้วย value object โดยเรียก method ใหม่แทน logic ที่เขียนอินไลน์
  4. รัน test
  5. ทำซ้ำ: ย้ายแต่ละจุดใช้งานและรวมแต่ละกฎที่ซ้ำกันเข้าเป็น method ทีละอัน
  6. เมื่อทุกจุดใช้ value object แล้ว ตัว primitive กับ logic ที่เคยกระจัดกระจายจะหายไปจาก code ฝั่งที่เรียกใช้ ต่อจากนี้ type คือบ้านของกฎใหม่ ๆ ทุกข้อที่เกี่ยวกับแนวคิดนั้น

ยกระดับ primitive เมื่อค่านั้นแบกความหมายเกินกว่า type ดิบจะสื่อได้ และเมื่อคุณจับได้ว่าตัวเองเขียน logic ชุดเดิมซ้ำ ๆ ไม่ว่าจะ validate เปรียบเทียบ จัดรูปแบบ หรือแปลงหน่วย value object จะรวบกฎเหล่านั้นไว้ที่เดียว ทำให้สร้าง state ที่ไม่ถูกต้องได้ยากขึ้น และให้ type system ดักจับตอนมีคนส่ง PhoneNumber เข้าไปในที่ที่ต้องการ ZipCode

ต้นทุนคือ class เพิ่มมาอีกหนึ่งตัว กับงานสร้าง object ตรงขอบระบบ ถ้าเป็นค่าที่ใช้ที่เดียวและไม่มีกฎอะไรผูกติด primitive ดิบก็พอแล้ว ห่อไปก็เป็นพิธีกรรมที่ไม่ได้อะไรกลับมา สัญญาณที่บอกว่าถึงเวลาลงมือคือ ความซ้ำ พอกฎชุดเดิมถูกคัดลอกเป็นชุดที่สองหรือสาม นั่นแหละคือจังหวะที่ object เริ่มคุ้ม

ใช้ Replace Primitive with Object เมื่อหลีกเลี่ยงเมื่อ
primitive นั้นมี validation หรือ behavior ของตัวเองprimitive นั้นเป็นค่าทั่วไปที่ไม่มี domain meaning
ต้องการ add method ที่เกี่ยวข้องกับ primitive นั้นoverhead ของ object ไม่คุ้มสำหรับค่าที่ใช้ภายใน
primitive เดิมปรากฏเป็น Data Clumps กับ primitive อื่นtype ใหม่จะมีแค่ getter ไม่มี behavior จริง

⚠️ ไม่ควร Replace Primitive with Object เมื่อ:

  • primitive นั้นเป็นค่าที่ไม่มีความหมายเฉพาะ domain (เช่น index, count)
  • ทำให้ serialization ซับซ้อนขึ้นโดยไม่จำเป็น
  • สร้าง Value Object ที่มีแค่ constructor และ getter — ไม่มี behavior
Replace Primitive with Object รักษากลิ่นเหม็นใด?
สัญญาณที่ชัดเจนที่สุดว่า primitive ควรกลายเป็น object คืออะไร?
ทำไมการตรวจสอบความถูกต้องใน constructor จึงมีคุณค่ามาก?
เมื่อใดที่ primitive ดิบยังเป็นตัวเลือกที่ถูกต้องอยู่?