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

Stable Dependencies

จง​พึ่งพา​ไป​ใน​ทิศทาง​ของ​ความ​เสถียร

Stable Dependencies Principle (SDP) กล่าว​ว่า “การ​พึ่งพา​ระหว่าง package ซอฟต์แวร์​ควร​อยู่​ใน​ทิศทาง​ของ​ความ​เสถียร (stability) ของ package นั่น​คือ package หนึ่ง​ควร​พึ่งพา​เฉพาะ package ที่​เสถียร​กว่า​เท่านั้น” หลักการ​นี้​เป็น​หนึ่ง​ใน​สาม​หลักการ​จัด​วาง package (package coupling principles) ที่ Robert C. Martin เสนอ​ไว้​คู่​กับ Acyclic Dependencies Principle (ห้าม​มี​วงจร​การ​พึ่งพา) และ Stable Abstractions Principle (package ที่​เสถียร​ควร​เป็น​นามธรรม​ด้วย เพื่อ​ให้​ยัง​ขยาย​ได้)

เมื่อใด​ที่ package หนึ่ง​เปลี่ยนแปลง package ทั้งหมด​ที่​พึ่งพา​มัน​ต้อง​ถูก​ตรวจสอบ​เพื่อ​ยืนยัน​ว่า​ยัง​ทำงาน​ตาม​ที่​คาด​หลัง​การ​เปลี่ยนแปลง ดังนั้น ยิ่ง​มี package จำนวน​มาก​พึ่งพา package ที่​ไม่​เสถียร (unstable) การ​รบกวน​ก็​ยิ่ง​มาก​ขึ้น​ทุก​ครั้ง​ที่​มัน​เปลี่ยน การ​จัด​ทิศทางการ​พึ่งพา​ให้​ไหล​ไป​สู่​ความ​เสถียร​จึง​ช่วย​ลด​แรง​กระเพื่อม​ของ​การ​เปลี่ยนแปลง​ลง

Writing software that fully meets its specifications is like walking on water. For each, the former is easy if the latter is frozen and near impossible if fluid.

— Anonymous

สิ่ง​สำคัญ​ที่​มัก​เข้าใจ​ผิด​คือ “ความ​เสถียร” ใน​ที่​นี้ ไม่​ได้​หมาย​ถึง​ความถี่​ที่ code เปลี่ยนแปลง แต่​หมาย​ถึง ต้นทุน​หรือ​ความ​ยาก​ใน​การ​เปลี่ยนแปลง​มัน package จะ​เสถียร​ก็​ต่อ​เมื่อ​มี​สิ่ง​อื่น​จำนวน​มาก​พึ่งพา​มัน​อยู่ — ยิ่ง​มี​ผู้​พึ่งพา​มาก การ​เปลี่ยน​มัน​ก็​ยิ่ง​มี​ความ​เสี่ยง​และ​ต้นทุน​สูง​ขึ้น (แม้​ตัว​มัน​เอง​จะ​แทบ​ไม่​เคย​เปลี่ยน​ก็ตาม) ใน​ทาง​กลับ​กัน package ที่​แทบ​ไม่มี​ใคร​พึ่งพา​ก็ “เปลี่ยน​ได้​ง่าย” เพราะ​แรง​กระเพื่อม​ของ​การ​เปลี่ยนแปลง​มี​วง​จำกัด

Martin เสนอ metric เชิง​ปริมาณ​เรียก​ว่า Instability (I) เพื่อ​วัด​สิ่ง​นี้:

I = Ce / (Ca + Ce)

โดย

  • Ca (Afferent Coupling) = จำนวน class/package ภายนอก​ที่ พึ่งพา​เข้า​มา​หา package นี้ (incoming dependencies)
  • Ce (Efferent Coupling) = จำนวน class/package ภายนอก​ที่ package นี้ พึ่งพา​ออก​ไป (outgoing dependencies)

ค่า I อยู่​ระหว่าง 0 ถึง 1 — package ที่​มี I เข้า​ใกล้ 0 (Ca สูง, Ce ต่ำ) คือ package ที่​เสถียร​มาก มัก​เป็น package แกน​กลาง เช่น Domain layer หรือ library พื้นฐาน ส่วน package ที่​มี I เข้า​ใกล้ 1 (Ce สูง, Ca ต่ำ) คือ package ที่​ไม่​เสถียร เปลี่ยน​ได้​อย่าง​อิสระ​โดย​แทบ​ไม่​กระทบ​ใคร เช่น project Infrastructure หรือ UI ปลายทาง

Stable Dependencies Principle จึง​สรุป​สั้น ๆ ว่า: graph การ​พึ่งพา​ของ​ระบบ​ควร​ไหล​จาก package ที่​มี I สูง (ไม่​เสถียร) ไปหา package ที่​มี I ต่ำ (เสถียร) เสมอ — ไม่​ควร​มี package ที่​เสถียร​พึ่งพา package ที่​ไม่​เสถียร เพราะ​นั่น​หมายความ​ว่า​ส่วน​ที่ “แข็ง” ของ​ระบบ​ถูก​ดึง​ให้​สั่น​ไหว​ตาม​ส่วน​ที่ “อ่อน” กว่า

หัวใจ​ของ​หลักการ​นี้​คือ​การ​ควบคุม blast radius ของ​การ​เปลี่ยนแปลง เมื่อ package หนึ่ง​เปลี่ยน ทุก package ที่​พึ่งพา​มัน​ต้อง build ใหม่ ทดสอบ​ใหม่ และ​ตรวจสอบ​พฤติกรรม​ใหม่ หาก package ที่​มี​ผู้​พึ่งพา​จำนวน​มาก (Domain, Core, Shared Kernel) ดัน​ไป​พึ่งพา package ที่​เปลี่ยน​บ่อย (SDK ของ​ผู้​ให้​บริการ​ภายนอก, UI framework version ล่าสุด, การ​ตั้ง​ค่า​เฉพาะ​สภาพ​แวดล้อม) การ​เปลี่ยนแปลง​เล็กน้อย​เพียง​จุด​เดียว​จะ​ไหล​ย้อน​กลับ​ขึ้น​ไป​สั่น​สะเทือน​ทั้ง​ระบบ

เทคนิค​เชิง​ปฏิบัติ​ที่​ทำให้ SDP เป็น​จริง​ใน​ระดับ class คือ Dependency Inversion Principle (DIP) — แทนที่ Domain (ซึ่ง​ควร​เสถียร​ที่สุด) จะ​อ้างอิง class รูปธรรม​ของ Infrastructure (ซึ่ง​ไม่​เสถียร) โดยตรง เรา​ให้ Domain เป็น​เจ้าของ interface หรือ abstraction แล้ว​ให้ Infrastructure เป็น​ฝ่าย​พึ่งพา (implement) abstraction นั้น​แทน ทิศทางการ​พึ่งพา​ระดับ compile จึง​ถูก “กลับ​ด้าน” ให้​สอดคล้อง​กับ​ทิศทาง​ความ​เสถียร ใน​ขณะ​ที่​ทิศทางการ​เรียก​ใช้งาน​จริง​ตอน runtime ยัง​เหมือน​เดิม เอกสาร Architectural Principles ของ Microsoft อธิบาย​ภาพ​นี้​ว่า​เป็นการ​เปลี่ยน​จาก “direct dependency graph” ไป​เป็น “inverted dependency graph”

ผลลัพธ์​ของ​การ​ละเมิด SDP มัก​ปรากฏ​เป็น​อาการ​เหล่า​นี้: การ​ทดสอบ unit test ของ Domain ต้อง mock หรือ​พึ่งพา infrastructure จริง, การ​อัปเกรด library ภายนอก​หนึ่ง​ตัว​ทำให้​ต้อง​แก้ code หลาย10 file ที่​ไม่​เกี่ยวข้อง​กัน​โดยตรง, และ​ทีม​กลัว​ที่​จะ​แตะ package แกน​กลาง​เพราะ​ไม่รู้​ว่า​อะไร​จะ​พัง​บ้าง

แผนภาพ​ต่อ​ไป​นี้​แสดง​ทิศทางการ​พึ่งพา​ที่​ถูกต้อง — ทั้ง UI และ Infrastructure (ไม่​เสถียร, เปลี่ยน​บ่อย) ต่าง​พึ่งพา​เข้าหา Domain (เสถียร, มี​ผู้​พึ่งพา​มาก) ไม่มี​ลูกศร​ใด​วิ่ง​ออก​จาก Domain ไป​หา​ชั้น​อื่น:

graph LR
    UI[UI Layer] --> App[Application Layer]
    App --> Domain[Domain Core]
    Infra[Infrastructure Layer] --> Domain

ก่อน (ไม่​ดี) — Domain อ้างอิง class รูปธรรม​จาก Infrastructure โดยตรง ทำให้ package ที่​ควร​เสถียร​ที่สุด​กลับ​ผูก​ติด​กับ package ที่​เปลี่ยน​บ่อย​ที่สุด (เช่น เปลี่ยน​ผู้​ให้​บริการ​อีเมล เปลี่ยน library SMTP):

namespace Domain
{
public class OrderService
{
// อ้างอิง class รูปธรรมจาก Infrastructure โดยตรง — Domain (เสถียร) พึ่งพา Infrastructure (ไม่เสถียร)
private readonly SmtpEmailSender _emailSender;
public OrderService(SmtpEmailSender emailSender)
{
_emailSender = emailSender;
}
public void CompleteOrder(Order order)
{
// ... ตรรกะทางธุรกิจของการปิดคำสั่งซื้อ
_emailSender.Send(order.CustomerEmail, "คำสั่งซื้อสำเร็จแล้ว");
}
}
}
namespace Infrastructure
{
// เปลี่ยนบ่อยตามผู้ให้บริการอีเมล การตั้งค่า SMTP หรือ SDK version ใหม่
public class SmtpEmailSender
{
public void Send(string to, string message) { /* ... */ }
}
}

หลัง (ดี) — ย้าย abstraction ไป​ให้ Domain เป็น​เจ้าของ แล้ว​ให้ Infrastructure เป็น​ฝ่าย implement มัน ทิศทางการ​พึ่งพา​จึง​ไหล​จาก​ไม่​เสถียร​ไป​สู่​เสถียร​อย่าง​ถูกต้อง:

namespace Domain
{
// Domain เป็นเจ้าของ interface นี้ — เสถียร เพราะมีผู้พึ่งพา (implementers) มาหามัน ไม่ใช่ตรงกันข้าม
public interface INotificationSender
{
void Send(string to, string message);
}
public class OrderService
{
private readonly INotificationSender _notificationSender;
public OrderService(INotificationSender notificationSender)
{
_notificationSender = notificationSender;
}
public void CompleteOrder(Order order)
{
// ... ตรรกะทางธุรกิจของการปิดคำสั่งซื้อ
_notificationSender.Send(order.CustomerEmail, "คำสั่งซื้อสำเร็จแล้ว");
}
}
}
namespace Infrastructure
{
// Infrastructure (ไม่เสถียร) เป็นฝ่ายพึ่งพา Domain (เสถียร) ผ่าน interface — ทิศทางถูกต้องตาม SDP
public class SmtpEmailSender : INotificationSender
{
public void Send(string to, string message) { /* ... */ }
}
}
  • project Domain หรือ Core มี project reference ไป​ยัง Infrastructure, UI หรือ library SDK ของ​ผู้​ให้​บริการ​ภายนอก​โดยตรง
  • interface ที่ Infrastructure ควร​จะ implement กลับ​ถูก​นิยาม​ไว้​ใน Infrastructure เอง แทนที่​จะ​เป็น Domain ที่​เป็น​ผู้​ใช้งาน (เจ้าของ abstraction ผิด​ชั้น)
  • มี​วงจร​การ​พึ่งพา (circular dependency) ระหว่าง package — เมื่อ A พึ่งพา B และ B พึ่งพา A กลับ ไม่มี​ฝ่าย​ใด​สามารถ “เสถียร​กว่า” อีก​ฝ่าย​ได้​เลย
  • การ​อัปเกรด​หรือ​แก้ไข library/package เดียว ทำให้​ต้อง​แก้ file จำนวน​มาก​ที่​ไม่​เกี่ยวข้อง​กัน​โดยตรง​ใน​หลาย​ชั้น​ของ​ระบบ
  • unit test ของ Domain ต้อง reference หรือ mock รายละเอียด​ของ Infrastructure (เช่น connection string, HTTP client เฉพาะ​เจ้า) เพื่อ​ให้ compile ผ่าน
  • class utility หรือ configuration ตัว​เดียว​ที่​มี​ผู้​พึ่งพา​จำนวน​มาก (Ca สูง) แต่​ยัง​ถูก​แก้ไข​บ่อย​อยู่​เรื่อย ๆ — ทุก​ครั้ง​ที่​แก้​คือ​ความ​เสี่ยง​ต่อ​ทั้ง​ระบบ