מה זה?

ביחידת ASP.NET Core בנינו תבנית שלמה: IRepository<T> (הבטחה ל-CRUD), InMemoryStudentRepository (מימוש שמחזיק List<Student> בזיכרון), StudentService (לוגיקה עסקית, מקבל IRepository<Student> בקונסטרוקטור), ו-StudentsController (מקבל StudentService בקונסטרוקטור). בשיעור הקודם למדנו להרים SQL Server אמיתי ולדבר איתו דרך DbContext — אבל חיברנו אותו ישירות ל-Controller, בעקיפין לכל התבנית הזו, רק כדי להוכיח שהחיבור הטכני עובד.

השיעור הזה סוגר את המעגל: כותבים SqlStudentRepository — מימוש חדש של אותו IRepository<T> שכבר קיים, עם DbContext במקום List. אם התבנית תוכננה נכון (וכבר ראינו שכן), StudentService ו-StudentsController לא ישתנו אף שורה אחת.

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

• SqlRepository — מימוש של IRepository<T> שמשתמש ב-EF Core (DbContext/DbSet) במקום מבנה נתונים בזיכרון

• Change Tracking — המנגנון שבו DbContext “זוכר” אילו אובייקטים נוספו/שונו/נמחקו, כדי לדעת איזו שאילתת SQL לייצר כש-SaveChanges() נקרא

• appsettings.json — קובץ ההגדרות של הפרויקט (כבר הוזכר בשיעור הראשון של היחידה הקודמת); כאן נשתמש בו בפועל לראשונה, במקום לכתוב מחרוזת חיבור ישירות בקוד

בונים את SqlRepository שלב אחר שלב

שלב 1: מזכירים את מה שכבר קיים

לפני שכותבים שורת קוד אחת, שני תזכורות קצרות מהיחידה הקודמת. ההבטחה שכבר הוגדרה:

public interface IRepository<T>
{
    List<T> GetAll();
    T GetById(int id);
    void Add(T item);
    void Update(T item);
    void Delete(int id);
}

והמימוש הקיים, שמחזיק הכול בזיכרון:

public class InMemoryStudentRepository : IRepository<Student>
{
    private List<Student> _students = new List<Student>();

    public List<Student> GetAll() => _students;
    public Student GetById(int id) => _students.FirstOrDefault(s => s.Id == id);
    public void Add(Student item) => _students.Add(item);
    public void Update(Student item) { /* update in the list */ }
    public void Delete(int id) => _students.RemoveAll(s => s.Id == id);
}

המטרה של השיעור הזה: לכתוב מחלקה שלישית — לא נוגעים בשתיים האלה — שמממשת בדיוק את אותו IRepository<Student>, אבל עם SchoolDbContext מהשיעור הקודם במקום List.

שלב 2: מתחילים מהמתודה הכי פשוטה — GetAll

public class SqlStudentRepository : IRepository<Student>
{
    private readonly SchoolDbContext _context;

    public SqlStudentRepository(SchoolDbContext context)   // injected automatically — AddDbContext from the previous lesson
    {
        _context = context;
    }

    public List<Student> GetAll() => _context.Students.ToList();
}

: IRepository<Student> אומר שהמחלקה הזו מתחייבת לממש את כל חמש המתודות של ה-Interface — עדיין רק אחת כתובה, בכוונה. הקונסטרוקטור מקבל SchoolDbContext — בדיוק אותו DbContext שבנינו בשיעור הקודם, ובדיוק אותו עקרון של Constructor Injection שכבר מוכר: אתם לא כותבים new SchoolDbContext(...) בשום מקום, ASP.NET מעביר אותו אוטומטית כי הוא רשום ב-DI (עוד מעט נזכיר גם את שורת הרישום).

GetAll() מתורגם ל-SELECT * FROM Students — בדיוק כמו שכבר ראיתם בשיעור הקודם.

שלב 3: מוסיפים GetById

public Student GetById(int id) => _context.Students.FirstOrDefault(s => s.Id == id);

זהה לחלוטין לתחביר ב-InMemoryStudentRepository משלב 1 — FirstOrDefault עובד זהה על List<T> ועל DbSet<T>. ההבדל היחיד (בלתי-נראה מהקוד עצמו): כאן זה מתורגם ל-SELECT TOP(1) * FROM Students WHERE Id = @id, לא לולאה על רשימה בזיכרון.

שלב 4: מוסיפים Add

public void Add(Student item)
{
    _context.Students.Add(item);
    _context.SaveChanges();   // this is where it's actually written to the DB
}

כאן מופיע ההבדל האמיתי הראשון מול InMemoryStudentRepository: שם, _students.Add(item) לבדו היה מספיק — השורה היא הפעולה. כאן, _context.Students.Add(item) לא כותב כלום ל-DB עדיין — הוא רק אומר ל-DbContext “תזכור שצריך להוסיף את זה” (זה בדיוק ה-Change Tracking שהוזכר במילות המפתח). רק SaveChanges() הופך את זה לשאילתת INSERT אמיתית. בלי השורה הזו, Add “עובד” מבחינת הקומפיילר אבל לא עושה כלום בפועל ב-DB.

שלב 5: מוסיפים Update — מושג חדש, Change Tracking

public void Update(Student item)
{
    _context.Students.Update(item);
    _context.SaveChanges();
}

_context.Students.Update(item) מסמן את האובייקט item כ"השתנה" אצל ה-DbContext — כך ש-SaveChanges() ידע לייצר UPDATE SQL (עם כל העמודות) במקום INSERT. זה שונה מהותית מ-InMemoryStudentRepository, ששם Update היה צריך למצוא את השורה הישנה ברשימה ולהחליף אותה ידנית — כאן DbContext עושה את ה"זיהוי מה השתנה" בשבילכם.

שלב 6: מוסיפים Delete — המתודה האחרונה

public void Delete(int id)
{
    var student = GetById(id);
    if (student != null)
    {
        _context.Students.Remove(student);
        _context.SaveChanges();
    }
}

שימו לב: קודם שולפים את האובייקט עם GetById (המתודה שכבר כתבנו בשלב 3) — EF Core לא יכול למחוק “לפי מספר בלבד”, הוא צריך אובייקט שהוא כבר “מכיר” (עקב ה-Change Tracking). if (student != null) מונע ניסיון מחיקה של תלמיד שלא קיים בכלל. Remove + SaveChanges() — אותו דפוס בדיוק כמו שאר המתודות.

כל חמש המתודות של IRepository<Student> ממומשות — ה-SqlStudentRepository שלם.

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

// before:
builder.Services.AddScoped<IRepository<Student>, InMemoryStudentRepository>();

// after — a single change:
builder.Services.AddScoped<IRepository<Student>, SqlStudentRepository>();

זהו. לא שורה נוספת, לא שינוי בשום מחלקה אחרת — רק מה נרשם מול אותו IRepository<Student>. AddDbContext<SchoolDbContext>(...) מהשיעור הקודם כבר רשום ונשאר בדיוק כמו שהיה; SqlStudentRepository מקבל ממנו את ה-DbContext בדיוק כפי שכל Controller קיבל אותו קודם.

שלב 8: מחרוזת החיבור “כמו שצריך” — appsettings.json

בשיעור הקודם כתבנו את מחרוזת החיבור ישירות בתוך Program.cs, כדי להתמקד בעיקר. עכשיו, כשהכול עובד, שווה לעשות את זה נכון: זוכרים את appsettings.json מהשיעור הראשון של היחידה הקודמת (“קובץ הגדרות תצורה”)? זה בדיוק המקום שמחרוזות חיבור אמורות לחיות בו:

// appsettings.json
{
  "ConnectionStrings": {
    "DefaultConnection": "Server=localhost,1433;Database=SchoolDb;User Id=sa;Password=YourStrong@Passw0rd;TrustServerCertificate=True;"
  }
}

ו-ב-Program.cs, במקום המחרוזת הקשיחה:

// before (previous lesson):
// var connectionString = "Server=localhost,1433;...";

// after:
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");

builder.Services.AddDbContext<SchoolDbContext>(options =>
    options.UseSqlServer(connectionString));

builder.Configuration יודע לקרוא את appsettings.json אוטומטית (זו בדיוק ההתנהגות המובנית שהוזכרה כבר בשיעור הראשון של היחידה הקודמת). GetConnectionString("DefaultConnection") הוא קיצור נוח ל-"ConnectionStrings:DefaultConnection" בתוך הקובץ. למה זה עדיף על מחרוזת קשיחה בקוד: אפשר לשים appsettings.Development.json נפרד עם DB אחר לפיתוח, ו-appsettings.json ל-production — בלי לגעת בקוד בכלל, רק בקבצי הגדרות.

שלב 9: ה-Controller חוזר להיות דק

בשיעור הקודם, StudentsController דיבר ישירות עם SchoolDbContext — זה היה מסומן במפורש כקיצור-דרך זמני. עכשיו, כשיש SqlStudentRepository תקין, חוזרים בדיוק לקוד ה-Controller המקורי מיחידת ה-DAL — בלי שום שינוי:

public class StudentsController : ControllerBase
{
    private readonly StudentService _service;
    public StudentsController(StudentService service) => _service = service;

    [HttpGet]
    public IActionResult GetAll() => Ok(_service.GetAll());

    [HttpPost]
    public IActionResult Create([FromBody] Student newStudent)
    {
        var student = _service.RegisterStudent(newStudent.Name, newStudent.Age);
        return CreatedAtAction(nameof(GetAll), student);
    }
}

זו בדיוק המחלקה מיחידת ה-DAL, מילה במילה. StudentService עצמה גם היא לא השתנתה — היא עדיין מקבלת IRepository<Student> בקונסטרוקטור, ולא יודעת (ולא צריכה לדעת) שמאחורי הקלעים זה כבר לא List בזיכרון אלא SQL Server אמיתי.

שלב 10: מריצים ובודקים שבאמת כלום לא “הרגיש” את השינוי

dotnet run
curl -X POST http://localhost:5123/api/students -H "Content-Type: application/json" -d "{\"name\":\"Dana\",\"age\":25}"
curl http://localhost:5123/api/students

אותן שתי קריאות בדיוק כמו בסוף השיעור הקודם, אותה תוצאה — אבל הפעם הבקשה עוברת דרך StudentsController → StudentService → SqlStudentRepository → DbContext → SQL Server, לא דרך Controller שמדבר עם ה-DB ישירות. אם עוצרים את השרת ומריצים שוב, דנה עדיין שם — בדיוק כמו קודם, רק שעכשיו זה עובר דרך כל השכבות שבנינו ביחידה הקודמת, בדיוק כמו שתוכננו.

הסבר עיקרי

זו ההוכחה המעשית ל-DIP ולתבנית ה-DAL כולה — StudentService מקבל IRepository<Student> דרך הקונסטרוקטור, ולא יודע (ולא צריך לדעת) אם המימוש בפועל הוא List בזיכרון או DbContext אמיתי. כל מה שהשתנה הוא מה נרשם ב-DI Container — שורה אחת ב-Program.cs.

SaveChanges() בכל מתודה, לא רק ב-Add — בניגוד ל-InMemoryRepository שבו Add/Update/Delete פעלו מיידית על ה-List בזיכרון, כאן כל שינוי חייב SaveChanges() מפורש כדי להיכתב בפועל ל-DB. זו נקודת ההבדל הכי משמעותית בין שני המימושים, למרות שה-Interface שלהם זהה לחלוטין.

יתרונות

מעבר ממימוש בזיכרון למסד נתונים אמיתי הוא שינוי מבודד לחלוטין — הוכחה חיה לערך של DIP+DAL; קוד ה-Service וה-Controller נשארים זהים לגמרי, כולל בדיקות שכבר נכתבו עליהם; Connection String דרך Configuration מאפשר סביבות מרובות בלי שינוי קוד.

חסרונות

SqlRepository דורש SaveChanges() מודע בכל מתודה — קל לשכוח בהשוואה למימוש InMemory ה"אוטומטי"; שגיאות DB אמיתיות (חיבור נכשל, timeout) הן מקור כשלים חדש שלא היה קיים במימוש InMemory כלל, ודורש טיפול נוסף.

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

• SqlRepository מממש את אותו IRepository<T> כמו InMemoryRepository — אותו חוזה, מימוש שונה

• השינוי היחיד הנדרש במעבר הוא רישום ה-DI ב-Program.cs, לא בכל קוד קורא

• כל מתודה ב-SqlRepository שמשנה נתונים חייבת SaveChanges() משלה; Update/Remove מסתמכים על Change Tracking

• Connection String נטען מ-appsettings.json/Configuration, לא מקושח בקוד

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

• לשכוח SaveChanges() באחת המתודות (למשל Delete) ולתהות למה השינוי “לא נשמר”

• לקושש Connection String ישירות בקוד במקום Configuration — קשה להחליף בין סביבות (ראו שלב 8)

• לשנות בטעות את חתימת IRepository<T> כדי “להתאים” ל-EF Core — מאבד את כל התועלת של ההפשטה המקורית

סיכום

SqlRepository הוא מימוש חדש של אותו IRepository<T> שכבר הוגדר ביחידת ASP.NET Core, הפעם עם EF Core (DbContext/DbSet) במקום List בזיכרון. בנינו אותו מתודה-מתודה (GetAll → GetById → Add → Update → Delete), רשמנו אותו ב-Program.cs בשורה אחת, העברנו את מחרוזת החיבור ל-appsettings.json, והחזרנו את ה-Controller לגרסתו הדקה המקורית — בלי לגעת בשכבות ה-Service וה-Controller כלל. זו ההוכחה המעשית והמוחשית ביותר לערך של הפרדת שכבות שנלמדה קודם.

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

Microsoft Learn — Implementing the repository pattern with EF Core

Microsoft Learn — Configuration in ASP.NET Core


תרגילים

תרגיל 1 — SqlRepository ראשון

המשימה: ממשו SqlProductRepository : IRepository<Product> מתרגיל היחידה הקודמת, עם DbContext אמיתי במקום List — מתודה אחר מתודה, כמו בשלבים 2-6 למעלה.

בדיקה: כל פעולות ה-CRUD עובדות זהה מבחינת ה-Controller/Service, אך הנתונים נשמרים בפועל ב-DB (נשארים גם אחרי restart של האפליקציה).

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

המשימה: קחו פרויקט קיים עם InMemoryProductRepository רשום ב-DI, והחליפו לרישום SqlProductRepository — שינוי בשורה אחת בלבד.

בדיקה: כל שאר הקוד (Service, Controller) ממשיך לעבוד בלי שום שינוי נוסף.


פרויקט מסכם

המשימה: השלימו את המעבר המלא של TasksController/TaskService (מהיחידה הקודמת) ל-EF Core אמיתי.

דרישות:

  1. TaskDbContext עם DbSet<TaskItem>, ו-Migration ראשונה
  2. SqlTaskRepository : IRepository<TaskItem> עם מימוש מלא (כל מתודה עם SaveChanges() משלה)
  3. עדכון Program.cs בלבד (כולל מעבר ל-appsettings.json) — TaskService ו-TasksController הקיימים לא נוגעים

בדיקה: כל ה-endpoints הקיימים (CRUD מלא) ממשיכים לעבוד זהה מבחוץ; יצירת משימה ואז restart של האפליקציה + GET מראה שהמשימה עדיין קיימת — הוכחה שהיא נשמרה ב-DB אמיתי, לא רק בזיכרון.

מה בפרק הבא

בפרק הבא — Queries מתקדמות: LINQ על DbSet, ההבדל בין raw SQL ל-LINQ, Include לטעינת קשרים (Student.Grades מהשיעור על DbContext), ובעיית ה-N+1 — מלכודת ביצועים נפוצה שחייבים להכיר.