Ddd

Моделювання фактора часу

Event Sourcing pattern для моделювання темпоральної dimensії в Domain-Driven Design

Моделювання фактора часу (Modeling the Dimension of Time)

Ключова ідея главиТрадиційні підходи зберігають поточний стан об'єктів. Але іноді бізнесу потрібна історія змін — як об'єкт досяг свого поточного стану? Саме тут допомагає Event Sourcing.

Введення: Що відсутнє?

Розглянемо таблицю системи управління потенційними клієнтами (leads):

Loading diagram...
erDiagram
    LEADS {
        int id PK
        string first_name
        string last_name
        string status
        string phone
        datetime followup_on
        datetime created_at
        datetime updated_at
    }

Приклад даних

IDFirst NameLast NameStatusPhoneFollowup OnCreated AtUpdated At
1JohnDoeNEW_LEAD555-1234NULL2019-11-192019-11-19
2JaneSmithCONVERTED555-5678NULL2019-11-202019-11-25
12CaseyDavisCONVERTED555-8101NULL2020-05-202020-05-27
Питання для роздумівТаблиця документує поточний стан лідів. Але чого в ній не вистачає?Ми можемо бачити:
  • ✅ Поточний статус кожного ліда
  • ✅ Контактну інформацію
  • ✅ Дату створення й оновлення
Але не можемо проаналізувати:
  • ❌ Скільки дзвінків було зроблено до конверсії?
  • ❌ Чи була покупка здійснена одразу, чи була довга воронка продажів?
  • ❌ Чи варто продовжувати намагатися з лідом після N спроб?

Проблема: Відсутня історична інформація

Таблиця відображає snapshot поточного стану, але в ній відсутня інформація про те, як кожен лід досяг свого стану:

Loading diagram...
graph LR
    Q1[NEW_LEAD] -->|???| Q2[FOLLOWUP_SET]
    Q2 -->|???| Q3[PENDING_PAYMENT]
    Q3 -->|???| Q4[CONVERTED]

    style Q1 fill:#ffcdd2
    style Q2 fill:#fff3e0
    style Q3 fill:#e3f2fd
    style Q4 fill:#c8e6c9
Бізнес-потребиЗ точки зору бізнесу критично важливо:
  • 📊 Аналізувати дані
  • 🎯 Оптимізувати процес продажів
  • 📈 В мчитися на досвіді
Один із способів заповнити відсутню інформацію — використовувати Event Sourcing.

Події як джерело даних (Event Sourcing)

Event Sourcing вводить в модель даних фактор часу. Замість схеми, що відображає поточний стан агрегатів, система зберігає події, що фіксують кожну зміну в життєвому циклі агрегата.

Loading diagram...
graph TB
    subgraph "Traditional State-Based"
        DB1[(Current State<br/>Database)]
        UPDATE[UPDATE operations]
        UPDATE -->|overwrite| DB1
    end

    subgraph "Event Sourcing"
        ES[(Event Store<br/>Append-Only)]
        EVENTS[Domain Events]
        EVENTS -->|append| ES
        ES -->|project| STATE[Current State]
    end

    style DB1 fill:#ffcdd2
    style UPDATE fill:#ffcdd2
    style ES fill:#c8e6c9
    style EVENTS fill:#c8e6c9
    style STATE fill:#e3f2fd

Приклад: Життєвий цикл ліда Casey Davis

Розглянемо CONVERTED-клієнта з ID 12. В Event Sourcing його дані виглядають так:

// Event 0: Lead initialized
{
  "lead-id": 12,
  "event-id": 0,
  "event-type": "lead-initialized",
  "first-name": "Casey",
  "last-name": "David", // Помилка в прізвищі!
  "phone-number": "555-2951",
  "timestamp": "2020-05-20T09:52:55.95Z"
}

// Event 1: First contact
{
  "lead-id": 12,
  "event-id": 1,
  "event-type": "contacted",
  "timestamp": "2020-05-20T12:32:08.24Z"
}

// Event 2: Followup scheduled
{
  "lead-id": 12,
  "event-id": 2,
  "event-type": "followup-set",
  "followup-on": "2020-05-27T12:00:00.00Z",
  "timestamp": "2020-05-20T12:32:08.24Z"
}

// Event 3: Contact details corrected
{
  "lead-id": 12,
  "event-id": 3,
  "event-type": "contact-details-updated",
  "first-name": "Casey",
  "last-name": "Davis", // ✅ Виправлено
  "phone-number": "555-8101", // Новий номер
  "timestamp": "2020-05-20T12:32:08.24Z"
}

// Event 4: Second contact
{
  "lead-id": 12,
  "event-id": 4,
  "event-type": "contacted",
  "timestamp": "2020-05-27T12:02:12.51Z"
}

// Event 5: Order submitted
{
  "lead-id": 12,
  "event-id": 5,
  "event-type": "order-submitted",
  "payment-deadline": "2020-05-30T12:02:12.51Z",
  "timestamp": "2020-05-27T12:02:12.51Z"
}

// Event 6: Payment confirmed
{
  "lead-id": 12,
  "event-id": 6,
  "event-type": "payment-confirmed",
  "status": "converted",
  "timestamp": "2020-05-27T12:38:44.12Z"
}
Історія клієнтаПодії розповідають повну історію:
  1. 09:52 – Лід створено в системі
  2. 📞 12:32 – Дзвінок торгового агента (через ~2.5 години)
  3. 📅 12:32 – Followup заплановано на наступний тиждень
  4. ✏️ 12:32 – Виправлено помилку в прізвищі + новий телефон
  5. 📞 27.05 12:02 – Повторний дзвінок (в запланований час)
  6. 🛒 12:02 – Замовлення відправлено
  7. 12:38 – Оплата підтверджена (~30 хв) → CONVERTED!
Це набагато багатша інформація, ніж просто "status = CONVERTED"!

Проекція стану (State Projection)

Стан клієнта можна легко спроекту вати зі подій, послідовно додаючи логіку перетворення до кожної події:

public class LeadProjection
{
    public long LeadId { get; private set; }
    public string FirstName { get; private set; }
    public string LastName { get; private set; }
    public string PhoneNumber { get; private set; }
    public LeadStatus Status { get; private set; }
    public int Version { get; private set; } // Лічильник модифікацій

    public void Apply(LeadInitialized @event)
    {
        LeadId = @event.LeadId;
        FirstName = @event.FirstName;
        LastName = @event.LastName;
        PhoneNumber = @event.PhoneNumber;
        Status = LeadStatus.NEW_LEAD;
        Version = 0;
    }

    public void Apply(ContactDetailsChanged @event)
    {
        FirstName = @event.FirstName;
        LastName = @event.LastName;
        PhoneNumber = @event.PhoneNumber;
        Version += 1;
    }

    public void Apply(Contacted @event)
    {
        Version += 1;
    }

    public void Apply(FollowupSet @event)
    {
        Status = LeadStatus.FOLLOWUP_SET;
        Version += 1;
    }

    public void Apply(OrderSubmitted @event)
    {
        Status = LeadStatus.PENDING_PAYMENT;
        Version += 1;
    }

    public void Apply(PaymentConfirmed @event)
    {
        Status = LeadStatus.CONVERTED;
        Version += 1;
    }
}

Поле Version: Лічильник модифікацій

Version як audit trailVersion представляє загальну кількість модифікацій, внесених у бізнес-сутність.Можливість: Якщо додати тільки підmножину подій → «подорож у часі» (time travel)!Приклад: Щоб отримати стан об'єкта у версії 3, застосовуємо тільки перші 3 події.

Кілька проекцій з одних подій

Потужність Event SourcingПодії є єдиним джерелом істини. З них можна створити кілька різних проекцій для різних потреб!

Проекція 1: Пошукова модель (Search Model)

Бізнес-потреба: Торгові агенти can не знати про зміни контактної інформації, внесені іншими агентами. Потрібен пошук за історичними значеннями.

public class LeadSearchModelProjection
{
    public long LeadId { get; private set; }
    public HashSet<string> FirstNames { get; private set; } // ✅ Всі варіанти
    public HashSet<string> LastNames { get; private set; }  // ✅ Всі варіанти
    public HashSet<PhoneNumber> PhoneNumbers { get; private set; }
    public int Version { get; private set; }

    public void Apply(LeadInitialized @event)
    {
        LeadId = @event.LeadId;
        FirstNames = new HashSet<string>();
        LastNames = new HashSet<string>();
        PhoneNumbers = new HashSet<PhoneNumber>();

        // Додаємо початкові значення
        FirstNames.Add(@event.FirstName);
        LastNames.Add(@event.LastName);
        PhoneNumbers.Add(@event.PhoneNumber);
        Version = 0;
    }

    public void Apply(ContactDetailsChanged @event)
    {
        // ✅ Додаємо до множини, не перезаписуємо!
        FirstNames.Add(@event.FirstName);
        LastNames.Add(@event.LastName);
        PhoneNumbers.Add(@event.PhoneNumber);
        Version += 1;
    }

    // Інші події ігноруємо (не впливають на пошук)
    public void Apply(Contacted @event) { Version += 1; }
    public void Apply(FollowupSet @event) { Version += 1; }
    public void Apply(OrderSubmitted @event) { Version += 1; }
    public void Apply(PaymentConfirmed @event) { Version += 1; }
}

Результат для Casey Davis:

LeadId: 12
FirstNames: ["Casey"]
LastNames: ["David", "Davis"] // ✅ Обидва варіанти!
PhoneNumbers: ["555-2951", "555-8101"] // ✅ Обидва номери!
Version: 6
Переваги для бізнесуАгент може знайти ліда за старим прізвищем "David" або старим телефоном "555-2951", навіть якщо вони вже змінилися!

Проекція 2: Analytical Model

Бізнес-потреба: Відділ аналітики хоче кількість followup calls для оптимізації процесу продажів.

public class AnalysisModelProjection
{
    public long LeadId { get; private set; }
    public int Followups { get; private set; } // ✅ Лічильник followups
    public LeadStatus Status { get; private set; }
    public int Version { get; private set; }

    public void Apply(LeadInitialized @event)
    {
        LeadId = @event.LeadId;
        Followups = 0;
        Status = LeadStatus.NEW_LEAD;
        Version = 0;
    }

    public void Apply(FollowupSet @event)
    {
        Status = LeadStatus.FOLLOWUP_SET;
        Followups += 1; // ✅ Підрахор followups!
        Version += 1;
    }

    public void Apply(OrderSubmitted @event)
    {
        Status = LeadStatus.PENDING_PAYMENT;
        Version += 1;
    }

    public void Apply(PaymentConfirmed @event)
    {
        Status = LeadStatus.CONVERTED;
        Version += 1;
    }

    // Інші Игрноруємо
    public void Apply(Contacted @event) { Version += 1; }
    public void Apply(ContactDetailsChanged @event) { Version += 1; }
}

Результат для Casey Davis:

LeadId: 12
Followups: 1
Status: CONVERTED
Version: 6
АналітикаАналітики можуть:
  • Порівнювати кількість followups для CONVERTED vs CLOSED лідів
  • Визначати оптимальну кількість спроб
  • Фільтрувати за статусами для ML-моделей

Джерело істини (Source of Truth)

Золоте правило Event SourcingВсі зміни стану об'єкта мають бути представлені і збережені як події. Події стають для системи джерелом істини (source of truth).
Loading diagram...
graph TB
    CMD[Command] -->|Execute| AR[Aggregate]
    AR -->|Generate| EVENTS[Domain Events]
    EVENTS -->|Append| ES[(Event Store<br/>SOURCE OF TRUTH)]

    ES -->|Project| PROJ1[Projection 1<br/>Read Model DB]
    ES -->|Project| PROJ2[Projection 2<br/>Elasticsearch]
    ES -->|Project| PROJ3[Projection 3<br/>Analytics DB]

    style ES fill:#4caf50
    style PROJ1 fill:#e3f2fd
    style PROJ2 fill:#e3f2fd
    style PROJ3 fill:#e3f2fd

База даних, де зберігаються події, є єдиним строго узгодженим сховищем (single source of truth).

Event StoreБаза даних для збереження подій називається event store (сховище подій).

Event Store: Вимоги

Event Store не має дозволяти змінювати або видаляти події (за винятком виключних випадків, як міграція даних).

Мінімальний інтерфейс

interface IEventStore
{
    // ✅ Завантаження всіх подій конкретної сутності
    IEnumerable<Event> Fetch(Guid instanceId);

    // ✅ Додавання нових подій
    void Append(Guid instanceId, Event[] newEvents, int expectedVersion);
}
Аналогія: Бухгалтерський облікПо своїй суті, Event Sourcing не є чимось новим. Фінансова індустрія використовує події для представлення змін у бухгалtерському реєстрі.Реєстр (ledger) — це журнал, призначений тільки для додавання записів, в якому документуються транзакції. Поточний стан (наприклад, баланс рахунку) завжди можна вивести шляхом «проекції» записів реєстру.

Event-Based Domain Model

Модель предметної області, що використовує Event Sourcing, працює за модифікованим сценарієм:

Крок 1: Завантаження подій

Завантажити події предметної області (domain events) aggregate з Event Store.

Крок 2: Реконструкція стану

Voссоздати стан — спроекту вати події у представлення стану для прийняття бізнес-рішень.

Крок 3: Виконання команди

Виконати команду бізнес-логіки aggregate → згенерувати нові domain events.

Крок 4: Фіксація подій

Зафіксирувати нові domain events у Event Store.

Приклад: Ticket Aggregate (Event-Sourced)

Application Layer слідує сценарію вище:

public class TicketAPI
{
    private IEventStore _eventStore;

    public void RequestEscalation(TicketId id, EscalationReason reason)
    {
        // ✅ Крок 1: Завантажуємо події
        var events = _eventStore.Fetch(id);

        // ✅ Крок 2: Реконструюємо стан
        var ticket = new Ticket();
        foreach (var @event in events)
        {
            ticket.Apply(@event);
        }

        // ✅ Крок 3: Виконуємо команду
        ticket.RequestEscalation(reason);

        // ✅ Крок 4: Зберігаємо нові події
        var newEvents = ticket.GetUncommittedEvents();
        _eventStore.Append(id, newEvents, ticket.Version);
    }
}

Ticket Aggregate (Event-Sourced):

public class Ticket
{
    private TicketId _id;
    private TicketStatus _status;
    private Priority _priority;
    private List<IDomainEvent> _uncommittedEvents = new();
    public int Version { get; private set; }

    // ✅ State reconstruction від подій
    public void Apply(TicketInitialized @event)
    {
        _id = @event.TicketId;
        _status = TicketStatus.Open;
        _priority = @event.Priority;
        Version++;
    }

    public void Apply(TicketEscalated @event)
    {
        _status = TicketStatus.Escalated;
        _priority = IncreasePriority(_priority);
        Version++;
    }

    // ✅ Command: генерує події
    public void RequestEscalation(EscalationReason reason)
    {
        if (_status == TicketStatus.Closed)
        {
            throw new InvalidOperationException("Cannot escalate closed ticket");
        }

        // Генеруємо подію замість прямої зміни стану
        var @event = new TicketEscalated(_id, reason, DateTime.UtcNow);
        _uncommittedEvents.Add(@event);

        // Застосовуємо до поточного стану
        Apply(@event);
    }

    public IReadOnlyList<IDomainEvent> GetUncommittedEvents()
    {
        return _uncommittedEvents;
    }
}
Ключова відмінністьУ традиційній Domain Model команди безпосередньо змінюють стан.В Event-Sourced Domain Model команди генерують події, які:
  1. Додаються до uncommitted events
  2. Застосовуються до стану (через Apply())
  3. Зберігаються у Event Store

Snapshots: Оптимізація продуктивності

Проблема масштабуЯкщо aggregate має тисячі подій → завантаження і проекція стають повільними.Рішення: Snaposhots (знімки стану).

Механізм Snapshots

Loading diagram...
graph LR
    E1[Event 1] --> E2[Event 2]
    E2 --> E3[...]
    E3 --> E100[Event 100]
    E100 --> SNAP[Snapshot v100]
    SNAP --> E101[Event 101]
    E101 --> E102[...]
    E102 --> E150[Event 150]

    style SNAP fill:#4caf50

Замість завантаження всіх 150 подій:

  1. Завантажуємо snapshot версії 100
  2. Застосовуємо тільки події 101-150
public void LoadAggregate(Guid aggregateId)
{
    // ✅ Спочатку пробуємо завантажити snapshot
    var snapshot = _snapshotStore.GetLatest(aggregateId);

    int fromVersion = 0;
    if (snapshot != null)
    {
        ticket.LoadFromSnapshot(snapshot);
        fromVersion = snapshot.Version;
    }

    // ✅ Завантажуємо тільки події ПІСЛЯ snapshot
    var events = _eventStore.Fetch(aggregateId, fromVersion);
    foreach (var @event in events)
    {
        ticket.Apply(@event);
    }
}

Переваги Event Sourcing

Audit Trail

Time Travel

Кілька проекцій

Debugging

Бізнес-інсайти


Недоліки Event Sourcing

Складність реалізаціїLearning curve — команди потребують навчання
Event versioning — еволюція схеми подій складна
Projections — потрібна інфраструктура для підтримки
Eventual consistency — read models не завжди актуальні
Debugging — складніше, ніж traditional CRUD
Операційні викликиStorage — події займають більше місця, ніж state-based
Event Store — потребує спеціалізованих рішень (EventStoreDB, Kafka)
Snapshots — додаткова складність і storage

Коли використовувати Event Sourcing?

Підходить для

НЕ підходить для


Інтеграція з CQRS

Event Sourcing природно поєднується з CQRS (Command Query Responsibility Segregation):

Loading diagram...
graph TB
    CMD[Commands] -->|Write| AG[Aggregate]
    AG -->|Generate| EV[Events]
    EV -->|Append| ES[(Event Store)]

    ES -->|Project| RM1[Read Model 1]
    ES -->|Project| RM2[Read Model 2]
    ES -->|Project| RM3[Read Model N]

    QUERY[Queries] -->|Read| RM1
    QUERY -->|Read| RM2
    QUERY -->|Read| RM3

    style ES fill:#4caf50
    style AG fill:#fff3e0
    style RM1 fill:#e3f2fd
    style RM2 fill:#e3f2fd
    style RM3 fill:#e3f2fd
Наступна главаCQRS (Command Query Responsibility Segregation) буде детально розглянуто в Главі 8.Event Sourcing + CQRS = потужна комбінація для складних систем!

Висновок

У цій главі розглянули паттерн Event Sourcing для моделювання темпоральної розмірності:

Події як джерело істини

Замость поточного стану зберігаємо всі зміни як події.

Projections

З подій створюємо кілька різних представлень для різних потреб.

Event Store

Append-only БД є єдиним джерелом істини.

Event-Based Aggregates

Команди генерують події, які застосовуються до стану.

Snapshots

Оптимізація через періодичні знімки стану.

Прогресія глав 5-7
  • Глава 5: Прості паттерни (Transaction Script, Active Record)
  • Глава 6: Складна логіка (Domain Model, Aggregates)
  • Глава 7: Темпоральність (Event Sourcing)
Разом ці паттерни покривають весь спектр складності бізнес-логіки!

Практичні вправи

::

У вас є aggregate з 10,000 подій. Завантаження всіх подій займає 5 секунд.

Питання:

  • Як часто створювати snapshots?
  • Який trade-off між частотою snapshots і storage?

::

Copyright © 2026