Feature Envy
method ที่สนใจข้อมูลของ class อื่นมากกว่า class ของตัวเอง
กลิ่นนี้คืออะไร
หัวข้อที่มีชื่อว่า “กลิ่นนี้คืออะไร”Feature Envy คือกลิ่นที่ method หนึ่ง “อิจฉา” feature ของ class อื่น — มันเรียก getter หรือ method ของ object อื่นซ้ำแล้วซ้ำเล่าเพื่ออ่านข้อมูลมาคำนวณ ทั้งที่แทบไม่แตะ field หรือ method ของ class ตัวเองเลย Martin Fowler นิยามไว้ตั้งแต่หนังสือ Refactoring ฉบับปี 1999 ว่าเป็นกรณีที่ “method ใน class หนึ่งสื่อสารกับข้อมูลหรือ method ของอีก module หนึ่งมากกว่า module ของตัวเอง” — ในฉบับพิมพ์ใหม่เขาขยายคำว่า “class” เป็น “module” เพื่อให้ครอบคลุมหน่วยจัดระเบียบ code แบบอื่นด้วย
รากของปัญหาคือ misplaced responsibility — ความรับผิดชอบถูกวางผิดที่ ตรรกะทางธุรกิจไม่ได้อยู่ติดกับข้อมูลที่มันต้องใช้ กลิ่นนี้มักโผล่มาพร้อมกับ Data Class เช่นเมื่อทีมแยก field ออกไปไว้ใน class ข้อมูลล้วน ๆ (มักเพื่อ mapping กับฐานข้อมูลหรือ DTO) แต่ลืมย้ายพฤติกรรมที่ใช้ field เหล่านั้นตามไปด้วย ผลคือมี class “Service” ลอย ๆ คอยเอื้อมมือเข้าไปควานข้อมูลจากอีกฝั่งอยู่ตลอด
ทางแก้คลาสสิกคือ Move Method (ในฉบับพิมพ์ที่สองเรียก Move Function) — ย้าย logic ไปอยู่กับข้อมูลของมัน ตามหลัก Tell, Don’t Ask: แทนที่จะถามข้อมูลออกมาแล้วคำนวณเอง ให้บอกให้ object นั้นทำงานให้ และตาม Law of Demeter: อย่าเอื้อมมือไปไกลเกินกว่าเพื่อนบ้านชั้นเดียว
วิธีสังเกต
หัวข้อที่มีชื่อว่า “วิธีสังเกต”สัญญาณที่พบบ่อยเวลาอ่าน code:
- method เรียก getter ของ object เดียวกันหลายตัวติดกัน (
customer.GetTier(),customer.GetLoyaltyYears(),customer.GetLifetimeSpend()…) เพื่อคำนวณค่าอะไรบางอย่าง - ถ้าลบ parameter ที่รับ object อื่นเข้ามาออกไป method นี้จะแทบไม่เหลืออะไรให้ทำ — มันแทบไม่ใช้ field หรือ method ของ class ตัวเองเลย
- method สามารถเป็น
staticได้สบาย ๆ ถ้าไม่ติดที่มันต้องพึ่งพา object ต่าง class ตลอดเวลา - code รีวิวมักถามคำถามแบบ “ทำไม
OrderServiceต้องรู้จัก field ภายในของCustomerตั้งสามตัวแค่เพื่อคำนวณส่วนลด” - มักเกิดคู่กับ Data Class ที่มีแต่ property เปิดสาธารณะเต็มไปหมดโดยไม่มีพฤติกรรมของตัวเอง
- เครื่องมือวัด cohesion อย่าง LCOM (Lack of Cohesion of Methods) มักสูงผิดปกติใน class ที่มี method แบบนี้ปะปนอยู่
ทำไมถึงเป็นปัญหา
หัวข้อที่มีชื่อว่า “ทำไมถึงเป็นปัญหา”Coupling ผิดทิศทาง — method ผูกติดกับโครงสร้างภายในของ class อื่นแน่นกว่า class ตัวเองเสียอีก ถ้า Customer เปลี่ยนวิธีเก็บข้อมูล field ที่ method นี้อ่านอยู่ code ฝั่ง OrderService ก็พังตามไปด้วย ทั้งที่ควรจะเป็นเรื่องภายในของ Customer เอง
Encapsulation รั่ว — เพื่อให้ method ข้างนอกอ่านข้อมูลได้ class ต้นทางต้องเปิด getter สาธารณะออกมาเยอะเกินจำเป็น ทั้งที่จริง ๆ ควรเปิดเผยแค่ “พฤติกรรม” ไม่ใช่ “ข้อมูลดิบ”
Duplicate Code แฝงตัว — ถ้ามีอีกจุดในระบบต้องคำนวณเรื่องเดียวกันจากข้อมูลเดียวกัน มันมักจะเขียน logic ซ้ำขึ้นมาอีกชุด เพราะ logic ไม่ได้อยู่เป็นที่เป็นทางใน class ข้อมูลนั้น ขัดกับ DRY
ทดสอบยาก — การเขียน unit test ให้ method ที่ envy class อื่น ต้องประกอบ object ของอีกฝั่งขึ้นมาเต็มรูปแบบ (หรือ mock) ทั้งที่ถ้า logic อยู่กับข้อมูลของมันเอง ก็ทดสอบได้แบบโดดเดี่ยวง่าย ๆ
สัญญาณของ Anemic Domain Model — ในบริบท DDD นี่มักเป็นรอยเท้าของ Anemic Domain Model: entity กลายเป็นถุงใส่ข้อมูลที่มีแต่ getter/setter ส่วนตรรกะทางธุรกิจทั้งหมดไปกองอยู่ใน “Service” ที่คอยเอื้อมมือเข้ามาอ่าน — นี่ขัดกับเจตนาของ model domain ที่ควรห่อหุ้มทั้งข้อมูลและพฤติกรรมที่สื่อ ubiquitous language ไว้ด้วยกัน
ข้อยกเว้น — อย่าไล่ล่ากลิ่นนี้แบบไม่ดูบริบท ถ้าการแยกพฤติกรรมออกจากข้อมูลเป็นความตั้งใจของ design pattern อย่าง Strategy หรือ Visitor ที่ต้องการสลับพฤติกรรมได้อย่างยืดหยุ่น กรณีนั้นไม่ใช่กลิ่น แต่เป็นการออกแบบที่ตั้งใจ
ตัวอย่างและการ refactor
หัวข้อที่มีชื่อว่า “ตัวอย่างและการ refactor”code สมมติ: OrderService ต้องคำนวณอัตราส่วนลดของลูกค้า แต่ทำโดยเอื้อมมือเข้าไปอ่าน field ของ Customer เองทั้งหมด
// กลิ่น: OrderService "อิจฉา" ข้อมูลของ Customerpublic class Customer{ public CustomerTier Tier { get; set; } public int LoyaltyYears { get; set; } public decimal LifetimeSpend { get; set; }}
public class OrderService{ public decimal CalculateDiscountRate(Customer customer) { // method นี้แทบไม่แตะข้อมูลของ OrderService เองเลย // มันอ่าน field ของ Customer ทั้งหมดสามตัวเพื่อคำนวณ decimal rate = 0m;
if (customer.Tier == CustomerTier.Gold) rate += 0.10m;
if (customer.LoyaltyYears >= 5) rate += 0.05m;
if (customer.LifetimeSpend > 100_000m) rate += 0.03m;
return Math.Min(rate, 0.20m); }}refactor ด้วย Move Method — ย้าย logic ทั้งก้อนไปอยู่กับ Customer ซึ่งเป็นเจ้าของข้อมูลจริง ๆ แล้วปิด field ให้เป็น private ตามหลัก encapsulation จากนั้นให้ OrderService แค่ “บอก” ให้ Customer คำนวณเอง (Tell, Don’t Ask):
// หลัง refactor ด้วย Move Method: ย้าย logic ไปอยู่กับข้อมูลที่มันต้องใช้public class Customer{ public CustomerTier Tier { get; private set; } public int LoyaltyYears { get; private set; } public decimal LifetimeSpend { get; private set; }
public decimal CalculateDiscountRate() { decimal rate = 0m;
if (Tier == CustomerTier.Gold) rate += 0.10m;
if (LoyaltyYears >= 5) rate += 0.05m;
if (LifetimeSpend > 100_000m) rate += 0.03m;
return Math.Min(rate, 0.20m); }}
public class OrderService{ public decimal CalculateDiscountRate(Customer customer) => customer.CalculateDiscountRate(); // Tell, Don't Ask}ถ้า method เดิมทำหลายอย่างปนกัน — ส่วน1 envy Customer อีกส่วนเป็นตรรกะเฉพาะของ order เอง (เช่นโปรโมชั่นตามฤดูกาล) — ให้ทำ Extract Method แยกส่วนที่ envy ออกมาก่อน แล้วค่อย Move Method เฉพาะส่วนนั้นไปหา Customer ส่วนตรรกะที่เหลือซึ่งเป็นของ OrderService จริง ๆ ก็ปล่อยไว้ที่เดิม — เทคนิคนี้บางแหล่งเรียกว่า “Split Across Classes” เมื่อ method หนึ่งพึ่งพาหลาย class พร้อมกัน
ทิศทางของ coupling เปลี่ยนจากกระจัดกระจาย (เอื้อมอ่านหลาย field) เหลือแค่การเรียกเดียว:
graph LR
subgraph Before
A1[CalculateDiscountRate] -->|reads Tier| B1[Customer]
A1 -->|reads LoyaltyYears| B2[Customer]
A1 -->|reads LifetimeSpend| B3[Customer]
end
subgraph After
A2[CalculateDiscountRate] -->|calls| B4[Customer]
end
ที่เกี่ยวข้อง
หัวข้อที่มีชื่อว่า “ที่เกี่ยวข้อง”- Data Class — กลิ่นคู่หูที่มักเกิดพร้อมกัน: class ข้อมูลล้วน ๆ ที่ไม่มีพฤติกรรม ทำให้ตรรกะต้องไปอยู่ที่อื่น
- Tell, Don’t Ask — หลักการที่ชี้ทางแก้: บอกให้ object ทำงาน แทนที่จะถามข้อมูลออกมาคำนวณเอง
- Law of Demeter — กติกาว่าอย่าเอื้อมมือเกินระยะเพื่อนบ้านชั้นเดียว
- Anemic Domain Model — ผลลัพธ์ระดับสถาปัตยกรรมเมื่อ Feature Envy เกิดขึ้นทั่วทั้งระบบ
- Inappropriate Intimacy — กลิ่นใกล้เคียงที่ class สองฝั่งผูกติดกันแน่นเกินไปทั้งคู่ ไม่ใช่แค่ฝ่ายเดียวที่ envy
- Middle Man — ผลข้างเคียงถ้า refactor เกินขอบเขต จน class หนึ่งเหลือแต่การส่งต่อ (delegate) ล้วน ๆ