Explicit Dependencies Principle
class ควรร้องขอสิ่งที่ต้องพึ่งพาอย่างเปิดเผย
เคยอยากทำอาหารตามสูตร แต่พบว่าสูตรลืมใส่วัตถุดิบบางอย่างในรายการซื้อของไหม? เช่นเดียวกับวัตถุดิบในสูตรอาหาร การเข้าใจ dependency ของ class/package/library/framework ก่อนทำงานกับมันเป็นเรื่องสำคัญ เมื่อวัตถุดิบทั้งหมดถูกระบุอย่างชัดเจน การทำตามสูตรก็ง่ายขึ้น
Implicit dependency (การพึ่งพาโดยปริยาย) และ transitive dependency (การพึ่งพาทางอ้อม) ทำให้การทำงานกับ class/package/library/framework ยากขึ้น นี่คือเหตุผลที่มี Explicit Dependencies Principle ซึ่งกล่าวว่า:
method และ class ควรร้องขอ object ผู้ร่วมงานที่จำเป็นต่อการทำงานอย่างถูกต้อง อย่างเปิดเผย (โดยทั่วไปผ่าน parameter ของ method หรือ constructor)
แนวคิดหลัก
หัวข้อที่มีชื่อว่า “แนวคิดหลัก”หาก class ของคุณต้องพึ่ง class อื่นเพื่อทำงาน class อื่นเหล่านั้นคือ dependency — dependency เหล่านี้เป็น implicit หากมันมีอยู่เพียงใน code ภายใน class ของคุณ ไม่ปรากฏใน public interface ส่วน explicit dependency มักปรากฏใน constructor ของ object (สำหรับ dependency ระดับ class) หรือในรายการ parameter ของ method หนึ่ง (สำหรับ dependency ที่เฉพาะที่มากกว่า)
รูปแบบทั่วไปของ implicit dependency ที่พบบ่อยที่สุดมีสามแบบ:
- การสร้าง instance ภายใน (
newโดยตรง) — method เรียกnew SqlLogger()เองกลาง function แทนที่จะรับ logger จากภายนอก - การเข้าถึง static member หรือ singleton แบบ global — เช่น class
Contextที่เก็บ state ร่วมให้ทั้ง app เข้าถึงได้จากทุกที่ (ดู Static Cling) - Service Locator — class ร้องขอ
ServiceLocator.Get<ILogger>()จากภายใน method ซึ่งแม้จะดูเหมือนใช้ dependency injection แต่ยังคงซ่อน dependency จาก public API เพราะไม่มีใครรู้ว่า class ต้องพึ่งอะไรจนกว่าจะไล่อ่าน code ภายในหรือรันแล้วพัง — Mark Seemann เรียกสิ่งนี้ว่าปัญหาของ “dishonest API” ใน Service Locator is an Anti-Pattern ของเขา
ทางแก้ที่ยึด Explicit Dependencies Principle คือ constructor injection และ method injection: ให้ dependency ปรากฏเป็น parameter ทำให้ compiler และคนอ่าน code เห็นตรงกันว่า class ต้องการอะไรบ้างก่อนจะทำงานได้ถูกต้อง — แนวทางนี้คือรากฐานของแนวปฏิบัติ Dependency Injection และเป็นเงื่อนไขเบื้องต้นของการทำ Inversion of Control ให้สำเร็จ
แผนภาพต่อไปนี้เปรียบเทียบทิศทางการค้นหา dependency ระหว่างสองวิธี:
flowchart LR
subgraph Implicit
A1[Method Body] -->|new or static call| B1[Hidden Dependency]
end
subgraph Explicit
C1[Constructor Parameter] --> D1[Class Field]
D1 --> E1[Method Body]
F1[Caller / Composition Root] --> C1
end
ฝั่งซ้าย method ต้องรู้จักและสร้าง dependency ของตัวเอง — ทิศทางพึ่งพาซ่อนอยู่ใน code ฝั่งขวา ผู้เรียก (หรือ composition root) เป็นคนจัดหา dependency ให้ตั้งแต่ตอนสร้าง object ทิศทางพึ่งพาจึงมองเห็นได้จาก signature ของ constructor เอง
ทำไมถึงสำคัญ
หัวข้อที่มีชื่อว่า “ทำไมถึงสำคัญ”class ที่มี implicit dependency มีต้นทุนดูแลรักษาสูงกว่า class ที่มี explicit dependency ด้วยเหตุผลหลายข้อ:
- ทดสอบยากกว่า เพราะ coupled กับผู้ร่วมงานแน่นกว่า — ไม่มีจุดใดให้ inject test double เข้าไปแทนที่ dependency จริงได้ ต้องพึ่ง global state หรือ monkey-patch ซึ่งเปราะบางและมักทำให้ unit test รันพร้อมกันไม่ได้
- วิเคราะห์ side effect ยากกว่า เพราะต้องค้นทั้ง codebase ของ class เพื่อหาการสร้าง instance หรือการเรียก static method — ผู้พัฒนาไม่สามารถเชื่อ public interface ได้อีกต่อไป ต้องอ่าน implementation ทุกครั้ง
- เปราะบางกว่า — การเปลี่ยนแปลงที่ dependency ที่ซ่อนอยู่ (เช่นเปลี่ยน schema ของ config global) อาจทำให้ class ที่ดูเหมือนไม่เกี่ยวข้องพังโดยไม่มีสัญญาณเตือนที่ compile time เพราะ error จะกลายเป็น runtime error แทน
ในทางกลับกัน class ที่มี explicit dependency ซื่อสัตย์กว่า มันบอกอย่างชัดเจนว่าต้องการอะไรเพื่อทำงาน มักยึด Principle of Least Surprise ด้วยการไม่ไปกระทบส่วนของ application ที่ไม่ได้แสดงว่าจำเป็นต้องกระทบ และ explicit dependency สลับเปลี่ยนกับ implementation อื่นได้ง่าย ไม่ว่าใน production หรือระหว่างทดสอบและ debug จึงดูแลง่ายกว่าและเปิดรับการเปลี่ยนแปลงมากกว่า
Microsoft เองก็ให้แนวทางในทิศทางเดียวกันสำหรับ .NET: “Avoid direct instantiation of dependent classes within services. Direct instantiation couples the code to a particular implementation” และ “Avoid using the service locator pattern” เพราะมันปะปนแนวทาง Inversion of Control สองแบบเข้าด้วยกันจนคาดเดาพฤติกรรมยาก (ดู Dependency injection guidelines ของ .NET)
หลักการนี้เกี่ยวข้องอย่างใกล้ชิดกับ Dependency Inversion Principle และ Hollywood Principle — DIP บอกว่าให้พึ่งพา abstraction ไม่ใช่ concrete implementation ส่วน Explicit Dependencies Principle บอกวิธีที่ทำให้การพึ่งพา abstraction นั้นมองเห็นได้จากภายนอก class ทั้งสองหลักการทำงานร่วมกัน: DIP กำหนด “พึ่งอะไร” ส่วน Explicit Dependencies Principle กำหนด “ต้องประกาศให้เห็นตรงไหน”
ตัวอย่าง
หัวข้อที่มีชื่อว่า “ตัวอย่าง”class PersonalizedResponse ต่อไปนี้ผูกแน่นกับ file system, system clock และลูกค้ารายหนึ่งผ่าน class Context ที่เป็น global — ทั้งหมดถูกซ่อนไว้และไม่ปรากฏใน interface:
Bad: dependency ถูกซ่อนอยู่ภายใน มองไม่เห็นจากภายนอก
public static class Context{ public static Customer CurrentCustomer { get; set; }
public static void Log(string message) { using (StreamWriter logFile = new StreamWriter( Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), "logfile.txt"))) { logFile.WriteLine(message); } }}
public class PersonalizedResponse{ public string GetResponse() { // เรียก static method ตรง ๆ — มองจาก signature ไม่มีทางรู้ว่า method นี้ทำ I/O Context.Log("Generating personalized response."); string formatString = "Good {0}, {1} {2}! Would you like a {3} widget today?"; string timeOfDay = "afternoon"; if (DateTime.Now.Hour < 12) { timeOfDay = "morning"; } if (DateTime.Now.Hour > 17) { timeOfDay = "evening"; } // ดึงลูกค้าจาก global state แทนที่จะรับเป็น parameter return String.Format(formatString, timeOfDay, Context.CurrentCustomer.Title, Context.CurrentCustomer.Fullname, Context.CurrentCustomer.FavoriteColor); }}code นี้ทดสอบไม่ได้เลยหากไม่เซ็ต global state ก่อน ผลลัพธ์ยังขึ้นกับเวลาจริงของเครื่อง (DateTime.Now) ทำให้ test ไม่ deterministic และหากลืมเซ็ต Context.CurrentCustomer ก่อนเรียก จะได้ NullReferenceException ที่ runtime แทนที่จะเป็น compile error
เมื่อ refactor ให้ dependency ชัดเจน logger และเวลาถูกดึงมาเป็น parameter ของ constructor ส่วนลูกค้าที่ถูกกระทำถูกดึงมาเป็น parameter ของ method ผลคือ code ที่ใช้ได้ก็ต่อเมื่อสิ่งที่มันต้องการถูกจัดหาให้แล้วเท่านั้น:
Good: dependency ถูกประกาศชัดเจนใน constructor และ parameter
public interface ILogger { void Log(string message); }
public class SimpleFileLogger : ILogger{ public void Log(string message) { using (StreamWriter logFile = new StreamWriter( Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), "logfile.txt"))) { logFile.WriteLine(message); } }}
public interface IDateTime { DateTime Now { get; } }
public class SystemDateTime : IDateTime{ public DateTime Now { get { return DateTime.Now; } }}
public class PersonalizedResponse{ private readonly ILogger _logger; private readonly IDateTime _dateTime;
// dependency ระดับ class: ประกาศผ่าน constructor public PersonalizedResponse(ILogger logger, IDateTime dateTime) { this._dateTime = dateTime; this._logger = logger; }
// dependency ระดับ method: ประกาศผ่าน parameter เพราะเฉพาะ method นี้เท่านั้นที่ใช้ customer public string GetResponse(Customer customer) { _logger.Log("Generating personalized response."); string formatString = "Good {0}, {1} {2}! Would you like a {3} widget today?"; string timeOfDay = "afternoon"; if (_dateTime.Now.Hour < 12) { timeOfDay = "morning"; } if (_dateTime.Now.Hour > 17) { timeOfDay = "evening"; } return String.Format(formatString, timeOfDay, customer.Title, customer.Fullname, customer.FavoriteColor); }}ตอนนี้ PersonalizedResponse ทดสอบได้ง่ายด้วย fake ILogger และ IDateTime ที่คืนค่าเวลาคงที่ ไม่ต้องแตะ global state ใด ๆ และ compiler จะบังคับให้ผู้เรียกส่ง dependency ทั้งหมดมาให้ก่อนจะสร้าง instance ได้เลย ความผิดพลาดจาก dependency ที่ขาดหายจึงกลายเป็น compile error แทนที่จะเป็น runtime error
การเลือกกำหนด dependency ที่ระดับ class หรือระดับ method ขึ้นอยู่กับวิธีที่ class ใช้มัน จำนวน method ที่อ้างถึงสิ่งนั้น ฯลฯ — ในตัวอย่างนี้ทุกอย่างอาจส่งผ่านเป็น parameter ของ method ก็ได้ แต่แสดงทั้งสองแบบไว้เพื่อการเปรียบเทียบ
สัญญาณว่ากำลังละเมิดหลักการนี้
หัวข้อที่มีชื่อว่า “สัญญาณว่ากำลังละเมิดหลักการนี้”- class เรียก
new SomeService()กลาง method แทนที่จะรับSomeService(หรือ interface ของมัน) เป็น parameter - class เข้าถึง static property/method หรือ singleton แบบ global (เช่น
Context.CurrentCustomer,ConfigurationManager.AppSettings) เพื่อดึงข้อมูลที่จำเป็นต่อการทำงาน — ดู Static Cling - class เรียก
ServiceLocator.Resolve<T>()หรือเทียบเท่าจากภายใน method แทนที่จะรับTมาทาง constructor - Constructor รับ parameter น้อยผิดปกติเมื่อเทียบกับสิ่งที่ method ข้างในทำจริง — เป็นสัญญาณว่ามี dependency ซ่อนอยู่ที่ไม่ได้ประกาศ
- หน่วยทดสอบของ class ต้อง set up global state หรือ mock static class ก่อนรันเสมอ แทนที่จะ inject test double ตรง ๆ ผ่าน constructor
- อ่าน public interface ของ class แล้วยังทายไม่ได้ว่ามันทำ I/O, เขียน file, หรือยิง network call — เพราะสิ่งเหล่านั้นถูกซ่อนอยู่ใน code ภายใน
ที่เกี่ยวข้อง
หัวข้อที่มีชื่อว่า “ที่เกี่ยวข้อง”- Dependency Inversion Principle
- Inversion of Control
- Hollywood Principle
- Dependency Injection (practice)
- Static Cling (antipattern)
- Least Astonishment