ניהול טעויות ואירועים – Events & Error Handling כחלק מ־Clean Architecture

בפוסטים הקודמים דיברנו לעומק על ארכיטקטורה נקייה, סקרנו איך מיישמים Dependency Injection, והסברנו כיצד לעצב נכון את השכבה העסקית (Domain Layer).
היום נדבר על חלק נוסף וחשוב לא פחות: ניהול טעויות ואירועים (Events & Error Handling).

למה ניהול שגיאות חשוב כל כך?

כמפתחים, אנחנו יודעים ששגיאות הן בלתי נמנעות. הבעיה מתחילה כשלא מנהלים אותן נכון. התוצאות:

  • קוד מסורבל ומלא בלוקים של try-catch.
  • לוגים מבלבלים שקשה להבין מהם מה קרה.
  • זליגת לוגיקה טכנית לתוך השכבה העסקית.

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

עקרונות לניהול שגיאות נקי (Clean Error Handling)

1. הפרדה בין שגיאות עסקיות לטכניות

  • שגיאות עסקיות הן חלק מהלוגיקה של האפליקציה (למשל "משתמש כבר רשום", "יתרת חשבון נמוכה").
  • שגיאות טכניות הן תקלות תשתית (למשל "ה-DB לא זמין", "בעיית רשת").

הנה דוגמה קצרה ב־TypeScript להבחנה ברורה בין סוגי שגיאות:

// Business Error
export class BusinessError extends Error {
  constructor(message: string) {
    super(message);
    this.name = "BusinessError";
  }
}

// Technical Error
export class TechnicalError extends Error {
  constructor(message: string) {
    super(message);
    this.name = "TechnicalError";
  }
}

2. טיפול בשגיאות בשכבת Use Cases בלבד

בשכבת ה-Use Case אנחנו זורקים שגיאות עסקיות במפורש:

class RegisterUserUseCase {
  constructor(private userRepo: IUserRepository) {}

  async execute(user: User) {
    const existing = await this.userRepo.findByEmail(user.email);
    if (existing) {
      throw new BusinessError("User already exists");
    }
    await this.userRepo.save(user);
  }
}

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

3. טיפול בשגיאות בשכבת ה־Controllers (Interface Adapters)

השכבה החיצונית של האפליקציה תופסת את השגיאות ומחזירה תגובות ברורות ללקוח:

app.post("/users", async (req, res) => {
  try {
    await registerUserUseCase.execute(req.body);
    res.status(201).send("User created");
  } catch (err) {
    if (err instanceof BusinessError) {
      res.status(400).send(err.message);
    } else {
      res.status(500).send("Internal Server Error");
    }
  }
});

4. שימוש אחיד בלוגים (Logging)

הימנעו מלכתוב console.log בכל מקום. במקום זאת השתמשו בספריית לוגים מסודרת (לדוגמה winston):

import winston from "winston";

const logger = winston.createLogger({
  level: "info",
  transports: [new winston.transports.Console()],
});

// בלוגיקה העסקית אין לוגים טכניים, אלא רק נקודות עסקיות משמעותיות:
logger.info("User registered successfully", { userId: user.id });
logger.error("Database connection failed", { error: err.message });

ניהול אירועים ו־Event-Driven Architecture כחלק מהארכיטקטורה הנקייה

אירועים (Events) הם דרך מצוינת להפוך את האפליקציה שלנו לגמישה, מודולרית, וקלה לתחזוקה.

הרעיון הוא פשוט: אנחנו מפרסמים אירועים והמערכת (או חלקים ממנה) מאזינה להם ומגיבה לפי הצורך.

לדוגמה, ברגע שנוצר משתמש חדש, ייתכן שנרצה לשלוח מייל ״ברוכים הבאים״, לעדכן מערכת אחרת, ולכתוב ללוג – וכל זה, מבלי לשנות את הקוד שאחראי על יצירת המשתמש.

דוגמה מעשית ב־Node.js ו־TypeScript:

שלב 1: יצירת תשתית אירועים פשוטה

import { EventEmitter } from "events";

// Event Dispatcher פשוט
export class DomainEventEmitter extends EventEmitter {}

export const domainEvents = new DomainEventEmitter();

שלב 2: אירועים בשכבת הליבה (Domain Layer)

// אירוע עסקי
class UserRegisteredEvent {
  constructor(public readonly userId: string,
  public readonly email: string
) {}
}

// ה־Use Case מפרסם אירוע
class RegisterUserUseCase {
  constructor(private userRepo: IUserRepository) {}

  async execute(user: User) {
    const existing = await this.userRepo.findByEmail(user.email);
    if (existing) {
      throw new BusinessError("User already exists");
    }
    await this.userRepo.save(user);

    domainEvents.emit("UserRegistered", new UserRegisteredEvent(user.id, user.email));
  }
}

שלב 3: האזנה לאירועים בשכבת התשתית

// שליחת אימייל בעת אירוע
domainEvents.on("UserRegistered", async (event: UserRegisteredEvent) => {
  try {
    await emailService.sendWelcomeEmail(event.email);
    logger.info("Welcome email sent", { userId: event.userId });
  } catch (err) {
    logger.error("Failed to send welcome email", { error: err.message });
  }
});

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

טיפים לניהול אירועים נכון בארכיטקטורה נקייה:

אירועים עסקיים בשכבה העסקית בלבד – שכבת ה-UI או DB לא מפרסמת אירועים.

מאזינים (Listeners) בשכבה החיצונית – כך השכבה העסקית נשארת נקייה.

האירועים צריכים להיות פשוטים – אובייקט אירוע לא אמור לכלול לוגיקה מורכבת, אלא רק נתונים.

טעויות נפוצות שכדאי להימנע מהן:

זליגת אירועים טכניים ללוגיקה העסקית:
אל תייצרו אירועים טכניים ("DB התעדכן", "API נענה") בתוך השכבה העסקית.

ריבוי אירועים מיותרים:
כל אירוע צריך לייצג משהו בעל משמעות עסקית ברורה ("משתמש נרשם", "הזמנה אושרה").

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

בשורה התחתונה…


ניהול שגיאות ואירועים הוא לא רק עניין טכני. כשעושים אותו נכון – הקוד הופך להיות ברור, גמיש ועמיד יותר לשינויים. שמירה על גבולות ברורים בין השכבות תבטיח שהמערכת שלנו תישאר נקייה ומתוחזקת היטב.

אשמח לשמוע איך אתם מנהלים את השגיאות והאירועים בפרויקטים שלכם. האם יצא לכם להיתקל בקשיים? האם יש פתרונות מעניינים שהייתם רוצים לשתף?

ספרו לי בתגובות – נתראה שם!

טעויות נפוצות ביישום ארכיטקטורה נקייה – וכיצד להימנע מהן

בפוסטים הקודמים בסדרה חקרנו את המבנה של ארכיטקטורה נקייה, השווינו אותה לארכיטקטורות אחרות, ודיברנו על עקרונות מעשיים ליישום. בשלב זה אתם בטח כבר נלהבים ליישם את העקרונות האלה בפרויקט שלכם. אבל כמו בכל פרקטיקה טובה, אפשר גם ליישם אותה בצורה לא נכונה. בפוסט הזה נמשיך את הסדרה בהתמקדות בטעויות נפוצות שצוותים עושים כשהם מיישמים ארכיטקטורה נקייה – וכמובן, איך להימנע מהן.

טעות #1: יצירת ממשקים מיותרים

הטעות הנפוצה הראשונה היא יצירת ממשקים (interfaces) לכל דבר, גם כשלא באמת צריך. זה בדרך כלל מגיע מהרצון ללכת "עד הסוף" עם עקרון היפוך התלות (Dependency Inversion) של הארכיטקטורה הנקייה. העיקרון אומר שהשכבות הפנימיות מגדירות ממשקים, והשכבות החיצוניות מממשות אותם, כדי שהקוד העסקי לא יהיה תלוי בפרטים הטכניים. אבל זה לא אומר שצריך להגדיר ממשק נפרד לכל מחלקה או שירות אם בפועל יש רק מימוש אחד.

לעיתים קרובות אני רואה צוותים שיוצרים ממשקים "ליתר ביטחון" – מתוך מחשבה שאולי בעתיד נצטרך עוד וריאציות, או סתם כי זה נראה להם "נקי" יותר. בפועל, זה עלול לסרבל את הקוד ולהכניס מורכבות מיותרת. פתאום, בשביל לעקוב אחרי מה שקורה, אתם צריכים לפתוח גם את קובץ ה־interface וגם את קובץ המימוש שלו, למרות שאין ביניהם שום הבדל מהותי.

לדוגמה, בפרויקט Node.js עם TypeScript, נתקלתי במקרים שבהם מגדירים ממשק UserRepository ואחריו מחלקה MongoUserRepository שמממשת אותו – למרות שאין שום מימוש אחר. הקוד נראה בערך כך:

interface UserRepository {
  findById(id: string): Promise<User | null>;
  save(user: User): Promise<void>;
}

class MongoUserRepository implements UserRepository {
  async findById(id: string): Promise<User | null> {
    // ... מממש חיפוש במסד נתונים MongoDB ...
  }
  async save(user: User): Promise<void> {
    // ... מממש שמירה במסד הנתונים ...
  }
}

יש כאן שני קבצים ושתי ישויות נפרדות בקוד, בשביל מה שבעצם הוא פעולה אחת פשוטה – אחזור ושמירת משתמשים. אם אין כרגע עוד מקור נתונים למשתמשים (למשל, אין גם PostgresUserRepository או InMemoryUserRepository), אז הממשק הזה לא משרת מטרה מיידית.
אגב, זאת טעות שאני עושה בעצמי הרבה פעמים.

איך להימנע מהטעות הזו? פשוט: לא להפוך הכל למופשט אם אין בכך צורך. אפשר להתחיל במחלקה קונקרטית (למשל מחלקת UserRepository ללא ממשק) ולהגדיר ממשק רק כשבאמת צריך אותו – למשל כשיש כמה מימושים, או כדי להקל על כתיבת בדיקות יחידה. אפשר תמיד להוסיף ממשק מאוחר יותר אם הפרויקט מתרחב, אבל קשה יותר להסיר מורכבות מיותרת אחרי שהיא נטמעה בקוד. כלל אצבע טוב הוא להימנע מ"Abstraction for the sake of abstraction" – אל תבצעו הפשטה רק כי "ככה עושים", אלא רק כשיש לה הצדקה מעשית. זה דורש תירגול וניסיון.

טעות #2: הפרדה קיצונית מדי

ארכיטקטורה נקייה מדגישה הפרדת אחריות, אבל אפשר גם לקחת את זה רחוק מדי. לפעמים, מתוך הרצון העז להימנע מכל תלות, צוותים מפרקים את הקוד לחלקים קטנים מדי או מפרידים אותו למודולים רבים – עד כדי כך שכל פעולה פשוטה דורשת לעבור דרך אינספור שכבות.

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

גם בתוך אותו קוד-בסיס, הפרדה מוגזמת יכולה להוביל לקוד מסורבל. לדוגמה, אם בשביל לבצע פעולה עסקית פשוטה האפליקציה עוברת דרך Controller, אחר כך מחלקת Use Case, אחר כך Service דומיין, ואז Repository – וכל אחד מהם רק מעביר הלאה את הקריאה בלי להוסיף לוגיקה ממשית – זה סימן שפירקנו יותר מדי.

חשוב לזכור שהמטרה היא להפריד אחריות במידה שתועיל למבנה הפרויקט, ולא להפריד "רק כדי להפריד". אם שני חלקי קוד תמיד משתנים יחד ותלויים זה בזה, אולי אין טעם להחזיק אותם בכוח בשכבות נפרדות. אפשר לשמור על עקרונות הארכיטקטורה גם בלי להפוך כל שכבה לשירות עצמאי או לכלול ארבעה מתווכים בכל קריאה. במילים אחרות, הפרידו היכן שהדבר מעניק גמישות או בהירות, אבל אל תגזימו כשההפרדה לא מוסיפה ערך.

טעות #3: מיקום שגוי של לוגיקה

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

כשלוגיקה עסקית מתערבבת בשכבה חיצונית, אנחנו מאבדים את ההפרדה שרצינו. קשה יותר לבדוק את זה בטסטים בבידוד (כי הם קשורים למסגרת חיצונית) וקשה לעשות בהם שימוש חוזר. דמיינו שיש לנו כלל עסקי חשוב שממומש רק בקוד של ה-API: אם מחר נרצה להשתמש בו גם במקום אחר (נניח בסקריפט רקע או בשירות נוסף), נצטרך לשכפל את הקוד או לחלץ אותו בדיעבד.

זה קטע קוד לדוגמה מroute ב-Express, שבו נעשתה טעות כזו:

// Express controller example (presentation layer)
app.post('/orders', async (req, res, next) => {
  try {
    const orderData = req.body;
    // בדיקת כלל עסקי בשכבת הקונטרולר - טעות!
    if (orderData.items.length === 0) {
      return res.status(400).json({ error: 'Order must include at least one item' });
    }
    const order = await OrderModel.create(orderData);  // שמירת ההזמנה ישירות דרך מודל הנתונים
    res.status(201).json(order);
  } catch (err) {
    next(err);
  }
});

בדוגמה הזו בדיקת התנאי "האם ההזמנה ריקה" מתבצעת ישירות ב-controller, ולא בשכבת הלוגיקה העסקית. המשמעות היא שאם נוסיף דרך נוספת ליצור הזמנות (נניח ממשק מנהל או שירות חיצוני), ייתכן שהכלל הזה לא ייאכף כי הוא לא נמצא במקום מרוכז אחד. בנוסף, הקוד שומר את ההזמנה ישירות באמצעות מודל מסד הנתונים (OrderModel), כך ששכבת הדומיין בכלל לא מעורבת בתהליך.

שימו לב לא להתבלבל! בדוגמה לא מדובר על ולידציה טכנית (שבודקת האם הקלט תקין מבחינה טכנית, האם יש את כל השדות הדרושים, האם הפורמט של השדה נכון וכו) – אלה מדובר על ולידציה עסקית – שהמקום שלה הוא לא בcontroller.

כדי לתקן מצב כזה, כדאי להעביר את הלוגיקה הזו פנימה. למשל, אפשר לממש את הכלל בדומיין עצמו – שהאובייקט Order לא יאפשר יצירה של הזמנה בלי פריטים, או להוסיף בדיקה בשכבת ה-Use Case (מקרה השימוש) לפני השמירה. כך, ה-controller יטפל רק בבקשת ה-HTTP ויעביר את הנתונים לשכבת הלוגיקה שתבצע את כל הבדיקות והפעולות העסקיות. הגישה הזו מבטיחה שכללי העסק מתקיימים תמיד, לא משנה מאיפה מתקבלת הקריאה.

טעות #4: שכבות סרק

בהמשך ישיר להפרדה מוגזמת, יש מקרים שבהם מממשים שכבה שלמה שלא עושה כמעט כלום – היא רק מקבלת קריאה ומעבירה אותה לשכבה הבאה. אלה "שכבות סרק" קלאסיות.

לפעמים זה נובע מהרצון לעקוב אחרי התבנית הנקייה אחד לאחד: יש לנו Controller, Service, Repository וכו', גם אם בפועל ה-Service (לדוגמה) לא מוסיף שום ערך מעבר למה שה-Repository כבר מספק. התוצאה היא שכבת ביניים שמסבכת את המערכת סתם.

לדוגמה, תסתכלו על הקוד הבא שבו יש Service שלא עושה יותר מהפניה למאגר נתונים:

class OrderService {
  constructor(private orderRepo: OrderRepository) {}
  
  async getOrderDetails(id: string) {
    // שכבת Service שלא מוסיפה שום לוגיקה משל עצמה
    return this.orderRepo.findById(id);
  }
}

במקרה הזה, המחלקה OrderService לא מבצעת שום עיבוד או לוגיקה משל עצמה. היא פשוט עוטפת קריאה ל-orderRepo. אם ה-controller שלנו קורא רק ל-OrderService.getOrderDetails, יכולנו באותה מידה לקרוא ישירות ל-orderRepo.findById ולוותר על השכבה הזאת לחלוטין.

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

טעות #5: חשיפת הדומיין למימושים חיצוניים

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

סימן אזהרה מיידי הוא אם אתם מוצאים בקוד הדומיין import של ספריות כמו Express, Mongoose, Axios או כל framework חיצוני אחר. למשל, אם יש לנו מחלקת ישות (Entity) שבנויה ישירות על מודל של ORM, או פונקציה בדומיין שקוראת ל-API חיצוני בעזרת ספריית HTTP – זה אומר שחשפנו את הדומיין שלנו למימוש חיצוני.

ניקח דוגמה: נניח שמישהו שם את הגדרת מודל המשתמש של Mongoose בתוך קוד הדומיין, ואז משתמש בו ישירות כדי לאחזר נתונים:

// טעות: מודל Mongoose המשולב בליבת הדומיין
import mongoose from 'mongoose';

const UserSchema = new mongoose.Schema({
  name: String,
  email: String
});
export const UserModel = mongoose.model('User', UserSchema);

// שימוש במודל החיצוני בתוך פונקציית דומיין:
export async function getUserName(id: string): Promise<string | null> {
  const userDoc = await UserModel.findById(id);  // תלות ישירה ב-Mongoose בתוך הדומיין
  return userDoc ? userDoc.name : null;
}

בדוגמה הזו, UserModel (שהוא חלק מ-Mongoose) זמין ישירות בקוד הלוגיקה העסקית (getUserName). המשמעות היא שהדומיין שלנו עכשיו תלוי בפרטי מימוש של בסיס הנתונים (MongoDB במקרה זה). אם מחר נרצה להחליף מסד נתונים, או אפילו רק לבדוק את הלוגיקה הזו בלי גישה לבסיס נתונים, אנחנו בבעיה.

כדי להימנע מחשיפת הדומיין, עלינו להקפיד שהכיוון תמיד יהיה כזה: הדומיין מגדיר חוזים (ממשקים), והחוץ מממש אותם. למשל, במקרה של מסד נתונים הדומיין יכול להגדיר ממשק UserRepository מופשט, וההטמעה של Mongoose תיעשה בשכבת ה-Repository שב-Infrastructure. כך הלוגיקה העסקית (getUserName וכדומה) תקרא לפונקציות של UserRepository שאינן תלויות בטכנולוגיה, ואלו בתורן יקראו בפועל ל-Mongoose. באופן דומה, אם הדומיין צריך לקרוא ל-Service חיצוני (API למשל), נגדיר ממשק מתאים ונממש אותו בשכבה חיצונית. בקיצור, הדומיין נשאר "נקי" מטכנולוגיות, ומקבל רק את מה שהוא צריך.

סיכום: לאזן בין העקרונות לאפקטיביות היומיומית

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

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

ארכיטקטורה נקייה היא רק כלי בארגז הכלים שלנו – לא מטרה בפני עצמה. השתמשו בה בחוכמה, למדו מהטעויות הנפוצות, והיו מוכנים גם לחרוג מעט "מהספר" כשצריך. בסופו של דבר, השאיפה היא למצוא את הנקודה שבה המערכת גם בנויה נכון ויציבה, וגם מאפשרת לכם ולצוות שלכם להיות פרודוקטיביים ביום־יום. בהצלחה בבניית הארכיטקטורה הנקייה (והפרקטית) שלכם!

ניהול השכבה העסקית (Domain Layer) – עקרונות ודוגמאות מעשיות

בפוסטים הקודמים עסקנו בעקרונות של ארכיטקטורה נקייה (Clean Architecture) וביישום מעשי של Dependency Injection. עכשיו הגיע הזמן לדבר על אחד הרכיבים הכי חשובים (והרבה פעמים הכי מוזנחים) בארכיטקטורה שלנו – השכבה העסקית (Domain Layer).

תזכורת מהירה: מהי השכבה העסקית (Domain Layer)?

השכבה העסקית היא הלב של המערכת שלנו. היא מכילה את כל הלוגיקה העסקית והמודלים העסקיים. כאן נמצאות הישויות (Entities) – המודלים הבסיסיים שמתארים את עולם התוכן שלנו, ו-Value Objects – אובייקטים ייחודיים שמייצגים ערכים ללא זהות נפרדת.

השכבה הזו חייבת להיות נקייה לחלוטין מתלות בטכנולוגיות, בתשתיות או בממשק משתמש. המשמעות היא:

  • אסור שתהיה תלויה ישירות ב-DB.
  • אסור שתכיל קוד ספציפי ל-UI.
  • אסור שתהיה תלויה ישירות בשום שירות חיצוני.

אז איך עושים את זה נכון?

Entities ו-Value Objects – מה ההבדל ואיך לתכנן אותם?

Entities (ישויות)

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

דוגמה לישות:

class User {
  constructor(public readonly id: string, public name: string, public email: string) {}

  updateEmail(newEmail: string) {
    if (!newEmail.includes('@')) {
      throw new Error('Invalid email format');
    }
    this.email = newEmail;
  }
}

הישות מוגדרת על ידי הזהות שלה (id). לא משנה אם יש שני משתמשים בעלי שם זהה – כל עוד ה־ID שונה, אלו שתי ישויות נפרדות.

Value Objects

Value Objects הם אובייקטים חסרי זהות. הם מוגדרים אך ורק על ידי הערכים שלהם. אם שני Value Objects זהים מבחינת הערכים שלהם – הם למעשה אותו אובייקט.

דוגמה טובה היא "כתובת":

class Address {
  constructor(
    public readonly city: string,
    public readonly street: string,
    public readonly houseNumber: number,
  ) {}

  equals(other: Address): boolean {
    return (
      this.city === other.city &&
      this.street === other.street &&
      this.houseNumber === other.houseNumber
    );
  }
}

אם לשני משתמשים יש את אותה כתובת בדיוק, אין משמעות לשאלה אם מדובר בשני אובייקטים שונים – הם תמיד ייחשבו זהים ברמה העסקית.

איך למנוע זליגת לוגיקת UI או Database לשכבה העסקית?

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

לדוגמה, זה לא תקין:

// דוגמה לא נכונה של Entity עם זליגת DB
class User {
  constructor(public id: string,
  public name: string,
  private db: Database
) {}

  async save() {
    await this.db.users.update(this.id, { name: this.name });
  }
}

כאן, הישויות כבר תלויות ישירות בשכבת ה-DB, מה שמפר לחלוטין את הכלל המרכזי של השכבה העסקית.

אז איך עושים את זה נכון?

הדרך הנכונה היא להשתמש ברפוזיטורים (Repositories) שהגדרנו בשכבת ה-Use Cases. הישויות עצמן לא אחראיות על שמירתן:

דוגמה נכונה:

// Entity נקי לחלוטין מ-DB
class User {
  constructor(public readonly id: string, public name: string) {}

  changeName(newName: string) {
    this.name = newName;
  }
}

// Repository Interface
interface IUserRepository {
  save(user: User): Promise<void>;
}

// Repository ממומש בשכבת התשתית (בחוץ!)

בצורה כזו השכבה העסקית נשארת נקייה לגמרי מלוגיקת DB.

שימוש מעשי ב-Domain Models – דוגמה עם TypeScript

הנה דוגמה מלאה לאופן שבו ה-Domain Model עובד בפועל:

// Entity
class Order {
  constructor(
    public readonly id: string,
    public readonly items: OrderItem[],
    public readonly customerId: string,
    public status: OrderStatus = OrderStatus.Created
  ) {}

  calculateTotal(): number {
    return this.items.reduce((total, item) => total + item.price * item.quantity, 0);
  }

  approveOrder() {
    if (this.status !== OrderStatus.Created) {
      throw new Error('Only new orders can be approved');
    }
    this.status = OrderStatus.Approved;
  }
}

// Value Object
class OrderItem {
  constructor(public productId: string, public price: number, public quantity: number) {}
}

// Enum פשוט לסטטוסים של הזמנה
enum OrderStatus {
  Created,
  Approved,
  Shipped,
  Delivered,
}

ה-Order הוא Entity מרכזי. יש לו פעולות עסקיות כמו חישוב הסכום ואישור הזמנה, וכל פרט טכני אחר (שמירה, טעינה) מנוהל מבחוץ, דרך Repository בשכבת Use Case/Infrastructure.

טיפים למניעת זליגה לשכבה העסקית (Best Practices)

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

  • לעולם אל תעבירו לשכבה העסקית אובייקטים טכניים כמו Request או Response של Express.
  • אין לוגיקת DB בתוך Entities ו-Value Objects. השתמשו בממשקים מופשטים.
  • הקפידו שה-Value Objects יהיו בלתי-ניתנים-לשינוי (Immutable).
  • בדיקות יחידה צריכות לבדוק רק את הלוגיקה העסקית, ללא Mock של DB או HTTP. אם זה קשה לכם – משהו לא בסדר בעיצוב שלכם.
  • אם קוד ה-Entity שלכם מתחיל להסתבך עם הרבה לוגיקה – בדקו אם אפשר להעביר חלק ללוגיקת Use Case או להפריד ל-Value Objects חדשים.

בשורה התחתונה

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

כמו תמיד, אם יש שאלות, הערות או שאתם פשוט רוצים לשתף איך אתם מנהלים את השכבה העסקית שלכם – אשמח לשמוע מכם בתגובות!

יישום Dependency Injection ב־Clean Architecture

בפוסטים הקודמים סקרנו את עקרונות הארכיטקטורה הנקייה (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 בפרויקטים שלכם? אילו יתרונות או קשיים פגשתם בדרך? מרגישים שהקוד נעשה נקי יותר? ספרו לי בתגובות – נתראה שם!

מהי ארכיטקטורה נקייה ומה המטרה שלה?

הקדמה

כמעט כל מפתח שנתקל בפרויקט גדול מכיר את התרחיש הבא: בהתחלה הכול ברור ופשוט, הקוד מסודר יפה, ואנחנו שולטים בכל פרט. אבל ככל שהפרויקט מתפתח וגדל, אנחנו מתחילים לשים לב למשהו מטריד – כל שינוי קטן (מה שאני קורא לו, התקבלה דרישה חדשה, לאובייקט ״בן אדם״ – צריך להיות זנב, והכלב צריך לדעת לדבר ספרדית) גורר אחריו שורה של תיקונים בלתי צפויים, ובלי ששמנו לב, מצאנו את עצמנו עמוק בתוך תסבוכת של תלות הדדית בין רכיבים וקבצים. בקיצור… ספגטי אחד גדול.

זה בדיוק מה שארכיטקטורה נקייה באה לפתור.

רגע, מה זה בכלל ארכיטקטורה נקייה?

הרעיון של ארכיטקטורה נקייה הוא לא משהו מסובך מדי, אלא דווקא מאוד אינטואיטיבי: לחלק את המערכת שלנו לשכבות ברורות של אחריות, כשהמרכז החשוב ביותר – הלוגיקה העסקית – נמצא בבידוד מוחלט מכל פרט טכני חיצוני.
למה זה טוב? כי זה מבטיח לנו שאפשר יהיה להחליף או לשדרג כל חלק חיצוני, בלי לפגוע במה שבאמת חשוב במערכת – הלוגיקה העסקית, או במילים פשוטות יותר: "מה שהמערכת אמורה לעשות".

איך זה נראה בפועל?

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

  • Entities (יישויות) – אלו הם האובייקטים הבסיסיים ביותר במערכת, כמו משתמשים, הזמנות, מוצרים וכדומה. כאן נמצאים המודלים העסקיים הטהורים, ללא שום קשר לטכנולוגיות או תשתיות ספציפיות.
  • Use Cases (מקרי שימוש) – השכבה הזו מגדירה את הפעולות או התהליכים שהמערכת יודעת לבצע באמצעות אותן ישויות. לדוגמה, "יצירת הזמנה חדשה", "רישום משתמש" או "עדכון פרטי מוצר". כאן נמצאת הלוגיקה העסקית עצמה.
  • Interface Adapters (מתאמי ממשק) – השכבה הזו מתווכת בין הליבה העסקית (Use Cases ו-Entities) לבין העולם החיצוני. למשל, היא מתרגמת מידע שמגיע מהמשתמש לתוך המערכת ומכינה נתונים פנימיים להצגה החוצה.
  • Frameworks & Drivers (מסגרות ותשתיות) – השכבה החיצונית ביותר, שכוללת את כל הכלים והטכנולוגיות החיצוניות, כמו בסיסי נתונים, שרתי HTTP, או ספריות חיצוניות שונות.

הכלל הכי חשוב בארכיטקטורה נקייה

כלל המפתח שמחזיק את כל המבנה הזה יחד נקרא "כלל התלות" והוא מאוד פשוט: השכבות הפנימיות (ב"שכבות פנימיות" בארכיטקטורה נקייה, הכוונה לשכבות שנמצאות במרכז של המערכת – דהיינו, השכבות שהן הכי חשובות מהבחינה העסקית והן הכי פחות תלויות בטכנולוגיה חיצונית) אף פעם לא תלויות בשכבות החיצוניות.
או במילים אחרות, אסור שהלוגיקה העסקית שלנו תדע או תושפע משום פרט טכני – לא סוג מסד הנתונים, לא פרוטוקול התקשורת, ואפילו לא פרטי הממשק המשתמש. כל אלו יכולים להשתנות בלי שנצטרך לגעת בשכבת הליבה.

ולמה כל זה שווה לנו?

יש כמה יתרונות מאוד גדולים לשימוש בארכיטקטורה נקייה:

  • קל יותר לתחזק ולשנות: כשהמערכת מסודרת לפי שכבות ברורות, כל שינוי הוא הרבה פחות מסובך ומסוכן. אנחנו יודעים בדיוק איפה לחפש ומה לשנות, בלי חשש שמקומות לא צפויים יישברו פתאום.
  • בדיקות קלות ואפקטיביות: כששכבת הלוגיקה העסקית מבודדת מהתשתיות, קל מאוד לכתוב בדיקות אוטומטיות מהירות שמכסים את הלוגיקה בלי לדאוג לסביבה מסובכת.
  • עצמאות מטכנולוגיות: אם היום אנחנו משתמשים ב-MongoDB ומחר רוצים לעבור ל-Postgres או אפילו לשירות ענן כלשהו, זה לא ישפיע על הלוגיקה העסקית שלנו. כל מה שנצטרך הוא לממש מחדש את שכבת התשתית.

בואו נראה דוגמה קצרה ב־TypeScript ו־Node.js

הנה דוגמה פשוטה להמחיש את העיקרון של ארכיטקטורה נקייה:

קודם כל, שכבת הלוגיקה העסקית מגדירה ממשק מופשט לגישה לנתונים:

// זה ממשק מופשט בשכבת הליבה
interface IUserRepository {
  save(user: User): Promise<void>;
  getById(id: string): Promise<User | null>;
}

// אובייקט עסקי פשוט
class User {
  constructor(public id: string, public name: string) {}
}

// שכבת הלוגיקה העסקית (Use Case)
class CreateUser {
  constructor(private repo: IUserRepository) {}

  async execute(user: User) {
    if (!user.name) throw new Error('Name is required');
    await this.repo.save(user);
  }
}

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

// מימוש של הממשק עבור MongoDB (שכבת תשתית חיצונית)
class MongoUserRepository implements IUserRepository {
  async save(user: User): Promise<void> {
    const collection = mongo.db('app').collection('users');
    await collection.insertOne(user);
  }

  async getById(id: string): Promise<User | null> {
    const collection = mongo.db('app').collection('users');
    return await collection.findOne({ id });
  }
}

ולבסוף, בהרכבת המערכת הראשית שלנו:

const userRepository = new MongoUserRepository();
const createUserUseCase = new CreateUser(userRepository);

await createUserUseCase.execute(new User('123', 'Aviv'));

שימו לב:
הקוד של CreateUser לא יודע כלום על MongoDB או על טכנולוגיה אחרת. הוא עובד רק מול ממשק מופשט (IUserRepository). זה כל הסוד – בידוד מוחלט של לוגיקה עסקית מטכנולוגיה חיצונית.

בשורה התחתונה – למי זה מתאים?

ארכיטקטורה נקייה היא לא חובה בכל פרויקט. אם אתם עובדים על סקריפט קצר, פרויקט ניסיוני או אפליקציה קטנה, כנראה שאין טעם להוסיף שכבות מיותרות.
אבל אם אתם מתכננים מוצר מורכב שיצטרך להתפתח לאורך זמן, שווה להשקיע את הזמן מראש, כי בטווח הארוך תגלו שההשקעה הזאת חוסכת המון זמן, כסף, ועצבים.

וכמו תמיד – אשמח לשמוע מכם בתגובות!
יש שאלות? רעיונות? משהו לא ברור? דברו איתי!