תבנית DAL — IRepository ו-Service Layer
מה זה?
בשיעור הקודם למדנו איך 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 מלאה.
דרישות:
IRepository<TaskItem>עם CRUD מלא, ומימושInMemoryTaskRepositoryTaskServiceעם לפחות כלל עסקי אחד (למשל: לא ניתן ליצור משימה עםTitleריק, גם אם עברה DataAnnotations — בדיקה נוספת ברמה עסקית)TasksControllerמוזרק עםTaskServiceבלבד — לא נוגע ב-IRepositoryישירות בכלל
בדיקה: כל זרימת ה-CRUD הקיימת ממשיכה לעבוד זהה מבחוץ (אותם endpoints, אותם Status Codes); בדיקה שה-Controller לא מכיל אף שורת קוד שנוגעת ישירות ב-IRepository.
מה בפרק הבא
בפרק הבא נעבור ליחידה חדשה — SQL ו-EF Core: נחליף את InMemoryStudentRepository במסד נתונים אמיתי שרץ בקונטיינר Docker (בדיוק כמו שלמדנו ביחידת ה-Docker), בלי לגעת ב-StudentService או ב-StudentsController.