Hidden Dependencies
สิ่งที่ class พึ่งพา มองไม่เห็นผ่าน public interface
กลิ่นนี้คืออะไร
หัวข้อที่มีชื่อว่า “กลิ่นนี้คืออะไร”Hidden Dependencies คือกลิ่นที่ class หนึ่งพึ่งพาสิ่งอื่น เช่น service, ข้อมูล, หรือ configuration แต่สิ่งนั้น มองไม่เห็นผ่าน public interface ของ class แทนที่ dependency จะถูกประกาศอย่างชัดเจนใน constructor หรือ method signature กลับถูกได้มาแบบเงียบ ๆ ระหว่างทำงาน — ดึงจาก service locator, เรียก static method, อ่านจาก global variable/singleton .Instance, หรือแม้แต่ new object ที่เป็น concrete dependency ขึ้นมาเองกลาง method
กลิ่นนี้เป็นการละเมิด Explicit Dependencies Principle โดยตรง หลักการนั้นบอกว่า method หรือ class ควรประกาศสิ่งที่ตนต้องการอย่างตรงไปตรงมาผ่าน signature เพื่อให้ผู้เรียกใช้ทราบล่วงหน้าว่าต้องเตรียมอะไรบ้าง เมื่อ dependency ถูกซ่อนไว้ constructor ที่ดู “ว่างเปล่า” หรือรับ parameter น้อย อาจให้ความรู้สึกผิด ๆ ว่าใช้งานง่าย ทั้งที่จริงแล้วภายใน code อาจโยงใยกับ service อีกสิบตัวที่มองไม่เห็นเลยจากภายนอก
สาเหตุที่พบบ่อยของกลิ่นนี้ ได้แก่ Service Locator ที่ resolve dependency กลาง method, Singleton ที่เข้าถึงผ่าน .Instance แบบ global, static class/method ที่เก็บ state หรือทำงานแทน object (ดู Static Cling), การอ่าน environment variable หรือ configuration แบบ ambient, และการ new concrete class ขึ้นมาเองแทนที่จะรับผ่าน constructor
วิธีสังเกต
หัวข้อที่มีชื่อว่า “วิธีสังเกต”สัญญาณที่บ่งบอกว่ากำลังเจอ Hidden Dependencies:
- Constructor ว่างเปล่าหรือรับ parameter น้อย แต่ method ข้างในทำงานซับซ้อน — สร้าง object ด้วย
new SomeClass()ได้ง่ายเกินคาด ทั้งที่พฤติกรรมภายในซับซ้อนมาก - เจอการเรียก
ServiceLocator.Resolve<T>(),Container.Get<T>(), หรือ IoC container ใด ๆ แบบ manual อยู่กลาง method body ไม่ใช่ที่ composition root - เจอการอ้างถึง static class, singleton
.Instance, หรือ global mutable state ภายใน method - เจอการอ่านค่าโดยตรงจาก environment variable,
ConfigurationManager, หรือ file config แทนที่จะรับค่าที่ต้องการผ่าน parameter - เขียน unit test ให้ class นี้ทำงานลำบาก เพราะต้อง setup global state, register ค่าใน container กลาง, หรือ mock สิ่งที่ไม่ได้อยู่ใน signature เลย
- เปลี่ยน implementation ภายใน class แล้วจู่ ๆ ทดสอบตัวอื่นที่ไม่เกี่ยวกันพังไปด้วย เพราะ dependency ที่ share กันอยู่แบบซ่อนเร้น (โยงกับ Temporal Coupling และ Artificial Coupling)
- อ่าน source code ทั้ง method แล้วยังบอกไม่ได้ว่า class นี้ “ต้องการอะไร” จนกว่าจะไล่ตามทุกบรรทัด
ทำไมถึงเป็นปัญหา
หัวข้อที่มีชื่อว่า “ทำไมถึงเป็นปัญหา”Mark Seemann อธิบายไว้ว่าปัญหาหลักของ Service Locator คือมัน “ซ่อน dependency ของ class ทำให้เกิด run-time error แทนที่จะเป็น compile-time error” — เมื่อดู OrderProcessor ผ่าน IntelliSense จะเห็นแค่ constructor เปล่า ไม่มีทางรู้เลยว่า method ข้างในต้องการ IOrderValidator จนกว่าจะรันจริงแล้วเจอ exception ลึกเข้าไปในการเรียกใช้ ในขณะที่ constructor injection จะทำให้ compiler (หรืออย่างน้อย DI container ตอน compose graph) ฟ้องปัญหาให้เห็นทันทีตั้งแต่ต้น
ผลกระทบที่ตามมา:
- Dishonest API — public interface โกหกว่า class นี้ต้องการน้อย ทั้งที่จริงต้องการเยอะ ผู้ใช้ต้องเปิดอ่าน source code หรือเอกสารเพื่อรู้ความจริง ขัดกับหลัก Principle of Least Astonishment
- ทดสอบยาก — เพราะ dependency ที่แท้จริงไม่ได้อยู่ใน signature จึงแทนที่ด้วย mock/stub ตรง ๆ ไม่ได้ ต้อง setup global registry ก่อนแต่ละ test แล้ว teardown ทีหลัง ยิ่งถ้าใช้ singleton ที่เก็บ state ข้าม test ก็จะเกิด test-order coupling และ test flaky
- Global state เป็นแหล่งเพาะ tight coupling — Singleton ที่เข้าถึงได้จากทุกที่ทำให้เกิด hidden dependency กระจายไปทั่ว code โดยไม่มีใครรู้ตัว การเปลี่ยนพฤติกรรม singleton หนึ่งจุดอาจกระเพื่อมไปทั้งระบบ
- Maintenance ยากขึ้นเรื่อย ๆ — เมื่อจะแก้ไข class ที่ใช้ hidden dependency ต้องเข้าใจ application ทั้งระบบว่าใครพึ่งพาอะไรผ่าน locator/static บ้าง เพราะ compiler ช่วยอะไรไม่ได้เลย
- ขัดกับ Dependency Inversion Principle — module high-level ควรพึ่งพา abstraction ที่รับมาอย่างชัดเจน ไม่ใช่ไปค้นหา (reach out) เอาเองจากแหล่งกลาง
ตัวอย่างและการ refactor
หัวข้อที่มีชื่อว่า “ตัวอย่างและการ refactor”code ต่อไปนี้มี OrderProcessor ที่ constructor ว่างเปล่า แต่ภายใน method Process กลับดึง dependency ผ่าน service locator และ singleton แบบเงียบ ๆ:
public class OrderProcessor{ // constructor ว่างเปล่า ดูเหมือนไม่ต้องพึ่งพาอะไรเลย public OrderProcessor() { }
public void Process(Order order) { // dependency ถูกดึงมาแบบเงียบ ๆ กลาง method ไม่มีใครรู้จากภายนอก var validator = ServiceLocator.Resolve<IOrderValidator>(); var shipper = ServiceLocator.Resolve<IOrderShipper>();
if (!validator.IsValid(order)) { throw new InvalidOperationException("Order ไม่ผ่านการตรวจสอบ"); }
shipper.Ship(order);
// static call ไปยัง singleton ที่เป็น global state AuditLog.Instance.Record($"Processed order {order.Id}"); }}
// เรียกใช้จากที่อื่นในระบบvar processor = new OrderProcessor();processor.Process(order);// ดูปลอดภัยตอน compile แต่จะ throw runtime exception// ถ้า container ยังไม่ได้ register IOrderValidator ไว้ปัญหา: ใครก็ตามที่อ่าน signature ของ OrderProcessor จะเข้าใจผิดว่า class นี้ไม่ต้องการอะไรเลย และเขียน unit test ให้ class นี้แยกเดี่ยว ๆ ไม่ได้ เพราะต้อง setup service locator กับ singleton ก่อนเสมอ
refactor ด้วย Constructor Injection (บางแหล่งเรียกว่า Introduce Explicit Dependency หรือ Replace Dependency Lookup with Dependency Injection) — ย้าย dependency ทั้งหมดที่ method ต้องการจริงมาประกาศเป็น parameter ของ constructor:
public class OrderProcessor{ private readonly IOrderValidator _validator; private readonly IOrderShipper _shipper; private readonly IAuditLog _auditLog;
// dependency ทั้งหมดประกาศชัดเจนผ่าน constructor เป็นสัญญาที่ compiler ช่วยตรวจให้ public OrderProcessor(IOrderValidator validator, IOrderShipper shipper, IAuditLog auditLog) { _validator = validator ?? throw new ArgumentNullException(nameof(validator)); _shipper = shipper ?? throw new ArgumentNullException(nameof(shipper)); _auditLog = auditLog ?? throw new ArgumentNullException(nameof(auditLog)); }
public void Process(Order order) { if (!_validator.IsValid(order)) { throw new InvalidOperationException("Order ไม่ผ่านการตรวจสอบ"); }
_shipper.Ship(order); _auditLog.Record($"Processed order {order.Id}"); }}
// composition root เดียวเท่านั้นที่รู้จัก container — ที่เหลือของระบบไม่ต้องรู้จักเลยvar processor = new OrderProcessor( container.Resolve<IOrderValidator>(), container.Resolve<IOrderShipper>(), container.Resolve<IAuditLog>());processor.Process(order);สังเกตว่า AuditLog.Instance (singleton) ก็ถูกแปลงเป็น IAuditLog ที่รับผ่าน constructor ด้วย เพราะ singleton ก็คือ hidden dependency รูปแบบหนึ่ง ตอนนี้ทุกอย่างที่ OrderProcessor ต้องการปรากฏอยู่ใน signature เดียว ใครจะสร้าง instance ก็เห็นทันทีว่าต้องเตรียมอะไรบ้าง และการ resolve จาก container ถูกผลักไปอยู่ที่ composition root จุดเดียวของ app (ตาม Explicit Dependencies Principle และแนวคิด Dependency Injection)
แผนภาพต่อไปนี้เทียบ flow แบบเดิมที่ dependency ถูกซ่อนไว้กับ flow ใหม่ที่ dependency ถูกเปิดเผยตั้งแต่จุดสร้าง object:
flowchart TD
ClientOld[Client code เดิม] --> CtorOld[new OrderProcessor ว่างเปล่า]
CtorOld --> ProcessOld[เรียก Process]
ProcessOld -.-> HiddenLookup[ServiceLocator Resolve กลาง method]
HiddenLookup -.-> RuntimeFail[runtime error ถ้าลืม register]
ClientNew[Client code ใหม่] --> CtorNew[new OrderProcessor พร้อม dependency ครบ]
CtorNew --> ProcessNew[เรียก Process ตรงไปตรงมา]
ที่เกี่ยวข้อง
หัวข้อที่มีชื่อว่า “ที่เกี่ยวข้อง”- Explicit Dependencies Principle
- Dependency Inversion Principle
- Dependency Injection
- Service Locator
- Singleton
- Static Cling