מה זה?

השיעור הזה בנוי כמו סיפור עם התקדמות: מתחילים מפתרון פשוט וטבעי, רואים בדיוק למה הוא נשבר במציאות, ורק אז מכניסים את הרעיון החדש כתשובה ישירה לבעיה הזאת — לא כמושג מופשט שצריך “לדעת שקיים”.

הדוגמה שנלווה לאורך כל השיעור: OrdersController צריך לשלוח התראה בכל פעם שנוצרת הזמנה חדשה — נתחיל מהדרך הכי פשוטה לעשות את זה, ונבנה משם צעד-צעד עד ל-Dependency Injection (בקיצור: DI) מלא.

מילות מפתח שחשוב לזכור

• DI Container — המנגנון המובנה של ASP.NET Core שמנהל אילו מימושים תואמים לאילו Interfaces

• services.AddScoped<...> — רישום: “כשמישהו מבקש INotifier, תן לו EmailNotifier”

• Constructor Injection — הדרך המקובלת לקבל תלות: פרמטר בקונסטרוקטור, ה-DI Container ממלא אותו אוטומטית

• Service Lifetime — כמה זמן “חי” כל מופע: Scoped (לכל בקשת HTTP), Singleton (אחד לכל התוכנית), Transient (מופע חדש בכל בקשה לו)

בונים DI שלב אחר שלב

שלב 0: הבעיה עצמה — מי שולח את ההתראה?

OrdersController (Controller רגיל, בדיוק כמו StudentsController שכבר בנינו) צריך שכל הזמנה חדשה תפיק התראה. מישהו צריך לדעת “איך שולחים התראה” — ואת ה"מישהו" הזה אנחנו כותבים בעצמנו, כמחלקה רגילה בפרויקט שלנו:

// Notifications/EmailNotifier.cs — קובץ שאנחנו כותבים, לא משהו שמגיע מוכן מ-ASP.NET
public class EmailNotifier
{
    public void Notify(string message)
    {
        // בפרויקט אמיתי כאן היה קוד ששולח מייל בפועל (SMTP וכו').
        // כדי שהדוגמה תישאר ממוקדת ב-DI ולא בפרטי שליחת מייל, נסתפק בהדפסה.
        Console.WriteLine($"Sending email: {message}");
    }
}

אין כאן שום דבר חדש — זו מחלקה בדיוק כמו Student או Product שכבר כתבנו: public class EmailNotifier מגדיר אותה, ובתוכה public void Notify(string message) היא מתודה רגילה שמקבלת טקסט ולא מחזירה כלום (void). היא יושבת בקובץ משלה בפרויקט (למשל Notifications/EmailNotifier.cs), בדיוק כמו שכל מחלקה אחרת שכתבנו יושבת בקובץ משלה.

עכשיו משתמשים בה מתוך ה-Controller, בדרך הכי טבעית שיש:

[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
    [HttpPost]
    public IActionResult PlaceOrder()
    {
        var notifier = new EmailNotifier();   // יוצרים אובייקט חדש מהמחלקה שכתבנו למעלה
        notifier.Notify("Order placed");
        return Ok();
    }
}

new EmailNotifier() הוא בדיוק אותו new שכבר השתמשנו בו המון פעמים (new Student(...), new List<int>()) — כאן פשוט יוצרים מופע מהמחלקה שלנו במקום ממחלקת נתונים. notifier הוא משתנה מקומי שמחזיק את האובייקט הזה, ואז קוראים למתודה Notify שלו.

וזה עובד. מריצים, שולחים הזמנה, מקבלים “Sending email: Order placed” בקונסולה. אין שום באג כאן ואין שום דבר “לא נכון” בקוד הזה כרגע.

שלב 1: מתי הפתרון הזה נשבר

הבעיה לא מופיעה היום — היא מופיעה כשמשהו משתנה. נניח שמחליטים לעבור מהתראות במייל להתראות ב-SMS. עכשיו צריך:

  1. לכתוב מחלקה חדשה, SmsNotifier, עם מתודת Notify שיודעת לשלוח SMS.
  2. לחפש כל מקום בפרויקט שכתוב בו new EmailNotifier() — לא רק ב-OrdersController, גם ב-InvoicesController, ב-ShippingController, ובכל Controller אחר שצריך לשלוח התראה — ולהחליף שם ל-new SmsNotifier().

בפרויקט עם 5 Controllers כאלה, זה 5 מקומות לתקן ידנית. שוכחים מקום אחד? הוא ימשיך לשלוח מייל בזמן שכל השאר עברו ל-SMS — באג שקט, לא שגיאת קומפילציה, רק התנהגות לא-עקבית שמישהו יגלה מאוחר.

השורש של הבעיה: כל Controller יוצר בעצמו את EmailNotifier הספציפי, ולכן כל Controller “יודע” איזה סוג התראה קיים במערכת. זה בדיוק המושג “תלות ישירה במימוש קונקרטי” — אם למדתם את יחידת ה-SOLID, זו בדיוק הבעיה ש-DIP מדבר עליה.

שלב 2: הפתרון — לתלות בהבטחה, לא בפרט

הרעיון: OrdersController לא צריך לדעת אם ההתראה נשלחת במייל, ב-SMS, או בכל דרך אחרת. הוא רק צריך לדעת שיש למישהו מתודת Notify. את ההבטחה הזו מבטא Interface:

// Notifications/INotifier.cs
public interface INotifier
{
    void Notify(string message);
}

Interface, כמו שכבר ראינו ביחידת ה-OOP, הוא רשימת מתודות בלי מימוש — הבטחה ש"כל מי שמממש אותי, יש לו את המתודות האלה". עכשיו EmailNotifier מממש את ההבטחה הזו:

public class EmailNotifier : INotifier   // ": INotifier" = "אני מבטיח שיש לי את כל מה שב-INotifier"
{
    public void Notify(string message)
    {
        Console.WriteLine($"Sending email: {message}");
    }
}

שום דבר בתוך EmailNotifier לא השתנה חוץ מה-: INotifier — היא עדיין אותה מחלקה, רק שעכשיו רשום עליה במפורש שהיא עומדת בהבטחה של INotifier.

שלב 3: ה-Controller תלוי בהבטחה, לא ב-EmailNotifier

עכשיו משנים את ה-Controller כך שיקבל INotifier — ההבטחה — ולא יידע בכלל ש-EmailNotifier קיים:

public class OrdersController : ControllerBase
{
    private readonly INotifier _notifier;

    public OrdersController(INotifier notifier)
    {
        _notifier = notifier;
    }

    [HttpPost]
    public IActionResult PlaceOrder()
    {
        _notifier.Notify("Order placed");
        return Ok();
    }
}

בואו נעצור על השורה private readonly INotifier _notifier; — כל מילה בה עושה משהו:

  • private — השדה נגיש רק מתוך המחלקה OrdersController עצמה, בדיוק כמו private שכבר ראינו על שדות ביחידת ה-OOP.
  • readonly — אפשר לתת לשדה ערך רק פעם אחת, בקונסטרוקטור. אחרי זה אי אפשר לשנות אותו. זה מתאים כאן כי ברגע שה-Controller נוצר, ה-Notifier שלו לא אמור להתחלף באמצע החיים שלו.
  • INotifier — הטיפוס של השדה. שימו לב: זה ה-Interface, לא EmailNotifier. זו בדיוק הנקודה — השדה יודע רק “יש כאן משהו שמממש Notify”, לא איזה מימוש ספציפי.
  • _notifier — שם השדה. קו-תחתי בהתחלה (_) הוא מוסכמת שמות נפוצה ב-C# לשדות private — מבדיל אותם ויזואלית מפרמטרים ומשתנים מקומיים.

ועכשיו הקונסטרוקטור: public OrdersController(INotifier notifier) — הפרמטר notifier מגיע מבחוץ, מישהו אחר צריך להעביר אותו כשיוצרים OrdersController. בגוף הקונסטרוקטור, _notifier = notifier; פשוט שומר את מה שהתקבל בתוך השדה, כדי שאר המתודות של המחלקה (כמו PlaceOrder) יוכלו להשתמש בו.

אבל זה יוצר שאלה חדשה: מי בכלל קורא ל-new OrdersController(...), ומה הוא מעביר בתור notifier? עד עכשיו אף אחד לא ענה על זה.

שלב 4: מי יוצר Controller בכלל?

התשובה, שכבר ראינו בשיעור ה-Controllers: אתם אף פעם לא כותבים new OrdersController(...) בעצמכם. ASP.NET Core עצמו יוצר את ה-Controller בכל פעם שמגיעה בקשת HTTP מתאימה (זוכרים את AddControllers()/MapControllers() מ-Program.cs?). זה אומר שעכשיו ASP.NET הוא זה שצריך לדעת מה להעביר בתור notifier — ולזה בדיוק משמש ה-DI Container.

שלב 5: אומרים ל-ASP.NET מה להעביר — רישום ב-Program.cs

ASP.NET Core מחזיק “ספר טלפונים” פנימי — עבור כל Interface, מי המימוש שצריך להעביר כשמישהו מבקש אותו. השורה שרושמת ערך בספר הזה:

// Program.cs
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddScoped<INotifier, EmailNotifier>();   // "כשמישהו מבקש INotifier, תן לו EmailNotifier"

var app = builder.Build();

builder.Services הוא בדיוק הספר-טלפונים הזה. AddScoped<INotifier, EmailNotifier>() היא הרשומה: הטיפוס הראשון (INotifier) הוא מה מבקשים, הטיפוס השני (EmailNotifier) הוא מה לתת. המילה Scoped קובעת לכמה זמן “חי” המימוש שנוצר — נרחיב על זה בהמשך השיעור; בשלב הזה מספיק לדעת שזו הדרך הנפוצה ביותר לרשום שירות.

שלב 6: רואים את זה מתחבר לבד

עכשיו, כשמגיעה בקשת POST /api/orders, קורה בדיוק זה:

Client
  │  POST /api/orders
  ▼
ASP.NET Core: "אני צריך ליצור OrdersController.
               הקונסטרוקטור שלו מבקש INotifier.
               בספר הטלפונים כתוב: INotifier → EmailNotifier."
  ▼
יוצר EmailNotifier, ומעביר אותו כפרמטר notifier
  ▼
OrdersController.PlaceOrder() רץ, _notifier הוא כבר EmailNotifier מוכן

שימו לב: ה-Controller משלב 3 לא השתנה בכלל. כל מה שהיה צריך זה שורת הרישום ב-Program.cs.

שלב 7: היתרון האמיתי — מחליפים מימוש בלי לגעת ב-Controllers

עכשיו, כשרוצים לעבור ל-SMS, כותבים מחלקה חדשה שמממשת את אותה הבטחה:

public class SmsNotifier : INotifier
{
    public void Notify(string message)
    {
        Console.WriteLine($"Sending SMS: {message}");
    }
}

ומשנים שורה אחת ב-Program.cs:

// Program.cs — שורה אחת, שום דבר אחר לא משתנה
builder.Services.AddScoped<INotifier, SmsNotifier>();   // עכשיו כולם מקבלים SmsNotifier

OrdersController לא נגעו בו. אם יש עוד 5 Controllers שמבקשים INotifier בקונסטרוקטור שלהם — גם בהם לא נגעו. כולם מקבלים אוטומטית את SmsNotifier החדש, כי כולם תלויים בהבטחה (INotifier) ולא במימוש הספציפי. זו בדיוק הבעיה משלב 1, פתורה לחלוטין.

טעות נפוצה: שכחתם לרשום ב-Program.cs

נניח שכתבתם את הכול משלבים 2-3, אבל שכחתם את שורת ה-AddScoped משלב 5. מריצים את הפרויקט, שולחים POST /api/orders, ומקבלים שגיאה בסגנון:

InvalidOperationException: Unable to resolve service for type 'INotifier'
while attempting to activate 'OrdersController'.

בואו נבין למה השגיאה הזו הגיונית, לא רק “נזכור לתקן”: ASP.NET ניגש לספר-הטלפונים שלו כדי לדעת מה להעביר בתור notifier, ולא מוצא שם רשומה בשביל INotifier. הוא לא יכול “לנחש” איזה מימוש התכוונתם אליו — EmailNotifier? SmsNotifier? אולי מימוש שלישי שעוד לא כתבתם? במקום לנחש, הוא זורק שגיאה ברורה. התיקון: לוודא שיש שורת AddScoped<INotifier, ...> ב-Program.cs לפני builder.Build().

הסבר עיקרי

מה שבנינו כאן הוא בדיוק DIP מיחידת SOLID, רק שהפעם ה-Framework עושה את ה"הזרקה" בעצמו. ב-DIP הזרקנו תלות ידנית (כתבנו בעצמנו איזה מימוש להעביר). עם DI Container, אנחנו רק אומרים ל-ASP.NET Core “כשמבקשים INotifier, תן EmailNotifier” — פעם אחת, ב-Program.cs — וההזרקה בפועל לכל Controller קורית אוטומטית בכל בקשה. זה אותו עיקרון בדיוק, רק עם Framework שמבצע את החיבור בשבילנו במקום שנעשה את זה ידנית בכל מקום.

Service Lifetime קובע כמה זמן “חי” כל מופע שנוצר:

  • Scoped (הכי נפוץ) — מופע חדש לכל בקשת HTTP; כל השירותים שמעורבים באותה בקשה (Controller, Service, Repository) משתפים את אותו מופע, אבל בקשה אחרת מקבלת מופע חדש לגמרי.
  • Singleton — מופע אחד ויחיד לכל חיי התוכנית, מרגע שהשרת עלה ועד שהוא נסגר. בדיוק כמו תבנית ה-Singleton מיחידת Design Patterns — רק שכאן ה-DI Container מנהל את זה בשבילכם, לא אתם כותבים לוגיקת Singleton ידנית.
  • Transient — מופע חדש בכל פעם שמבקשים אותו, אפילו בתוך אותה בקשת HTTP אם הוא מבוקש פעמיים.

למה זה חשוב מעבר לנוחות: בדיוק כמו ש-DIP איפשר להזריק MockPaymentGateway לבדיקות בלי לגעת ב-CheckoutService, DI מאפשר לרשום מימוש-בדיקה שונה בסביבת טסטים (למשל FakeNotifier שרק זוכר מה נשלח, בלי לשלוח שום דבר באמת), בזמן שקוד ה-Controller עצמו לא משתנה כלל.

יתרונות

Constructor Injection הופך תלויות לגלויות וברורות (רואים מיד מה Controller צריך כדי לפעול); החלפת מימוש היא שינוי במקום אחד (Program.cs), לא בכל מקום שמשתמש בתלות; Service Lifetimes נותנים שליטה מדויקת על ניהול משאבים.

חסרונות

בחירת Lifetime שגויה (למשל Singleton על שירות שאמור להיות Scoped) עלולה ליצור באגים עדינים של שיתוף מצב לא-רצוי בין בקשות; יותר מדי הפשטות/Interfaces רק לצורך DI, בלי צורך אמיתי, מוסיף מורכבות.

נקודות חשובות

• DI Container מנהל אילו מימושים תואמים לאילו Interfaces, נרשם ב-Program.cs

• Constructor Injection = קבלת תלות כפרמטר קונסטרוקטור, לא new ידני בפנים

• Scoped = לכל בקשת HTTP; Singleton = אחד לכל התוכנית; Transient = חדש בכל בקשה לתלות

• DI Container הוא המימוש המעשי של DIP מיחידת SOLID, לא רעיון נפרד

טעויות נפוצות

• לשכוח לרשום Interface ב-Program.cs ולקבל שגיאת ריצה “Unable to resolve service” כשה-Controller עולה (ראו את ההסבר המלא בשלבים למעלה)

• לבחור Singleton לשירות שמחזיק מצב ספציפי-לבקשה (כמו משתמש מחובר) — דליפת מידע בין בקשות שונות

• ליצור new EmailNotifier() ידנית בתוך Controller במקום להזריק — חוזר אחורה מ-DIP

סיכום

בנינו DI שלב אחר שלב: התחלנו מ-new EmailNotifier() מקושח ישירות בקוד (עובד, אבל שינוי דורש לחפש ולתקן בכל מקום), עברנו ל-Interface (INotifier) שמבטא “הבטחה” במקום מימוש ספציפי, המשכנו ל-Constructor Injection ידני (טוב יותר, אבל מי יוצר את ה-Controller?), ועד רישום ב-Program.cs עם DI Container שמבצע את ההזרקה אוטומטית לכל Controller. Service Lifetime (Scoped/Singleton/Transient) קובע כמה זמן חי כל מופע. היתרון המרכזי: החלפת מימוש היא שינוי במקום אחד, לא בכל מקום שמשתמש בתלות.

דוקומנטציה רשמית

Microsoft Learn — Dependency injection in ASP.NET Core


תרגילים

תרגיל 1 — רישום והזרקה ראשונים

המשימה: צרו interface ILogger { void Log(string message); } עם מימוש ConsoleLogger, רשמו אותו ב-Program.cs עם AddScoped, והזריקו אותו ל-Controller קיים דרך הקונסטרוקטור.

בדיקה: ה-Controller משתמש ב-ILogger שהוזרק, בלי ליצור new ConsoleLogger() בעצמו בשום מקום.

תרגיל 2 — החלפת מימוש

המשימה: צרו מימוש שני, FileLogger, והחליפו את הרישום ב-Program.cs מ-ConsoleLogger ל-FileLogger, בלי לגעת ב-Controller בכלל.

בדיקה: אחרי השינוי ב-Program.cs בלבד, ה-Controller “מקבל” את FileLogger החדש אוטומטית.


פרויקט מסכם

המשימה: הוסיפו שכבת שירות מוזרקת ל-TasksController (מהפרויקטים המסכמים הקודמים).

דרישות:

  1. interface ITaskNotifier עם מתודה NotifyTaskCreated(string title), ולפחות שני מימושים (ConsoleTaskNotifier, EmailTaskNotifier — יכול להדפיס בלבד לצורך התרגול)
  2. רישום המימוש הנבחר ב-Program.cs עם AddScoped
  3. TasksController מקבל ITaskNotifier דרך הקונסטרוקטור, וקורא לו בכל יצירת משימה חדשה (Create Action)

בדיקה: יצירת משימה מדפיסה הודעת התראה מהמימוש הרשום; החלפת הרישום ב-Program.cs בלבד (בלי לגעת ב-TasksController) משנה איזה מימוש רץ בפועל.

מה בפרק הבא

עכשיו שאנחנו יודעים איך ASP.NET מזריק תלויות אוטומטית, אפשר להשתמש בדיוק באותו מנגנון כדי להזריק ל-Controller לא רק “שולח התראות”, אלא גם “מקום שמור בו הנתונים” — תבנית DAL (IRepository): איך מפרידים לחלוטין בין הלוגיקה העסקית לבין איך הנתונים נשמרים בפועל, כהכנה למעבר מ-InMemory למסד נתונים אמיתי ביחידה הבאה.