ניהול השכבה העסקית (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 חדשים.

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

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

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

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

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

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

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

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

    בשלב הקוד, אפשר להתחיל בקטן: לבחור מקרה שימוש אחד ולממש אותו בצורה "נקייה" מקצה לקצה – כך תיצרו דפוס שאפשר לשכפל בהמשך. הקפידו להפריד כבר מהתחלה בין חלקי הקוד השונים: לוגיקה עסקית במקום אחד, קוד של גישה למסד נתונים/Filesystem במקום אחר, וקוד של HTTP או ממשק משתמש במקום נוסף. אם תתחילו ככה, גם אם לא הכל מושלם, תהיו בכיוון הנכון.
  • בפרויקט קיים: כאן האתגר גדול משמעותית, כי יש מערכת עובדת (ואולי מבולגנת) שצריך בהדרגה "לנקות". החדשות הטובות הן שלא חייבים (ולא כדאי) לבצע כתיבה מחדש מאפס. במקום זאת, אפשר לאמץ את עקרונות הארכיטקטורה הנקייה צעד אחר צעד: בחרו רכיב או איזור בעייתי בקוד והתחילו לבודד אותו. למשל, אם יש חלק בקוד שכולל גם לוגיקה וגם קריאות ישירות למסד נתונים, נסו להפריד ביניהם: צרו מחלקת שירות עסקי (Use Case) שמכילה את הלוגיקה, והזיזו את הקריאות למסד נתונים למחלקת "מאגר" (Repository) נפרדת. הגדירו ממשק (interface) בין השניים, כדי שהלוגיקה לא תהיה תלויה במימוש הטכני. באופן הדרגתי, כל פונקציונליות חדשה שתוסיפו – כבר תכתבו לפי העקרונות הנקיים, וחלקים ישנים תוכלו לשפר בהדרגה כאשר נוגעים בהם. כן, ייתכן שבתקופת הביניים תחיו עם שילוב של סגנונות, אבל זה בסדר כל עוד אתם במגמת שיפור מתמדת.

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

תכנון מקרי השימוש והליבה העסקית

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

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

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

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

ארגון הקבצים והתיקיות בפרויקט נקי

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

src/  
├── domain/            # שכבת הדומיין (ליבה עסקית)
│   ├── entities/      # ישויות ומודלים עסקיים
│   └── interfaces/    # ממשקים אבסטרקטיים (למשל, Repository)
├── use-cases/         # שכבת היישום (מקרי שימוש)
├── infrastructure/    # שכבת תשתית (מימושים טכניים)
│   ├── db/            # קוד גישה למסד נתונים
│   ├── services/      # שירותים חיצוניים (לדוגמה, SMTP, API חיצוני)
│   └── frameworks/    # קוד מסגרת (HTTP server, Express וכו')
└── interface-adapters/ # מתאמי ממשק (למשל, Controllers/Routes, View models)

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

עוד טיפ בארגון הקבצים: שמות ברורים. השתמשו בשמות שמרמזים על התפקיד בשכבה. למשל, UserRepository עבור ממשק, MongoUserRepository עבור המימוש שלו, או UserController עבור קובץ שמטפל בבקשות HTTP של משתמש. ככה, גם בלי לפתוח את הקובץ, מפתח יבין מה התפקיד שלו.

זכרו שאין צורך להגזים במספר השכבות אם הן לא מוסיפות ערך ברור. לפרויקט קטן ייתכן שתעבדו רק עם 3 תיקיות עיקריות: domain, application (use cases) ו-infrastructure. לפרויקט גדול אולי תוסיפו גם שכבת interface-adapters לתיווך. התאימו את המבנה לגודל וצרכי הפרויקט, אבל שמרו על העיקרון שכל שכבה תלויה רק בשכבות ה"פנימיות" ממנה (ולא ההיפך).

היפוך תלות: מתי ליצור ממשקים והזרקת תלויות

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

מתי נכון ליצור ממשק? בכל נקודת מגע בין הליבה העסקית לבין שירות חיצוני, מסד נתונים או מערכת אחרת. דוגמה קלאסית: גישה לנתונים. אם ה-Use Case שלנו צריך לשמור אובייקט במסד נתונים, הוא לא יקרא ישירות לפונקציה של MongoDB או PostgreSQL. במקום זה נגדיר ממשק כמו IUserRepository עם מתודות כמו save ו-getById. הממשק הזה יוגדר בשכבת הדומיין או היישומים (פנימית), וה-Use Case יקבל משהו שמממש את הממשק הזה. המשהו הזה – המימוש – יחיה בשכבת התשתית (למשל מחלקת MongoUserRepository). כך ה-Use Case "מדבר" בשפה שלו (אבסטרקטית), ולא אכפת לו אם מתחת אנחנו עובדים עם Mongo, קובץ, או כל דבר אחר.

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

אחרי שיצרנו ממשקים, נשתמש בהזרקת תלויות (Dependency Injection) כדי לספק ל-Use Case את המימושים המתאימים בזמן ריצה. הזרקת תלויות היא פשוט דרך מתוחכמת להגיד "נעביר מבחוץ את האובייקט שה-Use Case צריך, במקום שה-Use Case עצמו ייצור או יאתר אותו". אגב, בשביל DI לא חייבים ספרייה מיוחדת: אפשר פשוט להעביר את התלות דרך הקונסטרקטור (Constructor Injection) או כפרמטר לפונקציה. בסביבת Node.js הרבה פעמים מנהלים את ההרכבה הזו באופן ידני בקובץ התחלתי (composition root), וזה לגמרי בסדר גמור. רק בפרויקטים מאוד גדולים אולי ניעזר בכלי DI אוטומטי, אבל לרוב אין בכך צורך בהתחלה.

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

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

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

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

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

interface IEmailService {
  sendWelcomeEmail(user: User): Promise<void>;
}

// יישות (Entity) – ייצוג של משתמש
class User {
  constructor(public id: string, public name: string, public email: string) {}
}

// Use Case – לוגיקה עסקית לרישום משתמש חדש
class CreateUser {
  constructor(
    private userRepo: IUserRepository,
    private emailService: IEmailService
  ) {}

  async execute(user: User): Promise<void> {
    // בדיקה עסקית פשוטה
    if (!user.name || !user.email) {
      throw new Error("Name and email are required");
    }
    // שמירת המשתמש דרך ממשק המאגר
    await this.userRepo.save(user);
    // שליחת אימייל ברכה דרך ממשק האימייל
    await this.emailService.sendWelcomeEmail(user);
  }
}

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

// מימוש ה-Repository עבור 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 collection.findOne({ id });
  }
}

// מימוש שירות האימייל (שכבת תשתית)
class SimpleEmailService implements IEmailService {
  async sendWelcomeEmail(user: User): Promise<void> {
    // כאן היינו משתמשים בספרייה לשליחת אימייל, לדוגמה nodemailer.
    console.log(`Sending welcome email to ${user.email}...`);
    // לדוגמה: await emailLibrary.send({ to: user.email, ... });
  }
}

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

// הרכבת התלויות והרצת מקרה השימוש
const userRepository = new MongoUserRepository();
const emailService = new SimpleEmailService();
const createUserUseCase = new CreateUser(userRepository, emailService);

// דוגמה להפעלת מקרה השימוש
await createUserUseCase.execute(new User("123", "דני", "[email protected]"));

אפשר לדמיין שקוד ההרצה הזה נמצא למשל בקובץ שמאתחל את השרת (נניח בקובץ הראשי של האפליקציה), או בתוך Route ספציפי. הרעיון הוא שבנקודה הזו אנחנו יוצרים את המימושים ומזריקים אותם. אם היינו משתמשים במסגרת כמו Express, היינו מפעילים את createUserUseCase.execute() מתוך ה-Controller של הנתיב הרלוונטי, ומעבירים לו את הנתונים מהבקשה.

שימו לב כיצד ה-Use Case שלנו (CreateUser) לא יודע שום דבר על MongoDB, על console.log, או על איפה האפליקציה רצה. הוא פשוט מבצע את הלוגיקה העסקית: בודק את הנתונים, שומר, ושולח אימייל דרך הממשקים שניתנו לו. עבור בדיקות אוטומטיות – אפשר ליצור אובייקט CreateUser ולהזריק לו אובייקטי דמה שמממשים את הממשקים (למשל, FakeUserRepository ששומר למערך בזיכרון, ו-FakeEmailService שלא באמת שולח כלום), וכך לבדוק את הלוגיקה בלי תלות במסד אמיתי או בסביבת אימייל. עבור פיתוח – זה אומר שאם מחר נחליט לעבור מ-MongoDB ל-PostgreSQL, נכתוב מחלקת Repository חדשה ונתחוף אותה במקום MongoUserRepository, בלי לגעת בשורה של CreateUser. בדיוק בשביל הגמישות הזו התכנסנו.

בשורה התחתונה – טיפים להטמעה מוצלחת של הגישה

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

  • הפרידו לוגיקה מתשתית מהיום הראשון: גם אם לא בונים את כל השכבות בבת אחת, כדאי לפחות ליצור הבחנה ברורה בין קוד עסקי לקוד של גישה למסד, תקשורת, וכו'. אפילו אם זה אומר לשים אותם בקבצים נפרדים או מודולים שונים – זה צעד ראשון חשוב שישתלם מהר מאוד.
  • אל תנסו לתכנן הכול מראש עד הפרט האחרון: ארכיטקטורה נקייה היא מדריך, לא חוק קשיח. ייתכן שבהתחלה לא תהיו בטוחים מהם כל מקרי השימוש או כל הממשקים שתצטרכו. זה בסדר! התחילו ממה שברור, ובנו את הארכיטקטורה בהדרגה תוך כדי למידה. עדיף ליישם 80% מהעקרונות באופן עקבי מאשר לשאוף ל-100% ולתקוע את הפרויקט.
  • ממשקים – השתמשו במידה הנכונה: כפי שאמרנו, לא לכל מחלקה צריך ממשק. התמקדו ביצירת ממשקים בגבולות שבין הלוגיקה למימוש חיצוני. אם יש לכם שירות פנימי שמשמש רק את הלוגיקה ואין סיבה להחליף אותו – אפשר לוותר על יצירת ממשק עבורו בשלבים הראשונים. המנעו מאינסוף ממשקי-סרק. במקום זאת, צרו אותם איפה שזה מעניק לכם גמישות או יכולת בדיקה.
  • Layered, but pragmatic: המטרה אינה לייצר יותר שכבות מקובצת "כי הארכיטקט אמר". המטרה היא להקל על החיים שלנו כמפתחים. אם אתם מרגישים שחלק מסוים בארכיטקטורה הנקייה מסרבל את הפיתוח בלי תועלת ברורה – בחנו את זה. ייתכן שצריך לכוונן את הפרקטיקה (אולי לאחד שתי שכבות, אולי לדחות הכנסת ספריית DI מתוחכמת לשלב מאוחר יותר). שמרו על הרוח הכללית של ההפרדה, אבל התאימו אותה לצרכים שלכם וליכולות שלכם כצוות.
  • למדו מהתנסות ושפרו בהתמדה: אחרי שמיישמים Use Case אחד או שניים, עשו רטרוספקטיבה: האם המבנה שבחרנו עוזר לנו, או שיש תוספות/שינויים שכדאי לעשות? ארכיטקטורה נקייה יכולה לחייב משמעת, אבל ברגע שרואים את היתרונות (קוד קריא, בדיקות שעוברות חלק, יכולת שינוי מהירה), המוטיבציה של הצוות תעלה. שתפו אחד את השני בדוגמאות, כתבו הנחיות פנימיות אם צריך, ותמיד היו פתוחים לשפר את הסדר בפרויקט.

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

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

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

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

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

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

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