Skip to content

תבניות ארכיטקטורה אמינות: הפרדת קריאה וכתיבה, Outbox ויציאות ומתאמים

תאריך פרסום המקור באנגלית: 2026-06-14T10:04:02.999+03:00. תרגום ובדיקה: 28 באוקטובר 2026. זה אינו תאריך הפרסום של הגרסה העברית.

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

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

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

נבחן CQRS ו־Event Sourcing, תיבת הודעות יוצאות טרנזקציונית וארכיטקטורה משושה. כל אחת מטפלת בבעיה אחרת ומוסיפה פשרות; כדאי לבחור בהן כשהדרישות בפועל מצדיקות את המורכבות.

החוב הארכיטקטוני של ״לנוע מהר ולשבור דברים״

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

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

תבנית 1: הפרדת קריאות מכתיבות עם CQRS ו־Event Sourcing

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

למה זה חשוב עכשיו

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

הארכיטקטורה בפעולה

כשמשלבים CQRS עם Event Sourcing, במקום לשמור את המצב הנוכחי של אובייקט שומרים רצף אירועים שמשנים את מצבו.


[Client] 
   │
   ├── (Command: SubmitOrder) ──> [Write Service] ──> [Append Event] ──> [Event Store (Postgres/Kafka)]
   │                                                                           │
   │                                                                    (Async Projection)
   │                                                                           ▼
   └── (Query: GetOrderHistory) ─> [Read Service] <── [Read DB (Redis/Elasticsearch)]

ישות הזמנה המבוססת על אירועים להמחשה ב־JavaScript

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


// A simple implementation of an Event-Sourced Order Entity
class Order {
  constructor() {
    this.orderId = null;
    this.status = 'NEW';
    this.items = [];
    this.total = 0;
    this.changes = []; // Track uncommitted events
  }

  // Command Handler
  createOrder(orderId, items, total) {
    if (this.status !== 'NEW') throw new Error('Order already created.');
    this.applyChange({
      type: 'ORDER_CREATED',
      payload: { orderId, items, total, timestamp: Date.now() }
    });
  }

  payOrder() {
    if (this.status !== 'CREATED') throw new Error("Order cannot be paid.");
    this.applyChange({
      type: 'ORDER_PAID',
      payload: { orderId: this.orderId, timestamp: Date.now() }
    });
  }

  // Apply state changes
  applyChange(event, isNew = true) {
    this.playback(event);
    if (isNew) this.changes.push(event);
  }

  // The Rehydration Logic (State Projection)
  playback(event) {
    switch (event.type) {
      case 'ORDER_CREATED':
        this.orderId = event.payload.orderId;
        this.items = event.payload.items;
        this.total = event.payload.total;
        this.status = 'CREATED';
        break;
      case 'ORDER_PAID':
        this.status = 'PAID';
        break;
    }
  }
}

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

תבנית 2: תיבת הודעות יוצאות טרנזקציונית והעברה אמינה של אירועים

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

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

הפתרון: תבנית Outbox

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

דוגמה לסכמת מסד נתונים

כך יכולה להיראות סכמת SQL התומכת בתבנית:


-- The primary business entity table
CREATE TABLE users (
    id UUID PRIMARY KEY,
    email VARCHAR(255) NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- The transactional outbox table
CREATE TABLE outbox_events (
    id UUID PRIMARY KEY,
    aggregate_type VARCHAR(100) NOT NULL,
    aggregate_id VARCHAR(100) NOT NULL,
    event_type VARCHAR(100) NOT NULL,
    payload JSONB NOT NULL,
    status VARCHAR(50) DEFAULT 'PENDING', -- PENDING, PROCESSED, FAILED
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

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


BEGIN TRANSACTION;

INSERT INTO users (id, email) 
VALUES ('d3b07384-d113-4956-a534-1123456789ab', 'alex@sysseder.com');

INSERT INTO outbox_events (id, aggregate_type, aggregate_id, event_type, payload)
VALUES (
    'e5c18495-e224-5067-b645-2234567890bc', 
    'User', 
    'd3b07384-d113-4956-a534-1123456789ab', 
    'USER_REGISTERED', 
    '{"email": "alex@sysseder.com", "userId": "d3b07384-d113-4956-a534-1123456789ab"}'
);

COMMIT;

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

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

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

חשבו על מעבר מ־MongoDB ל־PostgreSQL, או מ־Express.js ל־Fastify, שמסתבך מפני ששאילתות ואובייקטי בקשה ייחודיים למסגרת מפוזרים בכל הקוד. ארכיטקטורה משושה מונעת את התלות הזאת באמצעות הגדרת ממשקים, המכונים יציאות, ומימושים, המכונים מתאמים.

ממה מורכב המשושה

  • ליבת התחום: כללים עסקיים שאינם תלויים במתאמי תשתית או ממשק. אין איסור גורף על ספריות שפה או ספריות מתאימות לתחום.
  • יציאות: ממשקים המגדירים כיצד נתונים נכנסים לליבה ויוצאים ממנה, למשל UserRepository.
  • מתאמים: המימושים בפועל של הממשקים, למשל PostgresUserRepository או RESTExpressController.

מימוש יציאות ומתאמים ב־TypeScript

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


// 1. PORT (Interface) - Must be stored in the Domain boundary
interface UserRepository {
  findById(id: string): Promise<User | null>;
  save(user: User): Promise<void>;
}

// 2. DOMAIN ENTITY (Core Business Logic)
class User {
  constructor(public readonly id: string, public email: string) {}
  
  changeEmail(newEmail: string) {
    if (!newEmail.includes('@')) {
      throw new Error("Invalid email format");
    }
    this.email = newEmail;
  }
}

// 3. ADAPTER (External Dependency Implementation)
class PostgresUserRepository implements UserRepository {
  async findById(id: string): Promise<User | null> {
    // Imagine actual DB queries running here via Knex/Prisma/pg
    console.log(`Querying Postgres for user: ${id}`);
    return new User(id, "alex@sysseder.com");
  }

  async save(user: User): Promise<void> {
    console.log(`Saving user ${user.id} to Postgres`);
  }
}

// 4. USE CASE (orchestrator using the Port, not the Adapter)
class ChangeEmailUseCase {
  constructor(private userRepository: UserRepository) {}

  async execute(userId: string, newEmail: string): Promise<void> {
    const user = await this.userRepository.findById(userId);
    if (!user) throw new Error("User not found");
    
    user.changeEmail(newEmail);
    await this.userRepository.save(user);
  }
}

// How we wire it all together (Dependency Injection)
const dbAdapter = new PostgresUserRepository();
const emailService = new ChangeEmailUseCase(dbAdapter);

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

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

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

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

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

עד הפעם הבאה, פיתוח נעים!

מקרא התרשים: Client — לקוח; Command — פקודה; Write Service — שירות כתיבה; Append Event — הוספת אירוע; Event Store — מאגר אירועים; Async Projection — בניית תצוגה אסינכרונית; Query — שאילתה; Read Service — שירות קריאה; Read DB — מסד קריאה. שמות הקוד והערותיו נשמרו באנגלית כדי לשמר את הדוגמה המקורית. הערת דיוק לגרסה המתוקנת: מתווך הודעות לבדו אינו מאגר אירועים, ואירועי דומיין אינם מחייבים Event Sourcing.

מקורות ראשוניים לבדיקה

Post a Comment

Previous Post Next Post