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

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

אם בפעם הקודמת הכרנו את הארכיטקטורה הנקייה (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? מה עבד לכם טוב, ומה פחות?