เรียนรู้ว่า Dependency Injection ช่วยให้โค้ดทดสอบ แก้ไข และขยายได้ง่ายขึ้น สำรวจแพตเทิร์นเชิงปฏิบัติ ตัวอย่าง และข้อควรระวังที่ควรหลีกเลี่ยง

Dependency Injection (DI) คือแนวคิดง่าย ๆ: แทนที่โค้ดจะเป็นฝ่ายสร้างสิ่งที่ต้องการเอง คุณจะ ให้ สิ่งเหล่านั้นกับมันจากภายนอก
สิ่งที่ "มันต้องการ" เหล่านี้คือ dependencies — เช่น การเชื่อมต่อฐานข้อมูล, บริการชำระเงิน, นาฬิกา, logger หรือผู้ส่งอีเมล หากโค้ดของคุณเข้ามาสร้าง dependencies เหล่านี้เอง มันจะล็อกไว้กับ วิธีที่ dependencies เหล่านั้นทำงาน
ลองนึกถึงตู้ชงกาแฟในออฟฟิศ มันต้องพึ่งน้ำ เมล็ดกาแฟ และไฟฟ้า
DI คือแนวทางที่สอง: "เครื่องกาแฟ" (คลาส/ฟังก์ชันของคุณ) โฟกัสที่การชงกาแฟ (งานของมัน) ขณะที่ "วัสดุ" (dependencies) ถูกจัดเตรียมโดยคนที่ตั้งระบบให้
DI ไม่ได้หมายความว่าคุณต้องใช้ framework เฉพาะ และไม่ใช่แค่ DI container เท่านั้น คุณสามารถทำ DI ด้วยตัวเองโดยส่ง dependencies เป็นพารามิเตอร์ (หรือผ่าน constructor) แล้วเสร็จ
DI ก็ไม่ใช่การ "mocking" โดยตรง แม้ mocking จะเป็นวิธีที่มักใช้กับ DI ในการทดสอบ แต่ DI เองเป็นเพียงแนวทางการออกแบบเกี่ยวกับที่มาของการสร้าง dependencies
เมื่อ dependencies ถูกให้จากภายนอก โค้ดของคุณจะรันได้ง่ายขึ้นในหลายบริบท: production, unit tests, เดโม และฟีเจอร์ในอนาคต
ความยืดหยุ่นเดียวกันนี้ทำให้โมดูลสะอาดขึ้น: ส่วนต่าง ๆ สามารถถูกแทนที่ได้โดยไม่ต้องเดินสายระบบทั้งระบบ ผลลัพธ์คือเทสต์เร็วขึ้นและชัดเจนขึ้น (เพราะคุณสามารถสลับตัวทดแทนที่เรียบง่ายได้) และโค้ดฐานเปลี่ยนแปลงง่ายขึ้น (เพราะส่วนต่าง ๆ ถูกลดการผูกกัน)
การผูกกันแน่นเกิดขึ้นเมื่อส่วนหนึ่งของโค้ดของคุณ ตัดสินใจโดยตรง ว่าจะใช้ส่วนอื่นอย่างไร รูปแบบที่พบได้บ่อยที่สุดคือการเรียก new ภายใน logic ทางธุรกิจ
นึกถึงฟังก์ชัน checkout ที่เรียก new StripeClient() และ new SmtpEmailSender() ภายใน ตอนแรกมันดูสะดวก—ทุกอย่างอยู่ที่นั่น แต่ก็ล็อก flow ของ checkout กับ implementation เหล่านั้น กฎการกำหนดค่า และแม้แต่กฎการสร้าง (API keys, timeouts, พฤติกรรมเครือข่าย)
การผูกนี้เป็น "แบบซ่อนเร้น" เพราะมันไม่เห็นจาก signature ของเมธอด ฟังก์ชันดูเหมือนแค่ประมวลผลคำสั่งซื้อ แต่จริง ๆ แล้วมันพึ่งพา payment gateways, ผู้ให้บริการอีเมล และบางทีฐานข้อมูลด้วย
เมื่อ dependencies ถูก hard-coded การเปลี่ยนแปลงเล็ก ๆ ก็สร้างปรากฏการณ์ตามมามากมาย:\n\n- การเปลี่ยนผู้ให้บริการ (Stripe → Adyen) หมายถึงแก้ business logic แทนที่จะสลับคอมโพเนนต์\n- การเพิ่ม caching, retries หรือ logging บังคับให้คุณดันความกังวลผ่านหลายจุดเรียก\n- การอัปเกรดไลบรารีอาจกลายเป็นการ refactor ขนาดใหญ่เพราะการสร้างถูกกระจายอยู่
Dependencies ที่ถูก hard-code ทำให้ unit tests ต้องทำงานจริง: เรียกเครือข่าย, file I/O, นาฬิกา, ID สุ่ม หรือทรัพยากรร่วม เทสต์จึงช้ากว่าเพราะไม่เป็นเอกเทศ และไม่เสถียรเพราะผลลัพธ์ขึ้นกับเวลา บริการภายนอก หรือการเรียงลำดับการรัน
หากคุณเห็นรูปแบบเหล่านี้ การผูกแน่นอาจกำลังทำให้คุณเสียเวลา:\n\n- สเตตัสระดับโลกถูกใช้เป็น dependency แบบเงียบ\n- Singletons ที่รีเซ็ตระหว่างเทสต์ได้ยาก\n- new กระจัดกระจายอยู่ใน logic แกนหลัก\n- โค้ดที่ทดสอบไม่ได้โดยไม่ใช้ฐานข้อมูล, เว็บเซิร์ฟเวอร์ หรือ API key จริง\n
Dependency Injection แก้ปัญหานี้ด้วยการทำให้ dependencies ชัดเจนและสลับได้—โดยไม่ต้องเขียน logic ทางธุรกิจใหม่เมื่อโลกเปลี่ยน
Inversion of Control (IoC) คือการเปลี่ยนความรับผิดชอบ: คลาสควรโฟกัสที่ สิ่งที่ต้องทำ ไม่ใช่ วิธีการหาเครื่องมือที่ต้องการ
เมื่อคลาสสร้าง dependency ของตัวเอง (เช่น new EmailService() หรือเปิดการเชื่อมต่อฐานข้อมูลโดยตรง) มันก็รับสองงาน: business logic และ การตั้งค่า นั่นทำให้คลาสเปลี่ยนยาก นำกลับมาใช้ซ้ำยาก และทดสอบยาก
ด้วย IoC โค้ดของคุณพึ่งพา abstractions — เช่น interfaces หรือชนิด "สัญญา" เล็ก ๆ — แทน implementation เฉพาะ
ตัวอย่าง CheckoutService ไม่จำเป็นต้องรู้ว่า payment จะผ่าน Stripe, PayPal, หรือตัวประมวลผลปลอม มันต้องการแค่ "สิ่งที่สามารถเรียกเก็บบัตรได้" ถ้า CheckoutService ยอมรับ IPaymentProcessor มันจะทำงานกับ implementation ใดก็ได้ที่ทำตามสัญญานั้น
สิ่งนี้ทำให้ core logic คงที่แม้เครื่องมือพื้นฐานจะเปลี่ยน
ส่วนปฏิบัติของ IoC คือย้ายการสร้าง dependency ออก จากคลาสแล้วส่งเข้ามา (บ่อยครั้งผ่าน constructor) นี่คือที่ที่ DI เข้ามา: DI เป็นวิธีทั่วไปเพื่อให้เกิด IoC
แทนที่จะ:\n\n- คลาสเลือกและสร้าง collaborator ของมัน\n\nคุณจะได้:\n\n- คลาสรับ collaborator จากภายนอก\n ผลลัพธ์คือความยืดหยุ่น: การสลับพฤติกรรมกลายเป็นเรื่องการตั้งค่า ไม่ใช่การเขียนใหม่
ถ้าคลาสไม่สร้าง dependency เอง ก็ต้องมีบางอย่างสร้างสิ่งนั้น สิ่งนั้นคือ composition root: ที่ที่แอปพลิเคชันของคุณประกอบเข้าด้วยกัน—โดยทั่วไปคือโค้ดเริ่มต้น
composition root คือที่ที่คุณตัดสินใจว่า "ใน production ใช้ RealPaymentProcessor; ในเทสต์ใช้ FakePaymentProcessor" การเก็บ wiring ไว้ในที่เดียวช่วยลดความประหลาดใจและทำให้โค้ดส่วนอื่นโฟกัสได้
IoC ทำให้ unit tests ง่ายขึ้นเพราะคุณสามารถให้ test double ที่เล็กและเร็วแทนการเรียกเครือข่ายจริงหรือฐานข้อมูล
มันยังทำให้การรีแฟกเตอร์ปลอดภัยขึ้น: เมื่อความรับผิดชอบแยกกัน การเปลี่ยน implementation ไม่ค่อยบังคับให้เปลี่ยนคลาสที่ใช้มัน—ตราบใดที่ abstraction ยังเหมือนเดิม
Dependency Injection (DI) ไม่ใช่เทคนิคเดียว—มันคือชุดวิธีเล็ก ๆ ในการ "ป้อน" สิ่งที่คลาสต้องการ (เช่น logger, client DB, หรือ payment gateway) สไตล์ที่คุณเลือกส่งผลต่อความชัดเจน การทดสอบ และความง่ายต่อการใช้งานผิดวิธี
ด้วย constructor injection dependencies เป็นสิ่งที่ต้องมีก่อนสร้างอ็อบเจ็กต์ ข้อดีคือ: คุณจะไม่ลืมส่ง dependencies
เหมาะเมื่อ dependency:\n\n- จำเป็นตลอดเวลาที่อ็อบเจ็กต์ทำงาน\n- ถูกใช้หลายเมธอด\n- สำคัญต้องตรวจสอบตอนต้น (เช่น ห้ามเป็น null/undefined)
Constructor injection ให้โค้ดที่ชัดเจนที่สุดและเทสต์ที่ตรงไปตรงมาที่สุด เพราะเทสต์สามารถส่ง fake หรือ mock ในตอนสร้างได้
บางครั้ง dependency จำเป็นแค่สำหรับการทำงานครั้งเดียว เช่น formatter พิเศษ หรือค่าสนามคำขอ ในกรณีเหล่านี้ส่งเป็นพารามิเตอร์เมธอดจะดีกว่า ป้องกันไม่ให้สิ่งที่ใช้ครั้งเดียวกลายเป็นฟิลด์ถาวร
Setter injection สะดวกเมื่อคุณไม่สามารถให้ dependency ตอนสร้างได้ (บาง framework หรือโค้ดเก่า) แต่ข้อเสียคือตะปูอาจซ่อนความต้องการ: คลาสอาจดูกำลังใช้งานได้แม้ยังไม่ถูกตั้งค่าเต็มที่
สิ่งนี้มักนำไปสู่ความประหลาดใจเวลารัน ("ทำไมค่านี้เป็น undefined?") และทำให้เทสต์เปราะบางเพราะการตั้งค่าหายได้ง่าย
Unit tests มีประโยชน์ที่สุดเมื่อมัน เร็ว, ทำซ้ำได้, และ โฟกัสพฤติกรรมเดียว ทันทีที่เทสต์หน่วยต้องพึ่งฐานข้อมูลจริง, เครือข่าย, ไฟล์ หรือเวลา มันมักจะช้าลงและเปราะบางขึ้น ยิ่งไปกว่านั้นข้อผิดพลาดก็ไม่ชัดเจน: โค้ดเสียหรือสิ่งแวดล้อมมีปัญหา?
DI แก้ปัญหานี้โดยให้โค้ดรับสิ่งที่ต้องการจากภายนอก ในเทสต์คุณสามารถสลับ dependencies เหล่านั้นด้วยตัวแทนที่เบาและคาดเดาได้
DB หรือ API จริงเพิ่มเวลา setup และ latency ด้วย DI คุณสามารถฉีด repository แบบ in-memory หรือ fake client ที่คืนค่าตามที่เตรียมไว้ทันที ผลคือ:\n\n- เทสต์มากขึ้นในงบเวลาที่เท่ากัน\n- คุณมีแนวโน้มรันเทสต์บ่อยขึ้น\n- CI รันเร็วขึ้น
ถ้าโค้ด new() ขึ้นมาเอง เทสต์มักถูกบังคับให้รันสแตกทั้งหมด ด้วย DI คุณสามารถฉีด:\n\n- mocks เพื่อตรวจการโต้ตอบ (เช่น "ส่งอีเมลหนึ่งฉบับ")\n- stubs เพื่อตั้งค่าผลลัพธ์เฉพาะ (เช่น "ผู้ใช้มีอยู่")\n- fakes ที่ทำงานแบบง่าย (เช่น store ในหน่วยความจำ)
ไม่มีทริกหรือสวิทช์ระดับโลก—แค่ส่ง implementation ต่างกัน
DI ทำให้การตั้งค่าง่ายขึ้น แทนที่จะค้นหาการกำหนดค่า connection strings หรือ environment variables ของเทสต์ คุณอ่านเทสต์แล้วเห็นได้ทันทีว่าอะไรเป็นของจริงและอะไรถูกแทนที่
เทสต์แบบ DI-friendly มักเป็น:\n\n1) Arrange: สร้าง service ด้วย fake repository และ stubbed clock\n2) Act: เรียกเมธอด\n3) Assert: ตรวจค่าที่คืนและ/หรือยืนยันการเรียกของ mock
ความตรงไปตรงมานี้ลดเสียงรบกวนและทำให้การวินิจฉัยความล้มเหลวง่ายขึ้น
Test seam คือช่องเปิดที่ตั้งใจให้คุณสลับพฤติกรรมได้ ใน production คุณเสียบของจริง ในเทสต์คุณเสียบตัวแทนที่ปลอดภัยและเร็ว DI เป็นหนึ่งในวิธีที่ง่ายที่สุดในการสร้าง seam เหล่านี้โดยไม่ต้องใช้ทริก
Seams มักมีประโยชน์รอบส่วนที่ควบคุมยากในการทดสอบ:\n\n- เวลา (วันที่/เวลาเปลี่ยนตลอด)\n- ไฟล์ระบบ (ช้า, สิทธิ์, cleanup)\n- อีเมล/SMS (ผลข้างเคียง, บริการภายนอก)\n- payment gateways (เงินจริง, ความล้มเหลวเครือข่าย)
ถ้าพื้นที่ธุรกิจเรียกสิ่งเหล่านี้โดยตรง เทสต์จะเปราะบาง: ล้มเพราะเวลาจริง, เครือข่าย, หรือสถานะร่วมของเครื่อง
Seam มักมาในรูปแบบ interface — หรือในภาษา dynamic คือสัญญาง่าย ๆ เช่น "อ็อบเจ็กต์นี้ต้องมีเมธอด now()" ความคิดหลักคือพึ่งพา สิ่งที่ต้องการ ไม่ใช่ มาจากไหน\n\nตัวอย่าง แทนที่จะเรียกนาฬิการะบบตรง ๆ ภายใน order service คุณพึ่งพา Clock:\n\n- Production: SystemClock.now()\n- Test: FakeClock.now() คืนค่าเวลาแบบคงที่\n
แบบเดียวกันใช้ได้กับ file reads (FileStore), ส่งอีเมล (Mailer), หรือเก็บเงิน (PaymentGateway) core logic ของคุณไม่เปลี่ยน แค่ implementation ที่เสียบเข้ามาเปลี่ยน
เมื่อคุณสลับพฤติกรรมได้โดยตั้งใจ:\n\n- เทสต์น้อยลงเปราะบาง: ไม่ต้องพึ่งเวลา, เครือข่าย, หรือสถานะร่วม\n- กรณีมุมง่ายขึ้น: จำลอง "การชำระเงินถูกปฏิเสธ" หรือ "timeout ของผู้ให้บริการอีเมล" ได้ง่าย\n- ข้อผิดพลาดชัดเจนขึ้น: ถ้าเทสต์ล้ม มักเป็นเพราะกฎธุรกิจไม่ถูกต้อง ไม่ใช่สภาพแวดล้อม
การวาง seam ให้ดีลดความจำเป็นในการ mock หนาแน่นทั่วโค้ด และแทนที่ด้วยจุดสลับที่สะอาดไม่กี่จุด
ความเป็นโมดูลคือแนวคิดที่ซอฟต์แวร์ประกอบด้วยส่วนอิสระ (โมดูล) ที่มีขอบเขตชัดเจน: แต่ละโมดูลรับผิดชอบเฉพาะงานและมีวิธีโต้ตอบกับระบบที่ชัดเจน
Dependency injection ช่วยเรื่องนี้โดยทำให้ขอบเขตนั้นชัดเจน แทนที่โมดูลจะค้นหา/สร้างทุกสิ่งที่ต้องการ มันรับ dependencies จากภายนอก การเปลี่ยนเล็ก ๆ นี้ลดสิ่งที่โมดูลหนึ่งรู้เกี่ยวกับอีกโมดูลหนึ่ง
เมื่อโค้ดสร้าง dependencies ภายในตัวเอง (เช่น new-ing DB client ภายใน service) ผู้เรียกกับ dependency จะผูกกันแน่น DI กระตุ้นให้คุณพึ่งพา interface (หรือสัญญา) แทน implementation เฉพาะ
นั่นหมายความว่าโมดูลโดยทั่วไปจำเป็นต้องรู้:\n\n- สิ่งที่มันต้องการ (เช่น PaymentGateway.charge() )\n- ไม่ใช่วิธีการที่ถูก implement (Stripe vs PayPal vs sandbox)
ผลคือโมดูลเปลี่ยนแปลงร่วมกันน้อยลง เพราะรายละเอียดภายในไม่รั่วไหลข้ามขอบเขต
โค้ดแบบโมดูลควรมอบให้คุณสลับคอมโพเนนต์โดยไม่ต้องแก้โค้ดของคนเรียก DI ทำให้เป็นไปได้:\n\n- แทนที่ผู้ส่งอีเมลจริงด้วย queued sender\n- เปลี่ยนจาก repository แบบไฟล์เป็นฐานข้อมูล\n- ใส่ caching decorator รอบบริการที่มีอยู่
ในแต่ละกรณี ผู้เรียกยังคงใช้สัญญาเดิม การเดินสายเปลี่ยนที่เดียว (composition root) แทนการแก้หลายจุด
ขอบเขต dependency ที่ชัดเจนช่วยให้ทีมทำงานขนานกันง่ายขึ้น ทีมหนึ่งสามารถพัฒนา implementation ใหม่เบื้องหลัง interface ที่ตกลงไว้ ขณะที่ทีมอื่นทำงานต่อกับฟีเจอร์ที่พึ่ง interface นั้น
DI ยังช่วยรีแฟกเตอร์ทีละขั้น: คุณสามารถแยกโมดูล ฉีดมัน และแทนที่ทีละน้อยโดยไม่ต้อง rewrite ครั้งใหญ่
การเห็น DI ในโค้ดทำให้เข้าใจได้เร็วกว่าอ่านคำจำกัดความ นี่คือตัวอย่างเล็ก ๆ ของฟีเจอร์การแจ้งเตือน
เมื่อคลาสเรียก new ภายใน มันตัดสินใจว่าจะใช้ implementation ไหนและสร้างอย่างไร
class EmailService {
send(to, message) {
// talks to real SMTP provider
}
}
class WelcomeNotifier {
notify(user) {
const email = new EmailService();
email.send(user.email, "Welcome!");
}
}
ปัญหาในการทดสอบ: เทสต์หน่วยเสี่ยงเรียกพฤติกรรมการส่งอีเมลจริง (หรือจำเป็นต้อง stub ระดับโลกที่ไม่สะดวก)
test("sends welcome email", () => {
const notifier = new WelcomeNotifier();
notifier.notify({ email: "[email protected]" });
// Hard to assert without patching EmailService globally
});
ตอนนี้ WelcomeNotifier รับอ็อบเจ็กต์ใดก็ได้ที่มีพฤติกรรมที่ต้องการ
class WelcomeNotifier {
constructor(emailService) {
this.emailService = emailService;
}
notify(user) {
this.emailService.send(user.email, "Welcome!");
}
}
เทสต์จะสั้น, เร็ว, และชัดเจน
test("sends welcome email", () => {
const fakeEmail = { send: vi.fn() };
const notifier = new WelcomeNotifier(fakeEmail);
notifier.notify({ email: "[email protected]" });
expect(fakeEmail.send).toHaveBeenCalledWith("[email protected]", "Welcome!");
});
อยากเพิ่ม SMS ไหม? คุณไม่ต้องแก้ WelcomeNotifier แค่ส่ง implementation ใหม่:
const smsService = { send: (to, msg) => {/* SMS provider */} };
const notifier = new WelcomeNotifier(smsService);
นี่คือผลประโยชน์เชิงปฏิบัติ: เทสต์ไม่ต้องสู้กับรายละเอียดการสร้าง และพฤติกรรมใหม่ถูกเพิ่มโดยการสลับ dependency แทนการเขียนโค้ดเดิมใหม่
Dependency Injection อาจง่ายเหมือน "ส่งสิ่งที่ต้องการเข้าไป" นั่นคือ manual DI DI container คือเครื่องมือที่ช่วยอัตโนมัติการเดินสาย ทั้งสองมีข้อดี—เคล็ดลับคือเลือกระดับการอัตโนมัติที่เข้ากับแอปของคุณ
ด้วย manual DI คุณสร้างอ็อบเจ็กต์และส่ง dependencies ผ่าน constructor/พารามิเตอร์ มันตรงไปตรงมา:\n\n- คุณเห็นว่าถูกสร้างอะไรที่ไหน\n- ไม่มีเวทมนตร์ซ่อนเมื่อเกิดปัญหา\n- เหมาะกับแอปเล็ก, สคริปท์, บริการที่มีคอมโพเนนต์ไม่มาก, และตอนเริ่มรีแฟกเตอร์
การเดินสายด้วยมือยังบังคับให้เกิดนิสัยการออกแบบที่ดี ถ้าอ็อบเจ็กต์ต้องการ dependencies เจ็ดตัว คุณจะรู้สึกเจ็บปวดทันที—ซึ่งมักเป็นสัญญาณให้แยกความรับผิดชอบ
เมื่อจำนวนคอมโพเนนต์เพิ่มขึ้น การเดินสายด้วยมืออาจกลายเป็นงานทำซ้ำ DI container ช่วยได้:\n\n- สร้าง object graph อัตโนมัติ\n- จัดการ lifetime (singleton vs per-request vs transient)\n- รวมการลงทะเบียนไว้ตรงกลาง (เช่น สลับเป็น test doubles ในสภาพแวดล้อมบางอย่าง)
คอนเทนเนอร์โดดเด่นในแอปที่มี boundary และ lifecycle ชัด—เว็บแอป บริการระยะยาว หรือระบบที่หลายฟีเจอร์พึ่ง infrastructure ร่วมกัน
คอนเทนเนอร์อาจทำให้ดีไซน์ที่ผูกแน่นดูเรียบร้อยเพราะการเดินสายหายไป แต่ปัญหาพื้นฐานยังอยู่:\n\n- dependencies ต่อคลาสมากเกินไป\n- ความรับผิดชอบไม่ชัดเจน (ใครสร้าง/dispose resource?)\n- รูปแบบ "service locator" ที่ทำให้ dependency มองไม่เห็นและเทสต์ยาก
ถ้าการเพิ่มคอนเทนเนอร์ทำให้โค้ดอ่านยากลง หรือถ้านักพัฒนาหยุดรู้ว่าต้องพึ่งอะไร คุณอาจไปไกลเกินไปแล้ว
เริ่มจาก manual DI เพื่อให้ทุกอย่างชัดเจนตอนออกแบบโมดูล แล้วเพิ่ม container เมื่อการเดินสายเริ่มซ้ำหรือการจัดการ lifecycle ยาก
กฎปฏิบัติ: ใช้ manual DI ภายใน core/business code ของคุณ และ (ถ้าต้องการ) ใช้ container ที่ขอบของแอป (composition root) เพื่อประกอบทุกอย่าง วิธีนี้คงความชัดเจนของการออกแบบในขณะที่ลด boilerplate เมื่อโปรเจ็กต์โตขึ้น
Dependency injection ช่วยให้โค้ดทดสอบและเปลี่ยนได้ง่าย—แต่ต้องใช้ด้วยวินัย นี่คือวิธีที่ DI มักจะผิดพลาด และนิสัยที่จะช่วยรักษามันให้เป็นประโยชน์
ถ้าคลาสต้องการ dependency จำนวนมาก มักแปลว่ามันทำงานมากเกินไป นี่ไม่ใช่ความล้มเหลวของ DI แต่นี่คือ DI บอกว่ามีกลิ่นการออกแบบ
กฎง่าย ๆ: ถ้าคุณอธิบายงานของคลาสไม่ครบในประโยคเดียว หรือ constructor โตขึ้นเรื่อย ๆ ให้พิจารณาแยกคลาส ดึง collaborator เล็ก ๆ ออกมา หรือรวมการดำเนินที่เกี่ยวข้องไว้หลัง interface เดียว (ระวังอย่าสร้าง "god service")
Service Locator มักดูเหมือนเรียก container.get(Foo) ภายในโค้ดธุรกิจ มันดูสะดวก แต่ทำให้ dependency มองไม่เห็น: อ่าน constructor แล้วไม่รู้ว่าต้องการอะไร
การทดสอบยากขึ้นเพราะต้องตั้งค่าสถานะระดับโลก (locator) แทนที่จะส่ง fakes แบบชัดเจน ชอบส่ง dependencies แบบชัดเจน (constructor injection เป็นทางเลือกที่ตรงไปตรงมาที่สุด)
คอนเทนเนอร์ DI อาจล้มเหลวตอนรันเมื่อ:\n\n- ไม่มีการลงทะเบียน dependency\n- การลงทะเบียนเลือก implementation ผิดสำหรับสภาพแวดล้อมนั้น\n- สองบริการพึ่งพากัน (ตรงหรืออ้อม) สร้างวงจร
ปัญหาเหล่านี้น่าหงุดหงิดเพราะจะปรากฏเมื่อตอน wiring ถูกเรียก
เก็บ constructor ให้เล็กและชัดเจน หากรายการ dependencies โตขึ้น ให้ดูเป็นสัญญาณรีแฟกเตอร์\n\nเพิ่ม integration test สำหรับการเดินสาย แม้เทสต์น้ำหนักเบาที่สร้าง container ของแอป (หรือ manual wiring) ก็ช่วยจับการลงทะเบียนหายและวงจรก่อนขึ้น production\n\nสุดท้าย เก็บการสร้างอ็อบเจ็กต์ไว้ที่เดียว (โดยทั่วไปคือ startup/composition root) และอย่าเรียก container ใน business logic การแยกนี้รักษาประโยชน์หลักของ DI: ความชัดเจนว่าขึ้นกับอะไร
DI แนะนำง่ายสุดเมื่อคุณทำทีละเล็กทีละน้อย เริ่มจากที่เทสต์ช้า/เปราะบาง และที่การเปลี่ยนกระจายไปหลายที่
มองหา dependencies ที่ทำให้โค้ดทดสอบหรือเข้าใจยาก:\n\n- I/O: การเข้าถึงไฟล์, การเรียก DB, คำขอเครือข่าย\n- เวลา: "now", timezone, delays, schedulers\n- ความสุ่ม: UUIDs, ตัวเลขสุ่ม, การสลับรายการ\n- API/SDK ภายนอก: payment, อีเมล, analytics, feature flags
ถ้าฟังก์ชันไม่รันโดยไม่เข้ากระบวนการนอก มันมักเป็น candidate ดีสำหรับ DI
new ขึ้นหรือติดต่อโดยตรง\n2. ดึง interface/สัญญา: กำหนดพฤติกรรมที่คุณต้องการจริง ๆ (มัก 1–3 เมธอด)\n3. สร้าง implementation จริง: ห่อ dependency เดิมไว้หลัง interface นั้น\n4. ฉีดมัน: ส่ง interface ผ่าน constructor หรือพารามิเตอร์ ฟอร์มที่ง่ายที่สุดที่เหมาะสม\n5. อัปเดตการเดินสาย production: สร้าง implementation จริงที่จุดสตาร์ทของแอปและส่งเข้าไป\n6. อัปเดตเทสต์: แทนที่ implementation จริงด้วย fake/stub/mock ที่คืนค่าคาดหวังวิธีนี้ทำให้แต่ละเปลี่ยนแปลงตรวจทานได้ และคุณสามารถหยุดหลังขั้นตอนไหนก็ได้โดยไม่ทำให้ระบบพัง
DI อาจทำให้โค้ดกลายเป็น "ทุกอย่างขึ้นกับทุกอย่าง" ถ้าคุณฉีดมากเกินไป
กฎดี ๆ: ฉีดความสามารถ ไม่ใช่รายละเอียด ตัวอย่างเช่น ฉีด Clock แทน "SystemTime + TimeZoneResolver + NtpClient" หากคลาสต้องการบริการห้าตัวไม่เกี่ยวข้องกัน อาจแปลว่ามันทำงานมากเกินไป—พิจารณาแยกความรับผิดชอบ
นอกจากนี้ หลีกเลี่ยงการส่ง dependency ผ่านหลายชั้น "เผื่อไว้" ฉีดเฉพาะที่ใช้ และรวมการเดินสายไว้ที่เดียว
ถ้าคุณใช้ code generator หรือ workflow สร้างฟีเจอร์เร็ว ๆ DI ยิ่งมีคุณค่าเพราะช่วยรักษาโครงสร้างเมื่อโปรเจ็กต์เติบโต ตัวอย่างเช่น เมื่อทีมใช้ Koder.ai สร้าง React frontends, Go services และ backend ที่ใช้ PostgreSQL จากสเป็กที่ขับเคลื่อนด้วยแชท การรักษา composition root และ interface ที่ DI-friendly ช่วยให้โค้ดที่สร้างขึ้นง่ายต่อการทดสอบ รีแฟกเตอร์ และสลับ integration (อีเมล, การชำระเงิน, การเก็บ) โดยไม่ต้องเขียน logic ธุรกิจใหม่
กฎเดิมยังคงอยู่: เก็บการสร้างอ็อบเจ็กต์และ wiring เฉพาะที่ขอบของระบบ และให้โค้ดธุรกิจเน้นพฤติกรรม
คุณควรชี้ให้เห็นการปรับปรุงที่จับต้องได้:\n\n- เทสต์หน่วยเร็วขึ้น (รอดูการลด I/O/เครือข่าย/เวลา)\n- เทสต์แยกกันมากขึ้น (น้อยการตั้งค่าระดับโลกและสถานะร่วม)\n- ขอบเขตชัดขึ้น (สัญญาระหว่างโมดูลชัดเจน)\n- การเปลี่ยนง่ายขึ้น (สลับ client API หรือกลยุทธ์เก็บข้อมูลด้วยการแก้โค้ดน้อย)
ถ้าคุณอยากก้าวต่อไป ให้เอกสาร composition root ของคุณและเก็บมันเรียบง่าย: ไฟล์เดียวสำหรับเดินสาย dependencies ขณะที่ส่วนอื่นของโค้ดยังคงโฟกัสที่พฤติกรรม
Dependency Injection (DI) หมายความว่าโค้ดของคุณ รับ สิ่งที่ต้องการ (ฐานข้อมูล, logger, นาฬิกา, ตัวประมวลผลการชำระเงิน) จากภายนอก แทนที่จะสร้างมันขึ้นมาเองภายในโค้ด
ในเชิงปฏิบัติ มักทำโดยการส่ง dependency ผ่าน constructor หรือพารามิเตอร์ฟังก์ชัน เพื่อให้มันชัดเจนและสามารถสลับได้ง่าย
Inversion of Control (IoC) คือแนวคิดกว้าง ๆ ว่า คลาสควรโฟกัสที่ สิ่งที่ต้องทำ ไม่ใช่ วิธีการหา collaborators
DI เป็นเทคนิคที่ใช้บ่อยเพื่อทำให้เกิด IoC โดยย้ายการสร้าง dependency ออกไปข้างนอกและส่ง dependencies เข้ามาแทน
ถ้าสร้าง dependency ด้วย new ภายใน logic ทางธุรกิจ มันจะยากที่จะเปลี่ยนออกไปใช้ implementation อื่น
ผลที่ตามมาคือ:
DI ทำให้เทสต์เร็วและกำหนดผลได้เพราะคุณสามารถฉีด test double แทนที่จะเรียกใช้ระบบภายนอกจริง
การสลับที่พบบ่อย:
คอนเทนเนอร์ DI เป็นทางเลือก ไม่จำเป็นต้องมีเสมอ เริ่มจาก manual DI (ส่ง dependencies โดยตรง) เมื่อ:
พิจารณาใช้ container เมื่อการเดินสายซ้ำซ้อนหรือคุณต้องจัดการ lifecycle (singleton/per-request)
ใช้ constructor injection เมื่อ dependency จำเป็นต่อการทำงานของอ็อบเจ็กต์และใช้ในหลายเมธอด
ใช้ method/parameter injection เมื่อจำเป็นแค่ครั้งเดียวในการเรียกหนึ่งครั้ง (เช่น ค่าที่อยู่ในขอบเขตการร้องขอ หรือ strategy ชั่วคราว)
หลีกเลี่ยง setter/property injection ยกเว้นต้องการ late wiring จริง ๆ; เพิ่มการตรวจสอบเพื่อ fail fast หากขาดหาย
Composition root คือจุดที่คุณ ประกอบ แอป: สร้าง implementation แล้วส่งให้ service ที่ต้องการ
เก็บไว้ใกล้จุดเริ่มต้นของแอป (entry point) เพื่อให้ส่วนอื่น ๆ ของโค้ดยังคงมุ่งที่พฤติกรรม ไม่ใช่การเดินสาย
Test seam คือจุดที่จงใจเปิดให้สลับพฤติกรรมได้
ตำแหน่งที่ดีสำหรับ seam คือส่วนที่ยากจะควบคุมในการทดสอบ:
Clock.now())DI สร้าง seam โดยอนุญาตให้ฉีด implementation แทนในเทสต์
ข้อผิดพลาดที่พบบ่อยได้แก่:
container.get() ในธุรกิจโค้ด ทำให้ dependency ถูกซ่อน; ควรส่ง dependencies แบบชัดเจนใช้การรีแฟกเตอร์แบบเล็กและทำซ้ำได้:
ทำซ้ำสำหรับ seam ถัดไป; หยุดเมื่อไหร่ก็ได้โดยไม่ต้องรีเขียนครั้งใหญ่