Artificial Coupling
บังคับให้แนวคิดที่ไม่เกี่ยวกันมามีความสัมพันธ์กันโดยไม่จำเป็น
กลิ่นนี้คืออะไร
หัวข้อที่มีชื่อว่า “กลิ่นนี้คืออะไร”Artificial Coupling คือกลิ่นที่ แนวคิดสองอย่างที่ไม่เกี่ยวข้องกันตามธรรมชาติถูกบังคับให้ผูกติดกัน ทั้งที่ domain หรือตรรกะทางธุรกิจไม่ได้เรียกร้องความสัมพันธ์นี้เลย ความผูกพันเกิดจาก “ความสะดวกของผู้เขียน code ณ ขณะนั้น” ล้วน ๆ เช่น หยิบ enum ทั่วไปไปประกาศซ้อนไว้ใน class เฉพาะเจาะจงตัวหนึ่งเพราะกำลังเขียน class นั้นอยู่พอดี หรือแปะ static method อรรถประโยชน์ (utility) ไว้ใน class แรกที่เจอเพราะขี้เกียจสร้าง file ใหม่ หรือสืบทอด base class เพียงเพื่อหยิบยืม field/method ไม่กี่ตัวมาใช้
Robert C. Martin ใน Clean Code เรียกกลิ่นนี้ว่า G13: Artificial Coupling และสรุปหลักการไว้สั้น ๆ ว่า “จงเลือก code ที่ชัดเจน มากกว่า code ที่สะดวก อย่าจัดกลุ่ม code ตามความสะดวกในการจดจำ (mental mapping) แทนที่จะจัดตามความชัดเจนของแนวคิด” — กล่าวคือ ต้องใช้เวลาคิดว่า function, constant, หรือ variable ตัวหนึ่ง “ควร” ประกาศอยู่ที่ไหนตามธรรมชาติของมัน ไม่ใช่โยนมันไว้ที่ตำแหน่งที่หยิบใช้ง่ายที่สุดในตอนนั้นแล้วปล่อยทิ้งไว้อย่างนั้นตลอดไป
จุดที่ทำให้ Artificial Coupling ต่างจากกลิ่นตระกูล Coupler อื่น ๆ (Feature Envy, Inappropriate Intimacy, Message Chains) คือ ที่นี่ไม่มีเหตุผลเชิงพฤติกรรมใด ๆ รองรับความสัมพันธ์เลย — มันคือ dependency ที่เกิดจาก “ที่วางของ” ไม่ใช่จาก “สิ่งที่ code ต้องทำ”
วิธีสังเกต
หัวข้อที่มีชื่อว่า “วิธีสังเกต”สัญญาณที่บอกว่ากำลังเจอ Artificial Coupling:
- ต้อง
using/reference class หนึ่งทั้ง class เพียงเพื่อเข้าถึง enum, constant, หรือ nested type เล็ก ๆ ตัวเดียวข้างในมัน - static utility method ที่ไม่เกี่ยวกับสถานะของ class เลย ถูกแปะไว้ใน class ทางธุรกิจ (เช่น
OrderProcessor.FormatCurrency(...)) เพียงเพราะเป็น class แรกที่ต้องการ formatting ตอนนั้น - inheritance ที่ไม่มีความสัมพันธ์แบบ “is-a” จริง แต่ทำไปเพื่อ “หยิบยืม” field หรือ method จาก base class
- เปลี่ยน code ใน module A แล้วต้อง build/แก้ module B ที่ไม่มีอะไรเกี่ยวข้องกันในเชิง domain เลย
- คำอธิบายในทีมที่ขึ้นต้นว่า “อ๋อ มันอยู่ตรงนั้นเพราะตอนเขียนครั้งแรกมันสะดวก” ไม่ใช่ “มันอยู่ตรงนั้นเพราะมันควรอยู่ตรงนั้น”
- ต้อง mock หรือ instantiate class ที่ไม่เกี่ยวข้องเลยแค่เพื่อทดสอบ unit เล็ก ๆ ตัวหนึ่ง
ทำไมถึงเป็นปัญหา
หัวข้อที่มีชื่อว่า “ทำไมถึงเป็นปัญหา”Coupling ที่ไม่มีเหตุผลรองรับสร้างต้นทุนแฝงหลายชั้น:
- ริปเปิลเอฟเฟกต์ผิดที่ — เปลี่ยนแนวคิด A (ที่ควรเป็นอิสระ) ดันไปกระทบแนวคิด B ที่ไม่เกี่ยวข้องกันจริง เพราะทั้งคู่ถูกบังคับให้อยู่ใน file/class เดียวกัน นี่คือเมล็ดพันธุ์ของ Shotgun Surgery และ Divergent Change
- ละเมิด Separation of Concerns และ SRP — class ที่แบก dependency ซึ่งไม่ใช่ของตัวเองมีเหตุผลให้เปลี่ยนแปลงมากกว่าหนึ่งเหตุผล ขัดกับ Single Responsibility โดยตรง
- ทำลาย encapsulation ของทั้งสองฝั่ง — ทั้ง class เจ้าของและ class ที่มายืมใช้ ต่างเผยรายละเอียดภายในให้กันโดยไม่จำเป็น เพิ่มพื้นที่เสี่ยงต่อ Inappropriate Intimacy
- reuse และ testability ต่ำลง — จะนำ enum หรือ utility ไปใช้ในบริบทอื่นก็ต้องลาก class ทั้งก้อนที่ไม่เกี่ยวข้องติดไปด้วย ทำให้เขียน unit test แบบแยกส่วนยากขึ้น
- สับสนต่อผู้มาใหม่ — โครงสร้าง code ไม่สื่อความหมายของ domain อีกต่อไป คนอ่านต้องจำ (“mental mapping”) ว่าอะไรอยู่ตรงไหนแทนที่จะอนุมานได้จากชื่อและตำแหน่ง
Artificial Coupling มักไม่ทำให้ code “พังทันที” แต่มันสะสมเป็นแรงเสียดทานที่ทำให้ codebase แข็งตัวขึ้นเรื่อย ๆ — ทุกการเปลี่ยนแปลงเล็ก ๆ ต้องเช็คผลกระทบข้ามขอบเขตที่ไม่ควรมีอยู่ตั้งแต่แรก
ตัวอย่างและการ refactor
หัวข้อที่มีชื่อว่า “ตัวอย่างและการ refactor”สมมติทีมต้องการ enum บอกวิธีจัดส่ง แต่ผู้เขียนคนแรกกำลังทำงานอยู่ใน Order เลยประกาศ enum นี้ซ้อนไว้ข้างในนั้น:
public class Order{ // enum นี้เป็นแนวคิดทั่วไป ไม่ได้ผูกกับ Order โดยธรรมชาติ // แต่ถูกซ้อนไว้ที่นี่เพราะสะดวกตอนเขียนครั้งแรก public enum ShippingMethod { Standard, Express, Overnight }
public ShippingMethod Method { get; set; } public decimal Total { get; set; }}
// Invoice.cs — ต้องรู้จัก Order ทั้ง class เพียงเพื่อใช้ enum เดียวข้างในpublic class Invoice{ public Order.ShippingMethod PreferredShipping { get; set; }
public decimal CalculateShippingSurcharge() { return PreferredShipping switch { Order.ShippingMethod.Overnight => 25.00m, Order.ShippingMethod.Express => 10.00m, _ => 0m }; }}Invoice ไม่ได้สนใจ Order เลยแม้แต่นิดเดียว มันแค่ต้องการแนวคิด “วิธีจัดส่ง” แต่ต้องอ้างอิง class Order ทั้งก้อนเพียงเพื่อไปหยิบ enum ข้างใน — ถ้าวันหนึ่ง Order ถูกย้ายไป assembly อื่น หรือถูก refactor ครั้งใหญ่ Invoice จะถูกดึงเข้าไปเกี่ยวข้องด้วยทั้งที่ไม่ควรต้องเป็นแบบนั้น
ก่อน refactor graph dependency หน้าตาแบบนี้:
flowchart LR
subgraph Before
InvoiceB[Invoice] --> OrderB[Order]
OrderB --> ShipB[ShippingMethod]
end
refactor ด้วยเทคนิคที่ใกล้เคียง Move Class ของ Fowler — ย้าย type ที่ยืนได้ด้วยตัวเองออกมาเป็น top-level type ของตัวเอง ไม่ซ้อนอยู่ใน class ใด class หนึ่ง:
// ShippingMethod.cs — แนวคิดที่ยืนได้ด้วยตัวเอง ไม่ผูกกับ Order หรือ Invoicepublic enum ShippingMethod { Standard, Express, Overnight }
public class Order{ public ShippingMethod Method { get; set; } public decimal Total { get; set; }}
// Invoice.cs — ไม่ต้องรู้จัก Order อีกต่อไปpublic class Invoice{ public ShippingMethod PreferredShipping { get; set; }
public decimal CalculateShippingSurcharge() { return PreferredShipping switch { ShippingMethod.Overnight => 25.00m, ShippingMethod.Express => 10.00m, _ => 0m }; }}หลัง refactor ทั้งสองฝั่งพึ่งพา ShippingMethod โดยตรง ไม่มี edge ปลอมระหว่างกันอีกต่อไป:
flowchart LR
subgraph After
InvoiceA[Invoice] --> ShipA[ShippingMethod]
OrderA[Order] --> ShipA
end
หลักการเดียวกันนี้ใช้ได้กับ static utility method ด้วย: ถ้า OrderProcessor มี public static string FormatCurrency(decimal amount) ที่ไม่แตะ field หรือ state ของ OrderProcessor เลย ให้ใช้ Extract Class ย้ายมันไปเป็น CurrencyFormatter แยกต่างหาก แล้วให้ทุก class ที่ต้องการ format เงินอ้างอิง CurrencyFormatter ตรง ๆ แทนที่จะต้องรู้จัก OrderProcessor ไปด้วย
ที่เกี่ยวข้อง
หัวข้อที่มีชื่อว่า “ที่เกี่ยวข้อง”- Separation of Concerns
- Single Responsibility
- Encapsulate What Varies
- Feature Envy
- Inappropriate Intimacy
- Hidden Dependencies