Dependency Injection چیست؟ به طور خلاصه، تزریق وابستگی روشی است که در آن یک کلاس یا ماژول به جای این که خودش وابستگی‌هایش را بسازد، آن‌ها را از بیرون دریافت می‌کند. نتیجه این کار جداسازی بهتر کد، تست‌پذیری بالاتر و امکان تعویض آسان پیاده‌سازی‌ها (مثلا جایگزینی سرویس ایمیل واقعی با نمونه آزمایشی) است.

ایده اصلی و نحوه کار

وقتی یک کلاس خودش وابستگی‌ها را می‌سازد (مثل ساخت مستقیم new EmailService())، به پیاده‌سازی خاصی قفل می‌شود و تغییر یا تست آن سخت می‌شود. تزریق وابستگی این قفل را باز می‌کند: وابستگی‌ها را از بیرون می‌گیریم و داخل سازنده یا متد قرار می‌دهیم.

  • Constructor Injection: وابستگی‌ها از طریق سازنده دریافت می‌شوند. خوانا، امن و قابل تست است و انتخاب پیش‌فرض خوبی محسوب می‌شود.
  • Method Injection: وابستگی فقط به یک متد خاص داده می‌شود، وقتی همه متدها به آن نیاز ندارند.
  • Property Injection: وابستگی از طریق یک پراپرتی ست می‌شود؛ ساده است اما کنترل کمتری بر چرخه حیات می‌دهد.

می‌توانید تزریق را دستی انجام دهید (ترکیب اشیا در نقطه شروع برنامه)، یا از یک «کانتینر DI» برای ثبت و ساخت خودکار گراف وابستگی‌ها کمک بگیرید. کانتینر امکاناتی مثل چرخه حیات (Singleton/Transient/Scoped) و مدیریت وابستگی‌های تو در تو را فراهم می‌کند، اما اجباری نیست.

مثال سریع: قبل و بعد از DI

قبل از DI: کد به پیاده‌سازی خاص قفل شده

class EmailService {
  send(to, message) {
    console.log(`Email to ${to}: ${message}`);
  }
}

class UserService {
  constructor() {
    // وابستگی اینجا ساخته می‌شود
    this.emailService = new EmailService();
  }

  register(user) {
    // فرض کنید کاربر را ذخیره می‌کنیم...
    this.emailService.send(user.email, 'Welcome!');
  }
}

const userService = new UserService();
userService.register({ email: 'a@b.com' });

مشکل: UserService به EmailService قفل شده است. اگر بخواهید در تست، ارسال ایمیل را فیک کنید یا سرویس ایمیل دیگری استفاده کنید، باید کد داخلی را تغییر دهید.

بعد از DI: کد جدا و قابل تعویض

class EmailService {
  send(to, message) {
    console.log(`Email to ${to}: ${message}`);
  }
}

class UserService {
  // وابستگی از بیرون تزریق می‌شود
  constructor(emailService) {
    this.emailService = emailService;
  }

  register(user) {
    this.emailService.send(user.email, 'Welcome!');
  }
}

// ترکیب در لایه شروع برنامه (Composition Root)
const emailService = new EmailService();
const userService = new UserService(emailService);
userService.register({ email: 'a@b.com' });

حالا UserService دیگر مسئول ساخت EmailService نیست و فقط «قرارداد» آن را می‌شناسد. می‌توانید هر پیاده‌سازی سازگار دیگری را تزریق کنید.

تست کردن با DI: سریع و بدون وابستگی خارجی

DI اجازه می‌دهد وابستگی‌های خارجی (ایمیل، پایگاه داده، شبکه) را با نسخه آزمایشی جایگزین کنید تا تست‌ها سریع و قابل اعتماد باشند.

// یک ایمیل سرویس ساختگی برای تست
const fakeEmailService = {
  sent: [],
  send(to, message) {
    this.sent.push({ to, message });
  }
};

const userService = new UserService(fakeEmailService);
userService.register({ email: 'test@site.io' });

// بررسی نتیجه
console.log(fakeEmailService.sent.length === 1); // true
console.log(fakeEmailService.sent[0].to === 'test@site.io'); // true

بدون اتصال به SMTP واقعی، منطق ثبت‌نام و ارسال اعلان را با اطمینان تست کردید.

DI Container چیست و چه زمانی لازم است؟

کانتینر DI یک آبجکت مرکزی است که می‌داند هر سرویس چگونه ساخته می‌شود و به چه چیزهایی وابسته است. وقتی تعداد سرویس‌ها زیاد شد، کانتینر ساخت اشیا، حل وابستگی‌های تو در تو و مدیریت چرخه حیات را خودکار می‌کند.

  • Singleton: یک نمونه در کل برنامه.
  • Transient: هر بار نمونه جدید.
  • Scoped: یک نمونه در هر اسکوپ (مثلا هر درخواست HTTP).

نمونه‌ای بسیار ساده از یک کانتینر برای درک ایده:

class Container {
  constructor() {
    this.registry = new Map(); // name -> { deps, factory, lifetime }
    this.cache = new Map();    // name -> instance (برای singleton)
  }

  register(name, deps, factory, lifetime = 'transient') {
    this.registry.set(name, { deps, factory, lifetime });
  }

  resolve(name) {
    const entry = this.registry.get(name);
    if (!entry) throw new Error(`Service not registered: ${name}`);

    if (entry.lifetime === 'singleton' && this.cache.has(name)) {
      return this.cache.get(name);
    }

    const resolvedDeps = entry.deps.map(d => this.resolve(d));
    const instance = entry.factory(...resolvedDeps);

    if (entry.lifetime === 'singleton') {
      this.cache.set(name, instance);
    }
    return instance;
  }
}

// استفاده
class EmailService {
  send(to, message) { console.log(`Email to ${to}: ${message}`); }
}
class UserService {
  constructor(emailService) { this.emailService = emailService; }
  register(user) { this.emailService.send(user.email, 'Welcome!'); }
}

const c = new Container();
c.register('EmailService', [], () => new EmailService(), 'singleton');
c.register('UserService', ['EmailService'], (email) => new UserService(email));

const userService = c.resolve('UserService');
userService.register({ email: 'a@b.com' });

برای پروژه‌های کوچک، تزریق دستی کاملا کافی است. وقتی گراف وابستگی‌ها پیچیده شد یا به مدیریت چرخه حیات نیاز داشتید، سراغ کانتینر بروید.

چه زمانی DI مناسب است و چه زمانی نه؟

  • مناسب: برنامه‌های متوسط تا بزرگ، نیاز جدی به تست واحد، چند پیاده‌سازی برای یک قرارداد (مثلا ایمیل محلی و ابری)، معماری پلاگینی، ماژول‌های قابل استفاده مجدد.
  • کم‌فایده: اسکریپت‌های یک‌بار مصرف، پروژه‌های بسیار ساده با تعداد کم کلاس/ماژول که تغییرات اندکی دارند.

اشتباهات رایج و راه حل کوتاه

  • Service Locator به جای DI: گرفتن وابستگی از کانتینر داخل کلاس (مثلا container.get('X')) کد را پنهان و تست را سخت می‌کند. راه‌حل: وابستگی‌ها را از سازنده دریافت کنید.
  • سازنده‌های حجیم: تزریق ۸-۱۰ وابستگی یعنی کلاس بیش از حد کار انجام می‌دهد. راه‌حل: تقسیم به ماژول‌های کوچک‌تر.
  • نشت کانتینر به دامنه: اجازه ندهید کانتینر به لایه دامنه برسد. راه‌حل: فقط در لایه شروع برنامه (Composition Root) از کانتینر استفاده کنید.
  • چرخه حیات اشتباه: اشتراک ناخواسته وضعیت بین درخواست‌ها با Singleton. راه‌حل: سرویس‌های stateful را Transient/Scoped نگه دارید.
  • وابستگی‌های چرخشی: دو ماژول به هم وابسته می‌شوند. راه‌حل: استخراج یک قرارداد مشترک یا معرفی لایه میانی.

محدودیت‌ها و هزینه‌ها

  • پیچیدگی اولیه: در پروژه‌های خیلی کوچک، DI ممکن است غیرضروری به نظر برسد.
  • ابهام در رهگیری جریان: با کانتینرهای پیچیده، پیدا کردن سازنده واقعی شی سخت‌تر می‌شود. مستندسازی و نام‌گذاری شفاف کمک می‌کند.
  • خطاهای زمان اجرا: در زبان‌های پویا ممکن است اشتباه در ثبت/حل وابستگی دیر کشف شود. تست‌های ترکیبی و چک‌لیست راه‌اندازی مفید است.

چک لیست پیاده سازی سریع

  1. قرارداد را تعریف کنید: مثلا سرویس «ارسال اعلان» با متدی مثل send(to, message).
  2. Constructor Injection را پیش‌فرض بگیرید و وابستگی‌ها را واضح در سازنده بپذیرید.
  3. نقطه ترکیب بسازید: فقط یک مکان برای ساخت اشیای سطح بالا (وب‌سرور، CLI main، یا bootstrap).
  4. چرخه حیات را تعیین کنید: stateless ها معمولا Singleton، وابسته به درخواست‌ها Scoped، و آبجکت‌های موقت Transient.
  5. برای تست، نسخه‌های فیک/ماک تزریق کنید و رفتار را راستی‌آزمایی کنید.
  6. در صورت رشد پروژه، به تدریج از کانتینر استفاده کنید؛ نه از روز اول به صورت افراطی.

سوالات متداول

تفاوت Inversion of Control با Dependency Injection چیست؟

IoC یک اصل کلی است: ماژول‌ها کنترل ساخت و اجرای خود را به بیرون می‌سپارند. DI یکی از راه‌های عملی کردن IoC است که روی «نحوه تامین وابستگی‌ها از بیرون» تمرکز دارد.

آیا Service Locator همان DI است؟

خیر. در Service Locator کلاس‌ها وابستگی را از یک مکان سراسری می‌گیرند و این وابستگی در امضای کلاس پنهان می‌ماند. در DI وابستگی‌ها صریحا به سازنده یا متد تزریق می‌شوند و تست‌پذیری بیشتر است.

در زبان‌های بدون interface چطور قرارداد تعریف کنیم؟

با قرارداد ضمنی از طریق امضای متدها (Duck Typing) یا تایپ‌های ساخت‌یافته (مثلا در TypeScript)؛ مهم این است که کلاس مصرف‌کننده فقط به «رفتار» تکیه کند نه پیاده‌سازی خاص.

گام بعدی چیست؟

یک ماژول پروژه‌تان را که الان داخلش new می‌زنید انتخاب کنید، سازنده آن را به دریافت وابستگی تغییر دهید و ساخت اشیا را به نقطه شروع برنامه منتقل کنید. همین یک تغییر کوچک، مسیر کد را برای تست‌پذیری و توسعه آینده هموار می‌کند.