Move Field
จุดประสงค์
หัวข้อที่มีชื่อว่า “จุดประสงค์”field อยู่บน record หนึ่ง แต่คนที่อ่านและอัปเดตส่วนใหญ่กลับเป็นอีก record ให้ย้าย field ไปอยู่กับ record ที่ใช้จริง ข้อมูลจะได้อยู่ติดกับ behavior ที่ต้องใช้ และ record ทั้งสองจะเลิกเอื้อมข้ามขอบเขตที่ขีดไว้ผิดที่ตั้งแต่แรก
อาการของปัญหา
หัวข้อที่มีชื่อว่า “อาการของปัญหา”โครงสร้างข้อมูลคือโครงกระดูกของโปรแกรม field ที่วางผิดที่จึงบิดทุกอย่างที่สร้างทับลงไป สัญญาณที่ควรจับตามีสามแบบ คือ field บน record A ที่ต้องส่งคู่ไปกับ record B เสมอ field ที่ method ของ B เป็นคนอ่านและเขียน ส่วน A แค่เก็บไว้เฉย ๆ และ record สองตัวที่ต้องอัปเดตพร้อมกันเพื่อให้ค่าตรงกัน ทุกแบบชี้ไปทางเดียวกันว่าเจ้าของตัวจริงของ field คือ B
ก่อน → หลัง
หัวข้อที่มีชื่อว่า “ก่อน → หลัง”discountRate เก็บอยู่บน Customer แต่คนกำหนดค่าทั้งหมดคือ PricingPlan ของลูกค้า ตอนแรกอัตราอยู่บนลูกค้า แล้ว plan ต้องเอื้อมข้ามมาหยิบไปคำนวณราคา หลัง refactor อัตราย้ายไปอยู่บน plan ตรงที่กฎการคิดราคาอยู่แล้ว
// Beforeclass PricingPlan { constructor(public name: string) {}
finalPrice(base: number, discountRate: number): number { return base * (1 - discountRate); }}
class Customer { constructor( public name: string, public plan: PricingPlan, public discountRate: number, ) {}
quote(base: number): number { return this.plan.finalPrice(base, this.discountRate); }}
// Afterclass PricingPlan { constructor(public name: string, public discountRate: number) {}
finalPrice(base: number): number { return base * (1 - this.discountRate); }}
class Customer { constructor(public name: string, public plan: PricingPlan) {}
quote(base: number): number { return this.plan.finalPrice(base); }}# Beforeclass PricingPlan: def __init__(self, name): self.name = name
def final_price(self, base, discount_rate): return base * (1 - discount_rate)
class Customer: def __init__(self, name, plan, discount_rate): self.name = name self.plan = plan self.discount_rate = discount_rate
def quote(self, base): return self.plan.final_price(base, self.discount_rate)
# Afterclass PricingPlan: def __init__(self, name, discount_rate): self.name = name self.discount_rate = discount_rate
def final_price(self, base): return base * (1 - self.discount_rate)
class Customer: def __init__(self, name, plan): self.name = name self.plan = plan
def quote(self, base): return self.plan.final_price(base)// Beforetype PricingPlan struct { Name string}
func (p PricingPlan) FinalPrice(base, discountRate float64) float64 { return base * (1 - discountRate)}
type Customer struct { Name string Plan PricingPlan DiscountRate float64}
func (c Customer) Quote(base float64) float64 { return c.Plan.FinalPrice(base, c.DiscountRate)}
// Aftertype PricingPlan struct { Name string DiscountRate float64}
func (p PricingPlan) FinalPrice(base float64) float64 { return base * (1 - p.DiscountRate)}
type Customer struct { Name string Plan PricingPlan}
func (c Customer) Quote(base float64) float64 { return c.Plan.FinalPrice(base)}// Beforestruct PricingPlan { name: String,}
impl PricingPlan { fn final_price(&self, base: f64, discount_rate: f64) -> f64 { base * (1.0 - discount_rate) }}
struct Customer { name: String, plan: PricingPlan, discount_rate: f64,}
impl Customer { fn quote(&self, base: f64) -> f64 { self.plan.final_price(base, self.discount_rate) }}
// Afterstruct PricingPlan { name: String, discount_rate: f64,}
impl PricingPlan { fn final_price(&self, base: f64) -> f64 { base * (1.0 - self.discount_rate) }}
struct Customer { name: String, plan: PricingPlan,}
impl Customer { fn quote(&self, base: f64) -> f64 { self.plan.final_price(base) }}flowchart LR
subgraph Before["Before"]
A["Customer<br/>name<br/>discountRate"]
B["PricingPlan"]
A -.->|"plan logic reads<br/>discountRate"| B
end
subgraph After["After"]
C["Customer<br/>name<br/>plan"]
D["PricingPlan<br/>discountRate"]
C --> D
end
Before -.->|"Move Field"| After กลไกการทำงาน
หัวข้อที่มีชื่อว่า “กลไกการทำงาน”- ถ้า field เป็น public ให้ห่อไว้หลัง accessor ก่อน ทุกการอ่านและเขียนจะได้ผ่านจุดเดียว ตอนย้ายจริงจึงแก้ที่เดียวจบ
- เพิ่ม field ลงใน record ปลายทาง พร้อมกับ accessor ที่นั่น
- ตัดสินใจว่าปลายทางจะเอาค่ามาอย่างไร จะคำนวณเอง เก็บไว้ตอนสร้าง หรือรับเข้ามาเป็น parameter ก็ได้
- เปลี่ยนทิศทาง accessor แต่ละตัวบนต้นทางให้มอบหมายไปยังสำเนาของ field ที่ปลายทาง
- รัน test behavior ต้องไม่เปลี่ยนแปลง ณ จุดตรวจสอบนี้
- ลบ field ออกจากต้นทางเมื่อไม่มีใครอ่านในที่เดิมแล้ว พร้อมตัด parameter ที่มีไว้ขนค่าออกไปด้วย
- รัน test อีกครั้ง ชุด test สีเขียวยืนยันว่าค่าอยู่ในที่เดียวแล้ว
ใช้เมื่อไหร่ / ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ใช้เมื่อไหร่ / ข้อแลกเปลี่ยน”ย้าย field เมื่อ method ของ record อื่นใช้มากกว่าเจ้าของปัจจุบัน เมื่อ field ต้องเดินทางคู่ไปกับ object อื่นตลอด หรือเมื่อการคอยไล่ให้สำเนาสองชุดตรงกันกลายเป็นแหล่งบั๊กประจำ
ต้นทุนคือการต้องแตะ caller ทุกตัวที่อ่าน field โดยตรง — ซึ่งนั่นเองคือเหตุผลที่การห่อหุ้มก่อนคุ้มค่า ไม่มีสิ่งตรงข้ามที่มีชื่อแยกต่างหาก: หากการเปลี่ยนแปลงภายหลังทำให้ record เดิม กลายเป็นผู้ใช้งานหนักกว่า คุณก็เพียงแค่ Move Field กลับ
เนื้อหาที่เกี่ยวข้อง
หัวข้อที่มีชื่อว่า “เนื้อหาที่เกี่ยวข้อง”| ใช้ Move Field เมื่อ | หลีกเลี่ยงเมื่อ |
|---|---|
| field ถูกใช้โดย class อื่นบ่อยกว่า class ที่เป็นเจ้าของ | การย้ายจะสร้าง circular dependency |
| field เป็นส่วนหนึ่งของ Extract Class ที่กำลังทำ | field นั้น computed จาก data ใน class ปัจจุบัน |
| เปลี่ยน field นี้ต้องแก้ class อื่นเสมอ | ทีมไม่มี test ครอบคลุม access patterns ของ field |
⚠️ ไม่ควร Move Field เมื่อ:
- field นั้นถูก access จากทั้งสอง class ในปริมาณพอ ๆ กัน
- การย้ายทำให้ API เปลี่ยน ซึ่ง break external caller
- ยังไม่แน่ใจว่า class ใหม่จะมีชีวิตยืนยาว