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

Dependency Inversion Principle

module ระดับ​สูง​และ​ระดับ​ต่ำ ควร​พึ่งพา abstraction ทั้ง​คู่

Dependency Inversion Principle (DIP) เป็น​ตัว “D” ตัว​สุดท้าย​ใน SOLID ซึ่ง Robert C. Martin เสนอ​ไว้​ใน​บทความ The Dependency Inversion Principle (C++ Report, 1996) และ​ขยาย​ความ​ต่อ​ใน​หนังสือ Agile Software Development, Principles, Patterns, and Practices หลักการ​นี้​ประกอบ​ด้วย​กฎ​สอง​ข้อ:

  1. module ระดับ​สูง (high-level module) ไม่​ควร​พึ่งพา module ระดับ​ต่ำ (low-level module) ทั้ง​คู่ควร​พึ่งพา abstraction
  2. Abstraction ไม่​ควร​พึ่งพา​รายละเอียด (detail) — รายละเอียด​ต่างหาก​ที่​ควร​พึ่งพา abstraction

คำ​ว่า “inversion” (การ​พลิก​กลับ) หมาย​ถึง​การ​พลิก​ทิศทางการ​พึ่งพา​ที่​มัก​เกิด​ขึ้น​ตาม​ธรรมชาติ​ใน code ปกติ​เมื่อ class A เรียก​ใช้ method ของ class B และ B เรียก​ใช้ method ของ C การ​พึ่งพา ณ compile-time ก็​จะ​ไหล​ไป​ใน​ทิศทาง​เดียว​กับ​การ​เรียก​ใช้งาน​ตอน runtime คือ A → B → C ผล​คือ​หาก C เปลี่ยน B และ A ก็​อาจ​ต้อง​เปลี่ยน​ตาม (transitive coupling)

DIP แก้​ปัญหา​นี้​ด้วย​การ​แทรก abstraction (โดย​ทั่วไป​คือ interface หรือ abstract class) ไว้​ระหว่าง module ระดับ​สูง​กับ​ระดับ​ต่ำ แล้ว​ให้ module ระดับ​สูง​เป็น​เจ้าของ abstraction นั้น ไม่ใช่ module ระดับ​ต่ำ เมื่อ​ทำ​เช่น​นี้ ทิศทางการ​พึ่งพา ณ compile-time จะ “พลิก” กลับ: module ระดับ​ต่ำ (รายละเอียด/detail) จะ​ต้อง implement interface ที่ module ระดับ​สูง​กำหนด​ไว้ ใน​ขณะ​ที่​ลำดับ​การ​เรียก​ใช้งาน​จริง​ตอน runtime ยัง​คง​เหมือน​เดิม

ข้อ​ควร​ระวัง​ที่​สำคัญ (ตาม​ที่ Martin Fowler เน้น​ย้ำ​ใน DIP in the Wild): DIP ไม่​เท่ากับ Dependency Injection หรือ Inversion of Control แม้​ทั้ง​สาม​มัก​ถูก​ใช้​ร่วม​กัน — Dependency Injection คือ เทคนิค ว่า​จะ​ส่ง dependency เข้าไป​ใน object อย่างไร ส่วน DIP คือ กฎ​การ​ออกแบบ ว่า​ฝั่ง​ไหน​ควร​เป็น​เจ้าของ abstraction และ​รายละเอียด​ควร​พึ่งพา​อะไร นอกจาก​นี้ abstraction ที่​ดี​ตาม DIP ไม่ใช่​แค่ “ใส่ interface ทับ​ของ​เดิม” — interface ต้อง​สะท้อน​ภาษา​ของ domain ฝั่ง​ผู้​เรียก​ใช้ ไม่ใช่​รายละเอียด​ทาง​เทคนิค​ของ​ฝั่ง implementation (เช่น interface ที่​มี method อย่าง GetAutoCommit หรือ CreateStatement ยัง​คง​เป็น abstraction ที่​ผูก​กับ​รายละเอียด​ของ​ฐาน​ข้อมูล​อยู่ดี แม้​จะ​เป็น interface ก็ตาม)

  • ลด coupling ที่​ไหล​เป็น​ทอด ๆ (transitive coupling) — เมื่อ UI สร้าง instance ของ Business Logic Layer (BLL) ตรง ๆ และ BLL ก็​สร้าง instance ของ Data Access Layer (DAL) ตรง ๆ อีก​ที ทั้ง​ระบบ​จะ tightly coupled กับ​ฐาน​ข้อมูล​ไป​จนถึง​ชั้น UI จำ​ไว้​ว่า “New is Glue” — การ​เรียก new โดยตรง (หรือ​เรียก static method) คือ​กาว​ที่​ผูก​ทุก​ชั้น​เข้า​ด้วย​กัน
  • ทำให้​ทดสอบ​ได้ (testability) — เมื่อ module ระดับ​สูง​พึ่งพา interface แทนที่​จะ​พึ่งพา class จริง เรา​สามารถ​แทนที่ implementation ด้วย test double หรือ mock ได้​โดย​ไม่​ต้อง​แตะ​ฐาน​ข้อมูล​จริง
  • เปิด​ทาง​ให้ plugin (plugin architecture) — เพราะ module ระดับ​ต่ำ implement interface ที่ module ระดับ​สูง​กำหนด เรา​จึง​สลับ implementation ได้​อย่าง​อิสระ (เช่น เปลี่ยน​จาก SQL Server ไป in-memory store) โดย​ไม่​กระทบ​ตรรกะ​ทาง​ธุรกิจ​เลย
  • เป็น​แกน​ของ​สถาปัตยกรรม​แบบ domain-centric — DIP คือ​กลไก​เบื้องหลัง​ที่​ทำให้ Clean Architecture, Hexagonal Architecture, Onion Architecture และ Domain-Driven Design ทำงาน​ได้ ชั้น domain/application เป็น​ผู้​กำหนด interface (เช่น port หรือ Repository) ส่วน​ชั้น infrastructure เป็น​ผู้ implement — ทำให้ dependency ทุก​เส้น​ชี้ เข้าหา domain ไม่ใช่​ออก​จาก domain
  • สัมพันธ์​กับ​หลักการ​อื่น​ใน SOLID — Robert Martin ระบุ​ว่า DIP เป็นการ​รวม​กัน​ใน​ระดับ first-class ของ Open/Closed Principle (ขยาย​พฤติกรรม​ผ่าน implementation ใหม่​โดย​ไม่​แก้ code เดิม) กับ Liskov Substitution Principle (implementation ใด ๆ ของ abstraction ต้อง​แทนที่​กัน​ได้​อย่าง​ปลอดภัย)

ก่อน — tightly coupled: OrderProcessor (module ระดับ​สูง) สร้าง instance ของ SqlOrderRepository (module ระดับ​ต่ำ) ขึ้น​มา​ตรง ๆ ด้วย new การ​พึ่งพา​ไหล​จาก​ตรรกะ​ทาง​ธุรกิจ​ลง​ไป​สู่​รายละเอียด​ของ​ฐาน​ข้อมูล​โดยตรง ทดสอบ OrderProcessor แยก​จาก​ฐาน​ข้อมูล​จริง​ไม่​ได้ และ​เปลี่ยน storage technology ไม่​ได้​โดย​ไม่​แก้ OrderProcessor

// รายละเอียดระดับต่ำ ผูกกับ SQL โดยตรง
public class SqlOrderRepository
{
public void Save(Order order)
{
// เปิด connection แล้ว INSERT ลงตาราง Orders
}
}
// module ระดับสูง แต่กลับพึ่งพารายละเอียดระดับต่ำโดยตรง
public class OrderProcessor
{
private readonly SqlOrderRepository _repository = new SqlOrderRepository();
public void Process(Order order)
{
// ตรรกะทางธุรกิจ ...
_repository.Save(order);
}
}

หลัง — พึ่งพา abstraction ที่ module ระดับ​สูง​เป็น​เจ้าของ: OrderProcessor กำหนด interface IOrderRepository เอง (abstraction นี้​อยู่​ใน​ชั้น​เดียว​กับ​ตรรกะ​ทาง​ธุรกิจ ไม่ใช่​ใน​ชั้น data access) แล้ว​รับ implementation ผ่าน constructor ตาม Explicit Dependencies Principle ส่วน SqlOrderRepository ซึ่ง​เป็น​รายละเอียด ก็​แค่ implement interface นั้น ทิศทาง compile-time ของ SqlOrderRepository จึง​พลิก​กลับ​มา​ชี้​เข้าหา​ชั้น​ตรรกะ​ทาง​ธุรกิจ

// abstraction ถูกกำหนดและเป็นเจ้าของโดยชั้นตรรกะทางธุรกิจ
public interface IOrderRepository
{
void Save(Order order);
}
// module ระดับสูง พึ่งพาเฉพาะ abstraction ไม่มี new และไม่มี static call
public class OrderProcessor
{
private readonly IOrderRepository _repository;
public OrderProcessor(IOrderRepository repository)
{
_repository = repository;
}
public void Process(Order order)
{
// ตรรกะทางธุรกิจ ...
_repository.Save(order);
}
}
// รายละเอียดระดับต่ำ implement interface ที่ระดับสูงกำหนดไว้
public class SqlOrderRepository : IOrderRepository
{
public void Save(Order order)
{
// เปิด connection แล้ว INSERT ลงตาราง Orders
}
}

ใน​สถานการณ์​จริง SqlOrderRepository จะ​ถูก​ส่ง​เข้าไป​ให้ OrderProcessor ผ่าน​กลไก Dependency Injection เช่น DI container ตอน composition root — แต่​นั่น​เป็น​เรื่อง​ของ วิธี​จัดหา implementation ไม่ใช่​ตัว DIP เอง

แผนภาพ​ด้าน​ล่าง​แสดง​ทิศทางการ​พึ่งพา​หลัง apply DIP: OrderProcessor (ระดับ​สูง) และ SqlOrderRepository (ระดับ​ต่ำ) ต่าง​พึ่งพา IOrderRepository ซึ่ง​เป็น abstraction ที่​ฝั่ง​ระดับ​สูง​เป็น​เจ้าของ ไม่ใช่​พึ่งพา​กัน​โดยตรง​อีก​ต่อ​ไป

flowchart LR
    UI[UI Layer] --> Processor[OrderProcessor High Level]
    Processor --> Abstraction[IOrderRepository Abstraction]
    SqlRepo[SqlOrderRepository Low Level] --> Abstraction
    SqlRepo --> DB[Database]
  • class ตรรกะ​ทาง​ธุรกิจ​มี keyword new เพื่อ​สร้าง collaborator ของ​ตัวเอง (โดย​เฉพาะ collaborator ที่​คุย​กับ​ฐาน​ข้อมูล file เครือข่าย หรือ​ระบบ​ภายนอก​อื่น ๆ) หรือ​เรียก static method ที่​ทำ​สิ่ง​เดียวกัน — ทั้ง​สอง​แบบ​คือ New is Glue
  • การ​พึ่งพา​ไหล​เป็น​ทอด ๆ ผ่าน​หลาย​ชั้น (UI → BLL → DAL → DB) จน​เปลี่ยน​ชั้น​ล่าง​สุด​แล้วกระทบ​ชั้น​บน​สุด​โดย​ไม่​คาด​คิด
  • interface ถูก​กำหนด​และ​อยู่​ใน “ชั้น” เดียว​กับ implementation ของ​มัน (เช่น​อยู่​ใน assembly หรือ namespace ของ data-access) แทนที่​จะ​อยู่​ใน namespace ของ​ชั้น​ตรรกะ​ทาง​ธุรกิจ​ที่​เป็น​ผู้​ใช้งาน — เจ้าของ abstraction ผิด​ฝั่ง
  • มี interface แต่​กลับ​สะท้อน​รายละเอียด​ทาง​เทคนิค​ของ implementation (เช่น method ชื่อ ExecuteQuery, GetAutoCommit) แทนที่​จะ​สะท้อน​ภาษา​ของ domain — เป็น “abstraction ปลอม” ที่​ยัง​ผูก​กับ​รายละเอียด​อยู่
  • เขียน unit test ของ​ตรรกะ​ทาง​ธุรกิจ​ไม่​ได้​เลย​ถ้า​ไม่มี​ฐาน​ข้อมูล file หรือ service ภายนอก​พร้อม​ใช้งาน​จริง
  • ใช้ DIP แบบ​สุด​โต่ง คือ​ใส่ interface ให้​ทุก class โดย​ไม่มี​เหตุผล ทำให้​เกิด boilerplate จำนวน​มาก​โดย​ไม่​ได้​ประโยชน์​ด้าน​ความ​ยืดหยุ่น​จริง (over-engineering)