מה זה?

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

נתחיל מהמצב הנוכחי של StudentsController, נראה בדיוק למה הוא ייתקע ביחידה הבאה (כשעוברים למסד נתונים אמיתי), ונבנה פתרון שלב-שלב שמכין אותנו מראש למעבר הזה.

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

• Repository Pattern — הפשטה של גישה לנתונים מאחורי Interface, כדי שהלוגיקה העסקית לא תדע (ולא תצטרך לדעת) איך הנתונים נשמרים בפועל

• IRepository<T> — Interface גנרי עם פעולות CRUD בסיסיות (GetAll, GetById, Add, Update, Delete)

• Service Layer — שכבה בין ה-Controller ל-Repository, מחזיקה לוגיקה עסקית (כמו ולידציה עסקית מורכבת) שלא שייכת ל-HTTP ולא לגישה-לנתונים

• Three-Layer Architecture — Controller (HTTP) → Service (לוגיקה עסקית) → Repository (גישה לנתונים), כל שכבה לא יודעת על מה שמעליה

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

שלב 0: המצב הנוכחי — הנתונים חיים בתוך ה-Controller

public class StudentsController : ControllerBase
{
    private static List<Student> _students = new();

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

    [HttpPost]
    public IActionResult Create([FromBody] Student newStudent)
    {
        _students.Add(newStudent);
        return CreatedAtAction(nameof(GetById), new { id = newStudent.Id }, newStudent);
    }
    // ... שאר ה-Actions
}

שתי נקודות ששווה לעצור עליהן לפני שממשיכים:

  • private static List<Student> _students — למה static דווקא? ASP.NET יוצר Controller חדש לכל בקשת HTTP (זוכרים? זה בדיוק מה שראינו בשיעור ה-Controllers). אם _students היה שדה רגיל (בלי static), כל בקשה הייתה מקבלת רשימה ריקה חדשה, וכל תלמיד שנוצר היה “נעלם” ברגע שהבקשה מסתיימת. static אומר “שדה אחד, משותף לכל המופעים של המחלקה” — כך הרשימה נשארת קיימת בין בקשה לבקשה. זו בדיוק הסיבה ש-static כאן היא “טלאי” ולא פתרון אמיתי — היא עובדת, אבל רק כי אנחנו “עוקפים” את זה שה-Controller עצמו נוצר מחדש כל פעם.
  • הנתונים והלוגיקה שמטפלת ב-HTTP (GetAll, Create) יושבים באותה מחלקה בדיוק.

זה עובד — אבל ביחידה הבאה עוברים למסד נתונים אמיתי (SQL). מה יקרה אז? כל שורת קוד ב-StudentsController שנוגעת ב-_students תצטרך להשתנות — ב-GetAll, ב-Create, בכל Action. תבנית DAL (Data Access Layer) פותרת את זה מראש.

שלב 1: מוציאים את הגישה לנתונים ל-Interface

בדיוק כמו ששלב 2 בשיעור הקודם הוציא את “שליחת ההתראה” ל-INotifier, עכשיו מוציאים את “הגישה לנתונים” ל-Interface. קודם מגדירים מה אפשר לעשות עם הנתונים, בלי לומר איך:

// IRepository<T> — Generic, בדיוק כמו ביחידת ה-Generics
public interface IRepository<T>
{
    List<T> GetAll();
    T GetById(int id);
    void Add(T item);
    void Update(T item);
    void Delete(int id);
}

ה-<T> הוא Generic — זוכרים מיחידת ה-Generics? במקום לכתוב IRepository נפרד לכל סוג נתון (IStudentRepository, IProductRepository…), כותבים אותו פעם אחת עם פרמטר-טיפוס T, וכל אחד ממלא אותו לפי הצורך: IRepository<Student> הוא הבטחה ל-CRUD על תלמידים, IRepository<Product> — על מוצרים, עם אותו קוד Interface בדיוק.

שלב 2: המימוש הראשון — בדיוק מה שהיה קודם, רק מועבר החוצה

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) { /* עדכון בתוך הרשימה */ }
    public void Delete(int id) => _students.RemoveAll(s => s.Id == id);
}

שימו לב לשלוש נקודות:

  • : IRepository<Student> — המחלקה הזו מממשת את ההבטחה מהשלב הקודם, בדיוק כמו ש-EmailNotifier מימש : INotifier.
  • _students כאן הוא שדה רגיל, לא static — כי עכשיו הוא לא חי בתוך Controller שנוצר מחדש בכל בקשה. בהמשך השיעור נראה שה-Repository עצמו יירשם כ-Scoped/Singleton ב-DI, ואז ה-Container הוא זה ש"שומר אותו בחיים" בין הקריאות — לא הפתרון המאולתר של static.
  • FirstOrDefault — מיחידת ה-LINQ: מחפש ברשימה לפי התנאי בסוגריים, ומחזיר את האיבר הראשון שמתאים, או null אם אף אחד לא מתאים.

חוץ מהעטיפה, זה בדיוק מה שהיה קודם בתוך ה-Controller — רק עבר למחלקה נפרדת, שמיישמת את ה-Interface מהשלב הקודם.

שלב 3: חיבור ה-Controller ל-Repository דרך DI

בדיוק כמו שבשיעור הקודם OrdersController קיבל INotifier בקונסטרוקטור, עכשיו StudentsController מקבל IRepository<Student>:

public class StudentsController : ControllerBase
{
    private readonly IRepository<Student> _repository;
    public StudentsController(IRepository<Student> repository) => _repository = repository;

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

(public StudentsController(IRepository<Student> repository) => _repository = repository; הוא בדיוק אותו קונסטרוקטור כמו בשיעור הקודם, רק כתוב בתחביר מקוצר — => במקום { _repository = repository; } — שני הכתיבים זהים לגמרי בפועל.)

וברישום ב-Program.cs, בדיוק אותה שורה מהמנגנון שכבר מכירים:

// Program.cs
builder.Services.AddScoped<IRepository<Student>, InMemoryStudentRepository>();

“כשמישהו מבקש IRepository<Student>, תן לו InMemoryStudentRepository.” ה-Controller כבר לא יודע אם הנתונים ב-List, ב-SQL, או בכל מקום אחר — הוא רק יודע שיש לו IRepository<Student> שיודע לבצע CRUD.

שלב 4: מתי Repository לבד לא מספיק

מה קורה אם צריך כלל כמו “גיל מינימלי 16 להרשמה”? זה לא שייך ל-Controller (זו לא לוגיקת HTTP), וגם לא ל-Repository (זו לא גישה-לנתונים — Repository לא אמור “לדעת” כללים עסקיים). לכלל הזה אין כרגע בית, אז מוסיפים שכבה שלישית באמצע:

public class StudentService
{
    private readonly IRepository<Student> _repository;   // תלות בהבטחה, לא במימוש — בדיוק כמו בשיעור הקודם

    public StudentService(IRepository<Student> repository) => _repository = repository;

    public Student RegisterStudent(string name, int age)
    {
        if (age < 16) throw new ArgumentException("Minimum registration age is 16");   // הכלל העסקי חי כאן
        var student = new Student(name, age);
        _repository.Add(student);
        return student;
    }
}

StudentService היא מחלקה רגילה שאנחנו כותבים, לא Controller ולא Repository. היא מקבלת IRepository<Student> בקונסטרוקטור — בדיוק אותו Constructor Injection — ומוסיפה עליו את הכלל העסקי לפני שהיא בכלל פונה ל-Repository.

שלב 5: ה-Controller הופך לדק — HTTP בלבד

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

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

ה-Controller כבר לא מקבל IRepository<Student> בכלל — הוא מקבל StudentService, וזה כל מה שהוא צריך לדעת. הוא לא נוגע בכלל הגיל המינימלי, ולא נוגע ב-Repository — הוא רק מתרגם בין HTTP (Create, גוף הבקשה) לקריאה למתודה RegisterStudent.

לרישום ב-Program.cs מצטרפת שורה נוספת, בשביל השכבה החדשה:

// Program.cs — רושמים את כל שלוש השכבות
builder.Services.AddScoped<IRepository<Student>, InMemoryStudentRepository>();
builder.Services.AddScoped<StudentService>();

השורה השנייה רושמת את StudentService עצמה (בלי Interface — כי אין כרגע צורך להחליף מימוש ל-Service, רק ל-Repository). ASP.NET יודע לבנות אותה: כשהוא רואה שה-StudentService מבקש IRepository<Student> בקונסטרוקטור שלה, הוא כבר יודע (מהשורה הראשונה) מה להעביר לה.

התוצאה: שלוש שכבות ברורות, כל אחת מוזרקת לזו שמעליה:

Client
  │  POST /api/students
  ▼
StudentsController   (HTTP בלבד: קורא ל-body, מחזיר Status Code)
  ▼
StudentService       (לוגיקה עסקית: בדיקת גיל מינימלי)
  ▼
IRepository<Student>  (גישה לנתונים: הוספה, קריאה, עדכון, מחיקה)

הסבר עיקרי

שלוש השכבות, שלוש אחריות (SRP במימוש ארכיטקטוני) — Controller יודע רק HTTP. Service יודע רק לוגיקה עסקית — הוא לא יודע אם הנתונים ב-List, SQL, או MongoDB. Repository יודע רק גישה-לנתונים — הוא לא יודע כלום על HTTP או על כללים עסקיים. כל שכבה יכולה להשתנות בנפרד בלי לגעת באחרות.

למה זה מכין בדיוק ליחידה הבאה — כש-InMemoryStudentRepository יוחלף ב-SqlStudentRepository (עם EF Core, ביחידה הבאה), שניהם ימשיכו לממש את אותו IRepository<Student> — StudentService ו-StudentsController לא ישתנו בכלל. שינוי אחד, בשכבה אחת, בזכות ה-Interface.

יתרונות

הפרדת שכבות מאפשרת להחליף מימוש נתונים (InMemory→SQL) בלי לגעת בלוגיקה עסקית או ב-HTTP; כל שכבה נבדקת (Unit Testing) בנפרד — אפשר לבדוק StudentService עם IRepository מדומה, בלי מסד נתונים אמיתי; מבנה ברור ועקבי בין כל ה-Controllers בפרויקט.

חסרונות

לפרויקט קטן מאוד (CRUD פשוט בלי לוגיקה עסקית) שלוש שכבות עלולות להרגיש כמו overhead מיותר; יותר קבצים ומחלקות לתחזק, גם כשההיגיון הפנימי פשוט יחסית.

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

• Controller = HTTP בלבד; Service = לוגיקה עסקית; Repository = גישה לנתונים

• IRepository<T> הוא ה-Interface שמאפשר להחליף InMemory ב-SQL בלי לגעת בשכבות מעליו

• Service Layer מחזיק לוגיקה עסקית שלא שייכת ל-HTTP ולא לגישה-ישירה-לנתונים

• זהו יישום ארכיטקטוני מלא של DIP — כל שכבה תלויה בהפשטה של השכבה שמתחתיה

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

• לשים לוגיקה עסקית (כמו בדיקת גיל מינימלי) ישירות ב-Controller במקום ב-Service

• לגשת ל-Repository ישירות מה-Controller, מדלגים על ה-Service Layer — מאבד את מקום הריכוז ללוגיקה עסקית

• ליצור new InMemoryStudentRepository() בתוך StudentService במקום להזריק דרך IRepository<T> — חוזר אחורה מ-DI/DIP, ומאבד את היכולת להחליף מימוש בלי לגעת ב-StudentService

סיכום

בנינו את תבנית ה-DAL שלב אחר שלב: הוצאת הגישה לנתונים ל-IRepository<T>, מימוש InMemory שהוא בדיוק מה שהיה קודם בתוך ה-Controller, חיבור דרך DI, הוספת StudentService ללוגיקה העסקית, וסיום עם Controller דק שיודע רק HTTP. התוצאה: Controller (HTTP בלבד) → Service (לוגיקה עסקית) → Repository (גישה לנתונים), כל שכבה לא יודעת על פרטי המימוש של מה שמתחתיה. מעבר ממימוש InMemory למסד נתונים אמיתי (ביחידה הבאה) יהיה שינוי מבודד לגמרי בשכבת ה-Repository בלבד.

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

Microsoft Learn — Repository pattern


תרגילים

תרגיל 1 — IRepository ומימוש InMemory

המשימה: צרו IRepository<Product> עם CRUD בסיסי, ומימוש InMemoryProductRepository. השתמשו בו ישירות (עדיין בלי Service Layer) מ-Controller קיים, דרך DI.

בדיקה: כל פעולות ה-CRUD עובדות דרך ה-Repository, לא דרך List ישירות בתוך ה-Controller.

תרגיל 2 — הוספת Service Layer

המשימה: הוסיפו ProductService בין ה-Controller ל-Repository מתרגיל 1, עם כלל עסקי אחד (למשל: מוצר עם Price <= 0 נדחה עם ArgumentException).

בדיקה: ה-Controller קורא רק ל-ProductService, לא ל-Repository ישירות; ניסיון להוסיף מוצר עם מחיר לא-חוקי נדחה בשכבת ה-Service.


פרויקט מסכם

המשימה: ארגנו מחדש את כל TasksController (מהפרויקטים המסכמים הקודמים) לפי תבנית DAL מלאה.

דרישות:

  1. IRepository<TaskItem> עם CRUD מלא, ומימוש InMemoryTaskRepository
  2. TaskService עם לפחות כלל עסקי אחד (למשל: לא ניתן ליצור משימה עם Title ריק, גם אם עברה DataAnnotations — בדיקה נוספת ברמה עסקית)
  3. TasksController מוזרק עם TaskService בלבד — לא נוגע ב-IRepository ישירות בכלל

בדיקה: כל זרימת ה-CRUD הקיימת ממשיכה לעבוד זהה מבחוץ (אותם endpoints, אותם Status Codes); בדיקה שה-Controller לא מכיל אף שורת קוד שנוגעת ישירות ב-IRepository.

מה בפרק הבא

בפרק הבא נעבור ליחידה חדשה — SQL ו-EF Core: נחליף את InMemoryStudentRepository במסד נתונים אמיתי שרץ בקונטיינר Docker (בדיוק כמו שלמדנו ביחידת ה-Docker), בלי לגעת ב-StudentService או ב-StudentsController.