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

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

טעות #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 למשל), נגדיר ממשק מתאים ונממש אותו בשכבה חיצונית. בקיצור, הדומיין נשאר "נקי" מטכנולוגיות, ומקבל רק את מה שהוא צריך.

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

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

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

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

ארכיטקטורה נקייה: השוואה לגישות אחרות, ויתרונות וחסרונות

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

השוואה בין ארכיטקטורה נקייה לגישות אחרות (MVC, שכבות, Hexagonal, IDesign)

MVC (Model-View-Controller)MVC היא תבנית עיצוב ותיקה שמפרידה את חלקי המערכת לשלושה: מודל (Model) שמייצג את הנתונים, תצוגה (View) שמציגה אותם, וקונטרולר (Controller) שמתאם בין השניים. גישת MVC משמשת לרוב בצד הלקוח או במערכות web כדי לארגן את שכבת ממשק המשתמש. לעומת ארכיטקטורה נקייה, MVC מתמקדת בעיקר בהפרדת הלוגיקה של התצוגה מהלוגיקה העסקית, אבל לא בהכרח מבודדת את הלוגיקה העסקית מתשתיות כמו מסד נתונים או מערכת קבצים.
במערכת MVC קלאסית, הקונטרולר והמודל עשויים לכלול קוד ששייך לגישה לנתונים או תלוי במסגרת (Framework) מסוימת. במערכות קטנות או בינוניות MVC יכולה להספיק ולהיות פשוטה להבנה, אבל כשגדלים – יש סיכון שהקוד העסקי 'יזלוג' למקומות שלא נועדו לו, כי MVC לבדה לא מכתיבה בידוד מוחלט של הליבה העסקית.
ארכיטקטורה נקייה, לעומת זאת, מציבה את הלוגיקה העסקית במרכז ודואגת להגן עליה מפני תלות במסגרת UI, מסדי נתונים וכו'.

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

ארכיטקטורת משושה (Hexagonal Architecture) – ידועה גם כ"Ports and Adapters". גישה זו, שקיבלה את שמה בגלל תרשים בצורת משושה, דוגלת ברעיון דומה מאוד לארכיטקטורה נקייה: בידוד הליבה העסקית מהעולם החיצוני על ידי יצירת "פורטלים" (ממשקים) שאליהם ניתן לחבר מתאמים. בארכיטקטורה משושה, הלוגיקה המרכזית של התוכנה לא תלויה בשום דבר חיצוני, כל אינטרקציה חיצונית (כמו מסד נתונים, שירות חיצוני, או ממשק משתמש) עוברת דרך מתאם (Adapter) שמממש פורט (Port) שהליבה הגדירה. אם זה נשמע מוכר, זה לא במקרה – ארכיטקטורה נקייה למעשה שואבת השראה רבה מארכיטקטורת המשושה (וגם מ"ארכיטקטורת בצל" – Onion Architecture).
ההבדלים בין Clean Architecture ו-Hexagonal הם בעיקר טרמינולוגיים והדגשים: Clean Architecture מגדירה שכבות ספציפיות (Entities, Use Cases וכו'), בעוד ש-Hexagonal מדברת במונחים כלליים יותר של פורטים ומתאמים. שתיהן מתאימות במיוחד למערכות מורכבות וארוכות טווח שצריכות גמישות מול שינויים בטכנולוגיה.

שיטת IDesign – זוהי מתודולוגיית תכנון ארכיטקטוני שפותחה על ידי האדריכל Juval Löwy. IDesign (ישראלי, יובל) מתמקדת בפירוק מערכת לחלקים על פי "אזורים של שינוי" (Areas of Volatility). כלומר, בשיטה הזו ננסה לזהות אילו חלקים מהמערכת צפויים להשתנות בתדירות גבוהה, ונבנה את הגבולות הארכיטקטוניים דווקא סביב אותם אזורים. התוצאה היא ארכיטקטורה שמזכירה במידה מסוימת את הארכיטקטורה הנקייה (למשל, גם כאן נמצא הפרדה בין ליבה יציבה לבין רכיבים חיצוניים משתנים), אבל התהליך להגיע לשם שונה: במקום להתחיל מההפרדה הטכנית (UI מול דומיין מול נתונים), IDesign מתחילה מניתוח דרישות ושימושי מערכת כדי לייצר שירותים או רכיבים לפי היציבות שלהם לאורך זמן.
ניתן לחשוב על IDesign כעל "שכבות בתוך הדומיין" עצמו – השכבות העליונות יותר מייצגות חלקים יציבים פחות (נתונים משתנים או דרישות עתידיות), והתחתונות את הליבה היציבה. הארכיטקטורה הנקייה, לעומת זאת, שמה מראש את הלוגיקה העסקית במרכז ומתווה את כל המערכת מסביבה.
IDesign מתאימה במיוחד לתכנון מערכות גדולות מאוד וארגוניות, כשיש צורך לתכנן גם חלוקה לשירותים או מיקרו-סרוויסים לפי תחומי אחריות ותלות. בפרויקטים קטנים יותר, שיטת IDesign עשויה להרגיש כבדה מדי, בדיוק כפי שארכיטקטורה נקייה עשויה להיות לעיתים יותר מדי אם היקף המערכת קטן.

המבנה של הארכיטקטורה הנקייה – ולמה הסדר הזה חשוב?

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

Entities (יישויות) – השכבה הפנימית ביותר. כאן חיים האובייקטים והמודלים העסקיים הבסיסיים של המערכת שלנו. אלה מחלקות שמייצגות את ה"אמיתות" של הדומיין – למשל, אובייקט User, Order, Product וכדומה, עם לוגיקה עסקית טהורה שקשורה להם. הנקודה החשובה לגבי Entities היא שהן אינן תלויות בשום דבר חיצוני. הן לא יודעות אם הנתונים שלהן מגיעים ממסד נתונים, מ-API, או מכל מקור אחר – הן פשוט מגדירות את הכללים והמצב של העסק.

Use Cases (מקרי שימוש) – שכבה זו עוטפת את ה-Entities ומשמשת כמוח המתאם שמכתיב מה המערכת יודעת לעשות. כל Use Case מייצג תרחיש או פעולה עסקית משמעותית – למשל "יצירת הזמנה חדשה", "הרשמת משתמש", "עדכון מלאי" וכו'. בשכבה הזו מיישמים את כללי העסק של האפליקציה: איך משתמשים באותם Entities כדי לבצע משימה. Use Case יכול להפעיל מספר ישויות ולתזמן ביניהן, והוא מוגדר כך שיטפל בפעולה שלמה מקצה לקצה. גם כאן, עיקרון המפתח הוא חוסר תלות בגורמים חיצוניים: מקרי השימוש יכולים להסתמך על Entities ולהגדיר ממשקים מופשטים (interfaces) לצורך פעולות I/O, אבל הם לא יודעים ולא אמורים לדעת שום דבר על איך בדיוק המידע נשמר או מוצג.

Interface Adapters (מתאמי ממשק) – זו השכבה שמתחילה לגשר בין עולם הלוגיקה הטהורה לבין העולם שבחוץ. היא מכילה את הקוד ש"מתרגם" נתונים מצורה אחת לאחרת בין השכבות. אפשר לכלול כאן, למשל, מחלקות Controller או Presenter (בצד של ממשק המשתמש) שמקבלות בקשות מהעולם החיצון (נניח קריאת HTTP), ומעבירות אותן אל ה-Use Cases בפורמט שמתאים להם. כמו כן, בצד השני, מתאמי ממשק כוללים מימושים של ממשקים שהוגדרו בשכבת ה-Use Cases – לדוגמה, מימוש ספציפי של IUserRepository באמצעות מסד נתונים מסוים. במילים אחרות, השכבה הזו עוטפת את הליבה ומוודאת שהלוגיקה העסקית יכולה לדבר עם העולם החיצוני בלי לדעת פרטים עליו: הנתונים מה-Use Case מומרים לאובייקטים המתאימים לתצוגה, והנתונים מן החוץ מומרים לאובייקטים פנימיים לפני שמעבירים אותם ללוגיקה.

Frameworks & Drivers (מסגרות ותשתיות) – השכבה החיצונית ביותר. כאן נמצאים כל הפרטים הטכניים הסופיים: מסדי נתונים, frameworks של צד שרת או צד לקוח, ספריות, וקוד שנותן מימוש בפועל. אפשר לחשוב על השכבה הזו כעל מה שנמצא בשוליים של המערכת – למשל, קוד ה-Express.js שמקבל HTTP request, או ה-ORM שמבצע CRUD על בסיס נתונים. המטרה היא שהרכיבים בשכבה הזו יממשו את החוזים שהוגדרו להם בשכבות הפנימיות (למשל, יממשו את הממשקים שה-Use Cases ציפו להם). כלי או מסגרת (Driver/Framework) יכול להתחלף בקלות בשכבה הזו: אם מחר נרצה להחליף את ספריית ה-UI, או לעבור ממסד נתונים אחד לאחר, נשנה רק את זה, בלי לגעת בלוגיקה העסקית עצמה.

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

יתרונות וחסרונות של הגישה

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

יתרונות בולטים:

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

בדיקתיות (Testability) גבוהה: אחד היתרונות הגדולים של בידוד הלוגיקה העסקית הוא שניתן לבדוק אותה באופן עצמאי, במהירות וללא תלות בסביבה חיצונית. אפשר לכתוב מבחני יחידה (unit tests) ל-Use Cases ו-Entities בלי להרים בכלל מסד נתונים, שרת או כל תלות אחרת – פשוט מפני שהלוגיקה לא מכירה אותם ולא אכפת לה מהם. זה מוביל למבחנים שרצים מהר ובאמינות גבוהה, מה שמסייע לנו לתפוס באגים לפני שהם מגיעים לפרודקשן. כשיש לנו כיסוי בדיקות טוב על הליבה, יש ביטחון להתפתח ולהוסיף יכולות בלי לחשוש שנשבור משהו בסיסי.

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

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

חסרונות ואתגרים:

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

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

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

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

בשורה התחתונה – מתי זה שווה את ההשקעה?

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

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

כמו תמיד, אשמח לשמוע מכם על הניסיון שלכם. האם יצא לכם לעבוד עם Clean Architecture? מה עבד לכם טוב, ומה פחות?

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

הקדמה

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

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

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

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

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

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

  • 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). זה כל הסוד – בידוד מוחלט של לוגיקה עסקית מטכנולוגיה חיצונית.

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

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

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