ข้าม​ไป​ยัง​เนื้อหา

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) เอา​เอง​จาก​แหล่ง​กลาง

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 ตรงไปตรงมา]