תבנית Repository: מה אנחנו באמת מפרידים מהקוד?
במאמר הקודם עסקנו ב־Unit of Work ובשאלה מי מחליט מתי שומרים את השינויים למסד הנתונים. השתמשנו שם גם ב־Repositories, אבל לא התעכבנו עליהם.
אז הפעם נתעכב.
כשמסתכלים על דוגמאות של Repository, קל לקבל את הרושם שמדובר במחלקה עם כמה מתודות מוכרות:
GetByIdAsync(...) GetAllAsync(...) Add(...) Update(...) Delete(...)
לכאורה, די פשוט. לוקחים את הפעולות שמבצעים מול מסד הנתונים, מעבירים אותן למחלקה אחרת וקוראים לה Repository.
אבל אם כל מה שעשינו הוא להעביר קוד מקובץ אחד לאחר, מה בעצם הרווחנו?
נתחיל בלי Repository
יש לנו מערכת הזמנות, ואנחנו רוצים לאפשר ביטול של הזמנה.
לצורך הדוגמה, הזמנה שכבר נשלחה אינה ניתנת לביטול. את הכלל הזה שמנו בתוך האובייקט עצמו:
public void Cancel()
{
if (Status == OrderStatus.Shipped)
{
throw new InvalidOperationException(
"A shipped order cannot be cancelled.");
}
Status = OrderStatus.Cancelled;
}
כדי להפעיל את הפעולה, צריך קודם להביא את ההזמנה:
public async Task CancelOrderAsync(
Guid orderId,
CancellationToken cancellationToken = default)
{
var order = await _db.Orders.SingleOrDefaultAsync(
order => order.Id == orderId,
cancellationToken);
if (order is null)
{
throw new KeyNotFoundException("Order not found.");
}
order.Cancel();
await _db.SaveChangesAsync(cancellationToken);
}
אין כאן בהכרח בעיה. זו מתודה קצרה, והחלוקה בתוכה ברורה: טעינה, הפעלת פעולה עסקית ושמירה.
לא צריך למהר לתקן קוד רק מפני שאין בו מספיק שכבות.
אבל כרגע השירות שמנהל את הביטול מכיר גם את הדרך שבה טוענים הזמנה באמצעות EF Core. הוא ניגש ל־DbSet, בונה שאילתת LINQ ומחליט איך לבצע אותה.
במערכת קטנה זה יכול להתאים. במערכת אחרת נרצה שהשירות יכיר רק פעולה בשם „הבא לי הזמנה”, בלי להכיר את מימוש הגישה לנתונים.
זה המקום שבו Repository יכול להיכנס.
אוסף של אובייקטים, לא אוסף של פקודות SQL
הרעיון של Repository הוא לספק לקוד דרך לעבוד עם אובייקטים מהמערכת, בזמן שפרטי האחסון והשליפה מרוכזים מאחורי הממשק שלו.
מבחינת הקוד שקורא לו, יש מקום שממנו מקבלים הזמנות ושאליו מוסיפים הזמנות. לא צריך לדעת בכל קריאה אילו טבלאות משתתפות בשליפה או איך מתרגמים אותה לפנייה למסד הנתונים.
אפשר להתחיל מממשק קטן:
public interface IOrderRepository
{
Task<Order?> GetByIdAsync(
Guid id,
CancellationToken cancellationToken = default);
void Add(Order order);
}
לא הכנסתי כאן את כל הפעולות שאולי נצטרך אי פעם. אם אין כרגע צורך במחיקה, אין סיבה להוסיף Delete רק מפני שכך נראית הדוגמה הרגילה של התבנית.
המימוש שלנו משתמש ב־EF Core:
public sealed class EfOrderRepository : IOrderRepository
{
private readonly AppDbContext _db;
public EfOrderRepository(AppDbContext db)
{
_db = db;
}
public Task<Order?> GetByIdAsync(
Guid id,
CancellationToken cancellationToken = default)
{
return _db.Orders.SingleOrDefaultAsync(
order => order.Id == id,
cancellationToken);
}
public void Add(Order order)
{
_db.Orders.Add(order);
}
}
המתודה Add כאן אינה שומרת למסד הנתונים. היא מוסיפה את ההזמנה למעקב של ה־DbContext, כדי שתישמר בהמשך.
גם ההזמנה שמוחזרת מ־GetByIdAsync נטענת במעקב, בהנחה שלא שינינו את ברירת המחדל של המעקב בהקשר הזה. זה חלק מההתנהגות שעליה המימוש שלנו נשען.
הפרט הזה חשוב. ממשק שמסתיר את EF Core לא פוטר אותנו מהצורך להגדיר מה אפשר לעשות עם האובייקט שהתקבל ומתי השינויים בו יישמרו.
ומי שומר?
נחזור לפעולת הביטול, הפעם באמצעות Repository ו־Unit of Work:
public sealed class OrderService
{
private readonly IOrderRepository _orders;
private readonly IUnitOfWork _unitOfWork;
public OrderService(
IOrderRepository orders,
IUnitOfWork unitOfWork)
{
_orders = orders;
_unitOfWork = unitOfWork;
}
public async Task CancelOrderAsync(
Guid orderId,
CancellationToken cancellationToken = default)
{
var order = await _orders.GetByIdAsync(
orderId,
cancellationToken);
if (order is null)
{
throw new KeyNotFoundException("Order not found.");
}
order.Cancel();
await _unitOfWork.SaveChangesAsync(cancellationToken);
}
}
כאן ה־Repository אחראי להביא את ההזמנה. האובייקט Order אחראי לכלל שאוסר לבטל הזמנה שנשלחה. השירות מחבר את השלבים, ויחידת העבודה מתאמת את השמירה.
בדוגמה הזאת, המימושים של ה־Repository ושל יחידת העבודה חייבים להשתמש באותו מופע של AppDbContext במסגרת הפעולה. אחרת, אנחנו עלולים לטעון ולשנות הזמנה בהקשר אחד ולנסות לשמור הקשר אחר, שלא יודע עליה דבר.
אין כאן צורך בקריאה ל־Update אחרי הביטול, מפני שההזמנה כבר נמצאת במעקב של ההקשר שבו משתמשים לשמירה.
זו גם הסיבה שלא הכנסתי SaveChangesAsync לתוך כל מתודה של ה־Repository. ייתכן שביטול ההזמנה יכלול בהמשך שינוי נוסף שצריך להישמר יחד איתה. ה־Repository של ההזמנות לא אמור להחליט בעצמו שכל הפעולה העסקית הסתיימה.
הדוגמאות מניחות שהישויות, המיפויים והרישום להזרקת התלויות כבר קיימים. הן מדגימות את חלוקת האחריות, לא מימוש מלא של מערכת הזמנות.
המקום שבו ההפרדה מתחילה להיות שימושית
בדוגמה הקודמת המימוש עדיין דומה מאוד לקוד שהיה לנו קודם. רק הזזנו את השאילתה.
ההבדל נעשה מעניין יותר כשהטעינה דורשת ידע שלא רוצים לפזר בין כל הקוראים.
למשל, פעולה עסקית עשויה להזדקק להזמנה יחד עם הפריטים שלה:
public Task<Order?> GetWithItemsAsync(
Guid id,
CancellationToken cancellationToken = default)
{
return _db.Orders
.Include(order => order.Items)
.SingleOrDefaultAsync(
order => order.Id == id,
cancellationToken);
}
מי שקורא למתודה הזאת יודע שהוא מקבל הזמנה עם פריטים. הוא לא צריך לזכור להוסיף Include בעצמו.
אין הכרח לטעון פריטים בכל שליפה של הזמנה. לפעמים זו עבודה מיותרת. צריך לבחור את המתודות לפי צורכי המערכת, ולא להעמיס את כל הקשרים האפשריים על GetByIdAsync אחת.
מצד שני, גם לא הייתי יוצר אוטומטית Repository נפרד לכל טבלה.
אם פריט הזמנה הוא חלק מההזמנה, והכללים העסקיים מחייבים לשנות אותו דרכה, חשיפה של OrderItemRepository עצמאי עלולה לאפשר לעקוף את הכללים האלה. בתכנון מבוסס דומיין, ההזמנה יכולה להיות שורש של Aggregate: קבוצת אובייקטים שמנוהלת כיחידה אחת מבחינת הכללים והעקביות שלה.
במקרה כזה, גבול ה־Repository נקבע לפי המודל העסקי, לא לפי מספר הטבלאות במסד הנתונים.
הפיתוי של Repository גנרי
אחרי שכותבים שניים או שלושה Repositories, כמעט מתבקש לחלץ את המשותף:
public interface IRepository<T>
{
Task<T?> GetByIdAsync(Guid id);
Task<IReadOnlyList<T>> GetAllAsync();
void Add(T entity);
void Update(T entity);
void Delete(T entity);
}
אפשר להשתמש בממשק כזה, והוא עשוי לחסוך קוד חוזר. אבל כדאי לשים לב למה הוא אומר על המערכת.
הוא מניח שלכל ישות יש מזהה מסוג Guid. הוא מציע לכולן מחיקה. והוא כולל פעולה שמחזירה את כל הרשומות, בלי מגבלה או חלוקה לעמודים.
אלה כבר החלטות, לא רק קיצור כתיבה.
עם הזמן מגיעות גם הדרישות שלא נכנסות לממשק: טעינת קשרים, מיון, סינון, עימוד, בחירה אם לעקוב אחרי התוצאות והחזרת שדות מסוימים בלבד. אפשר להוסיף עוד פרמטר ועוד מתודה, עד שהממשק מתחיל להיראות כמו גרסה נוספת של כלי הגישה לנתונים שכבר היה לנו.
אני לא חושב שגנריות היא הבעיה. השאלה היא אם מצאנו התנהגות משותפת שבאמת קיימת במערכת, או ניסינו להכניס צרכים שונים לאותה צורה.
הגבול יוצא החוצה עם IQueryable
דרך נוחה לכאורה להתמודד עם כל דרישות הסינון היא להחזיר שאילתה פתוחה:
public IQueryable<Order> Query()
{
return _db.Orders;
}
ואז הקוד שקורא ל־Repository ממשיך אותה:
var orders = await _orders.Query()
.Where(order => order.CustomerId == customerId)
.OrderByDescending(order => order.CreatedAt)
.Take(20)
.ToListAsync(cancellationToken);
זה גמיש מאוד. אבל עכשיו הקוד החיצוני שוב בונה את השאילתה, והביצוע שלה עדיין תלוי בספק השאילתות ובהקשר שנותר פתוח. בדוגמה הזאת הוא גם משתמש במתודת הרחבה של EF Core.
כלומר, קיבלנו נקודת גישה מרכזית, אבל לא הפרדה מלאה ממנגנון השאילתות.
זו יכולה להיות בחירה מכוונת. רק צריך לדעת שזה החוזה שבחרנו, ולא להניח שהקוד כבר מנותק מ־EF Core מפני שבאמצע מופיע ממשק בשם Repository.
אם מטרת ההפרדה היא שהקורא יבקש נתונים בלי להרכיב את שאילתת מסד הנתונים, אפשר לחשוף פעולה מוגדרת יותר:
Task<IReadOnlyList<Order>> GetRecentForCustomerAsync(
Guid customerId,
int limit,
CancellationToken cancellationToken = default);
המימוש אחראי לסינון, לסדר ולביצוע השאילתה. צריך גם להגדיר ולבדוק מהו ערך תקין של limit.
ולפעמים מסך שמציג רשימת הזמנות בכלל לא זקוק לאובייקטי Order מלאים. הוא צריך רק מספר הזמנה, תאריך וסכום. במקרה כזה אפשר לכתוב שירות שאילתות שמחזיר מודל תצוגה מצומצם. אין חובה להעביר כל קריאה במערכת דרך אותו Repository.
מה מרוויחים בבדיקות, ומה לא
אחת התועלות של ממשק כמו IOrderRepository היא שאפשר לבדוק את השירות בלי להפעיל מסד נתונים.
בבדיקה של ביטול הזמנה, למשל, אפשר לספק מימוש בדיקה שמחזיר הזמנה שכבר נשלחה ולוודא שהפעולה נדחית. אפשר גם להחזיר null ולבדוק את הטיפול בהזמנה שאינה קיימת.
אבל בדיקה כזאת לא בודקת את השאילתה שב־EfOrderRepository.
היא לא תוכיח שה־Include נכון, שהמיפוי למסד הנתונים תקין או ששאילתת LINQ מסוימת מתורגמת כפי שציפינו. לשם כך עדיין נדרשות בדיקות אינטגרציה מול מסד נתונים מהסוג שבו המערכת משתמשת.
ההפרדה מאפשרת לבדוק חלקים שונים בנפרד. היא לא הופכת בדיקה של תחליף בזיכרון לבדיקה של מסד הנתונים.
לא חייבים להוסיף את השכבה
בעבודה עם EF Core אנחנו כבר מקבלים לא מעט מהיכולות שהתבניות האלה עוסקות בהן: גישה לאוספי ישויות דרך DbSet, מעקב אחרי שינויים ותיאום שמירה דרך DbContext.
לכן, שימוש ישיר ב־DbContext בתוך שירות אפליקטיבי אינו מעיד בפני עצמו על תכנון לקוי. במערכת פשוטה הוא עשוי להיות הבחירה הברורה והנוחה יותר.
גם הטיעון „ככה נוכל להחליף מסד נתונים בקלות” דורש זהירות. Repository יכול לצמצם את המקומות שצריך לשנות, אבל הוא לא מעלים הבדלים בטרנזקציות, בביצועים, באילוצים או בצורת המידע. החלפה של מנגנון אחסון היא לא בהכרח החלפה של מחלקה אחת.
אני הייתי מתחיל מהקוד שיש במערכת: האם פרטי הטעינה חוזרים בכמה מקומות? האם שירותים צריכים להכיר יותר מדי פרטים על הגישה לנתונים? האם יש גבול ברור שהממשק יעזור לבטא?
אם כן, יש ל־Repository תפקיד מוגדר.
אם לא, אפשר להשאיר את השאילתה במקום שבו היא ברורה. את המחלקה הנוספת תמיד אפשר להוסיף כשיהיה לה מה לעשות.
להמשך קריאה
הכסף ירד מחשבון אחד. מה עם החשבון השני? להכיר את Unit of Work
להכיר את ABP דרך פעולה אחת: ביטול הזמנה
אני ארנן יהודה, מפתח Full Stack המתמקד בעיקר ב-Backend בסביבת .NET, ומנהל קהילת המפתחים C#mmunity. אפשר למצוא אותי בלינקדאין ולעקוב אחרי עמוד הקהילה.