Replace Primitive with Object
จุดประสงค์
หัวข้อที่มีชื่อว่า “จุดประสงค์”เอาค่า primitive อย่าง string หรือตัวเลข ที่เริ่มสะสมกฎว่าจะ validate อย่างไร parse อย่างไร หรือจัดรูปแบบอย่างไร มาห่อไว้ใน type เล็ก ๆ ที่ทำหน้าที่นี้โดยเฉพาะ value object ตัวใหม่จะถือทั้งข้อมูลดิบ และ behavior กฎทั้งหมดจึงมารวมอยู่จุดเดียว และชื่อ type ก็ประกาศออกมาเลยว่าค่านั้นคืออะไรกันแน่
Code Smell
หัวข้อที่มีชื่อว่า “Code Smell”นี่คือยารักษาอาการ Primitive Obsession: การแทนแนวคิดเชิงโดเมนที่มีความหมายด้วย primitive ดิบ ๆ หมายเลขโทรศัพท์เป็น string จำนวนเงินเป็น number อุณหภูมิเป็น float เปลือย ๆ อาการเหมือนกัน คือ logic เดียวกันกระจัดกระจายไปทุกที่ที่มีการใช้ primitive — regex เดิมในการตรวจสอบหมายเลขโทรศัพท์ การปัดเศษเดิมในการจัดการเซนต์ การแปลงเดิมที่ทำซ้ำที่แต่ละจุด การทำซ้ำแต่ละครั้งคือจุดที่จะลืมกฎและสร้างบั๊กขึ้นมา
ก่อน → หลัง
หัวข้อที่มีชื่อว่า “ก่อน → หลัง”หมายเลขโทรศัพท์ที่ถูกพกพาเป็น string ดิบ พร้อมการตรวจสอบความถูกต้องและการจัดรูปแบบที่ทำซ้ำในทุกการใช้งาน หลังจากนั้น value type PhoneNumber ขนาดเล็กเป็นเจ้าของตัวเลขและ behavior
// Beforefunction 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);}
// Afterclass 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());}# Beforedef call_customer(phone): digits = "".join(c for c in phone if c.isdigit()) if len(digits) != 10: raise ValueError("invalid phone") pretty = f"({digits[:3]}) {digits[3:6]}-{digits[6:]}" dial(pretty)
# Afterclass PhoneNumber: def __init__(self, raw): self._digits = "".join(c for c in raw if c.isdigit()) if len(self._digits) != 10: raise ValueError("invalid phone")
def area_code(self): return self._digits[:3]
def formatted(self): d = self._digits return f"({d[:3]}) {d[3:6]}-{d[6:]}"
def call_customer(phone): dial(phone.formatted())// Beforefunc CallCustomer(phone string) error { digits := stripNonDigits(phone) if len(digits) != 10 { return errors.New("invalid phone") } pretty := fmt.Sprintf("(%s) %s-%s", digits[:3], digits[3:6], digits[6:]) dial(pretty) return nil}
// Aftertype PhoneNumber struct { digits string}
func NewPhoneNumber(raw string) (PhoneNumber, error) { d := stripNonDigits(raw) if len(d) != 10 { return PhoneNumber{}, errors.New("invalid phone") } return PhoneNumber{digits: d}, nil}
func (p PhoneNumber) AreaCode() string { return p.digits[:3] }
func (p PhoneNumber) Formatted() string { return fmt.Sprintf("(%s) %s-%s", p.digits[:3], p.digits[3:6], p.digits[6:])}
func CallCustomer(phone PhoneNumber) { dial(phone.Formatted())}// Beforefn call_customer(phone: &str) -> Result<(), String> { let digits: String = phone.chars().filter(|c| c.is_ascii_digit()).collect(); if digits.len() != 10 { return Err("invalid phone".into()); } let pretty = format!("({}) {}-{}", &digits[..3], &digits[3..6], &digits[6..]); dial(&pretty); Ok(())}
// Afterpub struct PhoneNumber { digits: String,}
impl PhoneNumber { pub fn parse(raw: &str) -> Result<Self, String> { let digits: String = raw.chars().filter(|c| c.is_ascii_digit()).collect(); if digits.len() != 10 { return Err("invalid phone".into()); } Ok(Self { digits }) }
pub fn area_code(&self) -> &str { &self.digits[..3] }
pub fn formatted(&self) -> String { format!("({}) {}-{}", &self.digits[..3], &self.digits[3..6], &self.digits[6..]) }}
fn call_customer(phone: &PhoneNumber) { 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 กลไกการทำงาน
หัวข้อที่มีชื่อว่า “กลไกการทำงาน”- สร้าง class ใหม่ที่มี field เดียวถือ primitive ไว้ ให้ constructor (หรือ function parse) ที่รับค่าดิบและตรวจสอบความถูกต้องเพียงครั้งเดียว
- ย้าย behavior ที่กระจัดกระจายชิ้นหนึ่ง — การตรวจสอบความถูกต้อง การจัดรูปแบบ การ parse — เข้าไปเป็น method บน type ใหม่
- แทนที่การใช้งาน primitive ดิบหนึ่งจุดด้วย value object โดยเรียก method ใหม่แทน logic ที่เขียนอินไลน์
- รัน test
- ทำซ้ำ: ย้ายแต่ละจุดใช้งานและรวมแต่ละกฎที่ซ้ำกันเข้าเป็น method ทีละอัน
- เมื่อทุกจุดใช้ 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