בפוסטים הקודמים סקרנו את עקרונות הארכיטקטורה הנקייה (Clean Architecture) והשוונו אותה לגישות אחרות, הפעם נצלול לצד הפרקטי. נדבר בגובה העיניים על אחד המרכיבים הקריטיים ביישום Clean Architecture – Dependency Injection (הזרקת תלויות). נבין למה זה כל כך חשוב, ונראה דוגמאות קוד (Node.js + TypeScript עם Express).
רענון קצר: מהי ארכיטקטורה נקייה ולמה זה חשוב?
ארכיטקטורה נקייה היא דרך לארגן את הקוד כך שהלוגיקה העסקית שלנו תהיה מבודדת משכבות טכניות. אנו מחלקים את הפרויקט לשכבות ברורות:
- Entities (יישויות) – מודלים עסקיים בסיסיים (למשל: משתמש, משימה, הזמנה) בלי תלות במסד נתונים מסוג ספציפי או ספריות.
- Use Cases (מקרי שימוש) – הלוגיקה העסקית עצמה, המימוש של מה שהמערכת עושה (למשל: "רישום משתמש", "יצירת הזמנה חדשה"). שכבה זו מגדירה ממשקים שאחרים יממשו. שימו לב שחלק גורסים שכל מקרה שימוש צריך להיות בקובץ משלו, וחלק אחר גורס , ולדעתי בצדק, שהחלוקה צריכה להיות לפי הלוגיקה העסקית עצמה.
- Interface Adapters (מתאמים) – שכבה שמגשרת בין הליבה העסקית לבין העולם החיצון. כאן נמצאים ממירי נתונים, Controllers, Gateways וכדומה, שמתרגמים נתונים מהמשתמש או ממקום אחר אל הלוגיקה העסקית ולהיפך.
- Frameworks & Drivers (תשתיות) – השכבה החיצונית שכוללת מסדי נתונים, שרתי Web (כמו Express), ספריות צד ג' וכו'.
כלל הזהב של Clean Architecture הוא "כלל התלות": תלויות נעות פנימה – קוד בליבה העסקית לא יודע שום דבר על מה שמחוץ לו. המשמעות היא ששכבת ה-Use Cases אוסרת על עצמה להתייחס ישירות למסד נתונים, HTTP או כל פרט מסגרת. איך עושים זאת בפועל? בעזרת הגדרת ממשקים מופשטים בליבה, ומימוש שלהם בתשתיות. כאן בדיוק נכנס הנושא של Dependency Injection.
למה Dependency Injection קריטי ב-Clean Architecture?
Dependency Injection (הזרקת תלויות) הוא דפוס עיצוב שעוזר לנו להפוך את העיקרון התיאורטי של Clean Architecture למשהו מעשי. הרעיון פשוט: במקום שclass ייצור בעצמו את התלויות שלו (נגיד לפתוח חיבור DB, ליצור אובייקט שירות וכדומה), אנחנו מעבירים לו את התלויות מבחוץ. כך הclass תלוי בממשק מופשט ולא במימוש ספציפי.
במילים אחרות, הזרקת תלויות מאפשרת לנו לעמוד בכלל התלות: ה-Use Case תלוי בממשק (אבסטרקטי) שהוגדר בליבה, ולא במימוש הקונקרטי שנמצא בשכבת התשתית. זה מגלם את עקרון ה-DIP (Dependency Inversion Principle) מתוך SOLID – תלות בהפשטות במקום במימושים.
למה זה כל כך חשוב? כי בלי DI, קל מאוד "לערבב שכבות" בלי לשים לב או מתוך הרגל מגונה. למשל, אם פונקציה בלוגיקה העסקית תפתח ישירות חיבור למסד נתונים או תקרא לשירות חיצוני, שברנו את ההפרדה: הלוגיקה העסקית כעת תלויה בפרט טכני והתוצאה היא קוד שקשה לבדוק (תארו לכם שכל טסט יחויב לתקשר עם DB אמיתי), וקשה לתחזק (החלפת מסד נתונים תדרוש שינוי בקוד הליבה). באמצעות DI, אנחנו דואגים שהלוגיקה העסקית תהיה Plug-and-Play – אפשר לחבר לה כל מימוש שנרצה מבחוץ, מבלי לשנות את הקוד של הליבה. זה מקל על בדיקות Unit (אפשר להזרים תלות "דמה" למטרת טסט), ומכין את הקרקע לגמישות מירבית – החלפת ספריית DB, שינוי ספק שירות, או הרצה בסביבה שונה – כל אלו קורים בשכבה החיצונית בלי להשפיע על הקוד העסקי.
איך מיישמים Dependency Injection בפועל?
נשתמש בדוגמה של ניהול רשימת משימות (To-Do) בשביל הפשטות. נניח שיש לנו אפליקציה שמנהלת משימות, ואנחנו רוצים להפריד בין הלוגיקה העסקית לניהול משימות לבין פרטי המימוש של מסד הנתונים ופרטי ה-API.
1. הגדרת ממשקים בליבת המערכת
ראשית, בליבה העסקית (Use Cases/Entities) נגדיר ממשק שמתאר את מה שאנחנו צריכים מהתשתית. במקרה שלנו, ה-Use Case רוצה לקרוא את כל המשימות. לכן נגדיר ממשק לרפוזיטורי של משימות, בלי לממש אותו עדיין (רק החתימה):
// בקובץ בשכבת ה-Use Cases (למשל src/domain/todoRepository.ts)
interface ITodoRepository {
getAll(): Promise<Todo[]>;
}
// יישות עסקית בסיסית (Entity)
class Todo {
constructor(public id: string, public title: string,
public completed: boolean) {}
}
// Use Case: לוגיקה עסקית לקבלת משימות לא-גמורות
class TodoService {
// מקבל רפוזיטורי דרך ה-Constructor (תלות מוזרקת)
constructor(private todoRepo: ITodoRepository) {}
async getIncompleteTodos(): Promise<Todo[]> {
const todos = await this.todoRepo.getAll();
// לוגיקה עסקית: סינון משימות שהושלמו
return todos.filter(todo => !todo.completed);
// אם יש בריפו אפשרות לשלוף את זה כבר מפולטר כמובן שעדיף
}
}
שימו לב לכמה דברים חשובים בקוד למעלה: המחלקה TodoService
לא מייצרת בעצמה את הרפוזיטורי או ניגשת ישירות למסד נתונים. היא מצפה לקבל אובייקט שעומד בממשק ITodoRepository
. מבחינת TodoService
, לא אכפת אם המידע מגיע מ-MongoDB, מקובץ, או מזיכרון – כל עוד זה מממש את הממשק ומספק את הנתונים. כך הגדרנו את התלות באופן מופשט.
2. מימוש התלויות בשכבת התשתית
כעת, בשכבה החיצונית (תשתיות), נממש את הממשק הזה בפועל. נניח שאנחנו משתמשים בבסיס נתונים MongoDB כדי לאחסן את המשימות. נגדיר מחלקה שמממשת את ITodoRepository
עם גישה ל-MongoDB:
// בקובץ בשכבת התשתיות (למשל src/infrastructure/mongoTodoRepository.ts)
class MongoTodoRepository implements ITodoRepository {
async getAll(): Promise<Todo[]> {
const collection = mongo.db("app").collection("todos");
return collection.find({}).toArray();
}
}
הקלאס הזה יודע לעבוד מול MongoDB ולהחזיר את רשימת המשימות. העניין המהותי: המחלקה הזאת מממשת את הממשק ITodoRepository
. כלומר, מהצד של הלוגיקה העסקית, היא "מתחזה" לסוג הנתון שה-Use Case יודע לעבוד איתו. כעת נוכל לחבר בין ה-Use Case למימוש הזה.
3. חיווט התלויות (Composition Root) בחלק העליון של האפליקציה
השלב האחרון הוא הרכבה – לחבר את הכל יחד בנקודה אחת בעת אתחול התוכנית. בדרך כלל, זה קורה בקובץ ההפעלה הראשי של האפליקציה (למשל index.ts
או בקובץ הגדרת השרת) , בנקודת כניסה או בנקודה מרכזית. שם אנחנו ניצור מופעים של המימושים הקונקרטיים, ונזריק אותם למופעים של הלוגיקה העסקית. אם נמשיך בדוגמה שלנו:
// בקובץ הראשי של השרת (למשל src/app.ts)
import express from "express";
import { MongoTodoRepository } from "./infrastructure/mongoTodoRepository";
import { TodoService } from "./domain/todoService";
const app = express();
// יצירת מופעים של המימושים הקונקרטיים
const todoRepository = new MongoTodoRepository();
const todoService = new TodoService(todoRepository);
// הגדרת ראוטים ב-Express ושימוש ב-TodoService
app.get("/todos", async (req, res) => {
try {
const todos = await todoService.getIncompleteTodos();
res.json(todos);
} catch (err) {
res.status(500).send(err.message);
}
});
app.listen(3000, () => {
console.log("Server running on port 3000");
});
כאן אנחנו יוצרים MongoTodoRepository
(שכבת תשתית) ומזריקים אותו ל-TodoService
(שכבת Use Case). ה-TodoService
מוכן ומזומן לשימוש, מבלי לדעת בכלל מאיפה הגיעו הנתונים. בראוטר של Express, אנחנו יכולים לזמן את todoService.getIncompleteTodos()
כדי לקבל את המשימות ולהחזיר אותן כ-JSON. שימו לב איך הראוט לא יוצר שום אובייקט של DB או נוגע בפרטי המימוש – הכול הוזרק מראש.
4. אופציונלי: שימוש במכולת תלויות (Dependency Injection Container)
בפרויקטים קטנים, יצירת אובייקטים ידנית והזרקה שלהם כמו שהראינו זה מספיק. אבל ככל שהפרויקט גדל, וכמות התלויות גדלה (לדוגמה, Use Case עם 3-4 שירותים שונים שתלויים בו), נרצה לנהל זאת בצורה אוטומטית ומסודרת יותר. כאן נכנסות לתמונה ספריות DI למיניהן (מה שנקרא Container או "מכולת תלויות").
ספריות כמו InversifyJS, tsyringe או Typedi מאפשרות לנו לרשום את המימושים שלנו בקונטיינר מרכזי, והן ידאגו ליצור ולהזריק אותם אוטומטית לפי צורך. לדוגמה, עם tsyringe
(ספריית DI פופולרית), היינו יכולים לעשות משהו כזה:
import "reflect-metadata";
import { container } from "tsyringe";
import { TodoService } from "./domain/todoService";
import { MongoTodoRepository } from "./infrastructure/mongoTodoRepository";
// רישום התלות בקונטיינר: כשמבקשים ITodoRepository, תן מופע של MongoTodoRepository
container.register<ITodoRepository>("ITodoRepository", { useClass: MongoTodoRepository });
// קבלת מופע מוכן של TodoService עם כל התלויות מוזרקות אוטומטית
const todoService = container.resolve(TodoService);
בספריה זו, נשתמש בדקורטורים של TypeScript כדי לסמן את המחלקות הניתנות להזרקה (@injectable
) ואת התלויות שהן צריכות (@inject("ITodoRepository")
בתוך הבנאי). לא נעמיק כאן לכל הפרטים – העיקר הוא להבין שיש כלים שעוזרים לנהל את ההזרקה בצורה אוטומטית, במיוחד בפרויקטים גדולים, וזה ממש נוח להשתמש בהם , וגם שומר על הסדר.. 🙂 מסגרות עבודה כמו NestJS למשל, מובנות כולה סביב מנגנון DI מובנה שמפשט מאוד את כל התהליך (אבל זה כבר נושא לפוסט נפרד 😉).
יתרונות הגישה (ולמה זה שווה את ההשקעה)
ראינו איך מיישמים Dependency Injection, אבל מה יוצא לנו מכל זה? כמה יתרונות בולטים:
- מודולריות והחלפה קלה של רכיבים – כיוון שהלוגיקה העסקית תלויה רק בממשקים, אפשר לממש אותם במספר דרכים. היום אתם עובדים עם MongoDB? מחר רוצים לעבור ל-PostgreSQL או אפילו לשירות חיצוני? אין בעיה – מוסיפים מימוש חדש של
ITodoRepository
והלוגיקה לא צריכה להשתנות בכלל. אותו דבר לגבי כל שירות חיצוני (API, תשלום, הודעות וכדומה). - בדיקות יחידה קלות – זו אולי המתנה הגדולה ביותר. באמצעות DI אפשר להזרים ל-Use Case מימוש דמה (Mock או Stub) של התלות במקום המימוש האמיתי. למשל, בשביל לבדוק את
TodoService
שלנו, נוכל ליצור מחלקה שמממשתITodoRepository
אבל מחזירה נתונים מזויפים מתוך מערך בזיכרון. כך נבדוק את הלוגיקה (סינון completed וכו') בלי צורך במסד נתונים אמיתי או תלות בסביבה חיצונית. הבדיקות רצות מהר יותר והן אמינות כי הן ממוקדות רק בלוגיקה. - קוד קריא ותחזוקתי – כשמקפידים על הזרקת תלויות, מבנה הקוד נעשה ברור יותר. כשרואים בנאי של מחלקה שמקבל את כל התלויות שלו מבחוץ, מיד ברור לנו מה תלוי במה. זה מאלץ אותנו גם לחשוב על הפרדת אחריות: אם יש מחלקה שמקבלת 5-6 תלויות, אולי זו נורה אדומה שהאחריות שלה רחבה מדי. בקוד "ספגטי" ללא DI, קשה לראות את זה כי אובייקטים נוצרים בחבואה בתוך הפונקציות.
- עמידה בעקרונות SOLID ועיצוב נקי – DI הוא ממש יישום ישיר של Dependency Inversion Principle (ה-D ב-SOLID). אימוץ שלו דוחף אותנו באופן טבעי לקוד עם צימוד נמוך (Low Coupling) וקוהזיה גבוהה, שזה בדיוק מה שאנחנו רוצים בארכיטקטורה נקייה. שינוי בדרישה עסקית ישפיע רק על שכבת הלוגיקה, ושינוי טכני ישפיע רק על שכבת התשתית – בדיוק כפי שהתכוונו.
מה קורה כשלא מיישמים נכון? בעיות נפוצות בצימוד תלויות
חשוב לדבר גם על הצד השני: מהן הבעיות הנפוצות כשלא משתמשים בהזרקת תלויות, או כשמשתמשים בה לא נכון:
- צימוד חזק בין רכיבים – ללא DI, נמצא לעיתים קרובות קוד ש"קושר" חלקים שונים חזק אחד לשני. למשל, Controller שיוצר בעצמו אובייקט Service, שבתוכו יוצר אובייקט Repository. מצב כזה מקשה מאוד לשנות משהו באמצע. אם מחר נרצה שה-Service ישתמש ברפוזיטורי אחר (נניח, לשימוש בסוג מסד נתונים שונה או API אחר), נצטרך לשנות את הקוד גם ב-Controller. כל רכיב שמורכב בתוך קוד של רכיב אחר הופך את השינוי למסוכן ומסובך יותר.
- קוד שקשה לבדוק – תארו לעצמכם פונקציית שירות שפונה ישירות ל-API חיצוני. כשתרצו לבדוק אותה, היא תמיד תנסה לקרוא ל-API האמיתי. בלי DI, אין דרך קלה להגיד לקוד "בזמן טסט תשתמש במשהו אחר". מפתחים עוקפים את זה לפעמים ע"י "Flags" או תנאים בתוך הקוד ("אם ENV=test אז…"), אבל זה פתרון לא אלגנטי ועתיר בבאגים. דרך הרבה יותר נקייה: DI – להעביר אובייקט חלופי בבדיקות.
- הפרת עקרון האחריות היחידה – לפעמים חוסר ב-DI מעיד על עיצוב לקוי. למשל, מחלקה שפותחת חיבור לבסיס נתונים וגם מעבדת את הנתונים וגם מחזירה תשובה – היא כנראה עושה יותר מדי. הזרקת תלויות "מכריחה" אותנו לפצל אחריות בצורה הגיונית: מחלקה עסקית תקבל ממשק של מחלקת DB, במקום לעשות את עבודת ה-DB בעצמה. אם מגלים שקשה מאוד לבצע DI ברכיב מסוים, ייתכן שהוא ממלא מספר תפקידים שראוי להפריד.
- הזרקת תלות לא מתאימה – גם כשמיישמים DI, צריך להיזהר לא להזריק דברים מהשכבה הלא נכונה. למשל, טעות תהיה להזריק אובייקט של
Request
ו-Response
של Express ישירות ל-Use Case. למה זו טעות? כי בקוד העסקי שלנו אסור שיהיה תלוי במבנה של HTTP או Express. במקום זאת, ה-Controller (בשכבת ה-Interface Adapters) יכול לשלוף את הנתונים הרלוונטיים מ-Request
(כמו פרמטרים או JSON מהגוף) ולהעביר אותם כפרמטרים רגילים ל-Use Case. ה-Use Case מחזיר תוצאה פשוטה (למשל אובייקט או ערך), וה-Controller מתרגם את זה ל-Response. הקפדה על כך ששכבות לא "יזלגו" לתוך אחת השנייה היא חלק בלתי נפרד מיישום נכון של DI במסגרת Clean Architecture.
סיכום והמלצות לדרך
Dependency Injection הוא אולי נשמע דבר מורכב למי שלא התנסה, אבל כמו שראינו – הרעיון די אינטואיטיבי: בנייה חכמה של המערכת כך שרכיבים יקבלו את מה שהם צריכים במקום ללכת לחפש את זה בעצמם. ביישום נכון, DI מאפשר לקוד שלנו להיות גמיש כמו לגו – להרכיב ולפרק רכיבים בקלות, לבדוק אותם יחידנית, ולהחליף חלקים בלי לשבור את העסק.
כמה המלצות פרקטיות כדי להתחיל ליישם הזרקת תלויות (במיוחד אם אתם נכנסים לזה לראשונה):
- התחילו בקטן – זהו חלק במערכת שלכם שיש בו תלות "קשיחה" שגורמת לכאב ראש (אולי גישה לבסיס נתונים, קריאה לשירות מרוחק, או אפילו שימוש במחלקה סטטית שקשה לשנות). תגדירו לו ממשק ותזריקו אותו במקום ליצור ישירות. תראו איך זה משפיע על הקוד.
- הגדירו ממשקים ברורים – הקדישו מחשבה לעיצוב הממשק של התלות. הוא צריך להיות מספיק מופשט כדי שלא ידלוף לוגיקה של מימוש. למשל,
ITodoRepository
לא אמור לחשוף את ספריית ה-ORM שלכם – הוא מספק פעולות עסקיות (getAll
,save
, וכדומה) במונחי הדומיין. - שקלו שימוש בכלי DI בפרויקטים גדולים – אם יש לכם עשרות רבות של תלויות ופונקציונליות מורכבת, ספריית DI יכולה לחסוך קוד חיווט ולמנוע שגיאות. עם זאת, אל תמהרו לסבך פרויקט קטן עם Container – הרבה פעמים הזרקה "ידנית" בקובץ אחד מספיקה. תמיד אפשר להכניס Container כשמרגישים שהגודל מצדיק.
- אל תשכחו את הכוונה המקורית – DI הוא אמצעי, לא מטרה בפני עצמה. המטרה היא קוד נקי, מבודד, שנוח לעבוד איתו. אם אתם מוצאים את עצמכם מנסים בכוח להזריק כל דבר זז ומסבכים את הקוד, חזרו צעד אחורה ותבדקו איפה באמת נדרש בידוד. לפעמים גם בגישה נקייה אפשר לאפשר לעצמכם "קיצור דרך" איפה שזה לא נורא, במיוחד בפרויקטים פשוטים – העיקר הוא המודעות והיכולת להשתפר עם הזמן.
בשורה התחתונה, הזרקת תלויות היא כלי מרכזי בהגשמת ההבטחה של Clean Architecture. היא מחברת את התיאוריה לפרקטיקה, ומבטיחה שהלוגיקה העסקית שלנו תישאר נקייה, עצמאית וברת-בדיקה.
כמו תמיד, אשמח לשמוע על החוויות והתובנות שלכם! האם יצא לכם ליישם Dependency Injection בפרויקטים שלכם? אילו יתרונות או קשיים פגשתם בדרך? מרגישים שהקוד נעשה נקי יותר? ספרו לי בתגובות – נתראה שם!