Моделювання фактора часу
Моделювання фактора часу (Modeling the Dimension of Time)
Введення: Що відсутнє?
Розглянемо таблицю системи управління потенційними клієнтами (leads):
Приклад даних
| ID | First Name | Last Name | Status | Phone | Followup On | Created At | Updated At |
|---|---|---|---|---|---|---|---|
| 1 | John | Doe | NEW_LEAD | 555-1234 | NULL | 2019-11-19 | 2019-11-19 |
| 2 | Jane | Smith | CONVERTED | 555-5678 | NULL | 2019-11-20 | 2019-11-25 |
| 12 | Casey | Davis | CONVERTED | 555-8101 | NULL | 2020-05-20 | 2020-05-27 |
- ✅ Поточний статус кожного ліда
- ✅ Контактну інформацію
- ✅ Дату створення й оновлення
- ❌ Скільки дзвінків було зроблено до конверсії?
- ❌ Чи була покупка здійснена одразу, чи була довга воронка продажів?
- ❌ Чи варто продовжувати намагатися з лідом після N спроб?
Проблема: Відсутня історична інформація
Таблиця відображає snapshot поточного стану, але в ній відсутня інформація про те, як кожен лід досяг свого стану:
- 📊 Аналізувати дані
- 🎯 Оптимізувати процес продажів
- 📈 В мчитися на досвіді
Події як джерело даних (Event Sourcing)
Event Sourcing вводить в модель даних фактор часу. Замість схеми, що відображає поточний стан агрегатів, система зберігає події, що фіксують кожну зміну в життєвому циклі агрегата.
Приклад: Життєвий цикл ліда 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"
}
- ⏰ 09:52 – Лід створено в системі
- 📞 12:32 – Дзвінок торгового агента (через ~2.5 години)
- 📅 12:32 – Followup заплановано на наступний тиждень
- ✏️ 12:32 – Виправлено помилку в прізвищі + новий телефон
- 📞 27.05 12:02 – Повторний дзвінок (в запланований час)
- 🛒 12:02 – Замовлення відправлено
- ✅ 12:38 – Оплата підтверджена (~30 хв) → 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;
}
}
Процес:
- Завантажуємо всі події ліда
- Передаємо кожну подію у відповідний метод
Apply() - Projection accumulates state
- Отримуємо точне відображення поточного стану
Результат для Casey Davis:
LeadId: 12
FirstName: "Casey"
LastName: "Davis"
PhoneNumber: "555-8101"
Status: CONVERTED
Version: 6
Поле Version: Лічильник модифікацій
Version представляє загальну кількість модифікацій, внесених у бізнес-сутність.Можливість: Якщо додати тільки підmножину подій → «подорож у часі» (time travel)!Приклад: Щоб отримати стан об'єкта у версії 3, застосовуємо тільки перші 3 події.Кілька проекцій з одних подій
Проекція 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
Проекція 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)
База даних, де зберігаються події, є єдиним строго узгодженим сховищем (single source of truth).
Event Store: Вимоги
Event Store не має дозволяти змінювати або видаляти події (за винятком виключних випадків, як міграція даних).
Мінімальний інтерфейс
interface IEventStore
{
// ✅ Завантаження всіх подій конкретної сутності
IEnumerable<Event> Fetch(Guid instanceId);
// ✅ Додавання нових подій
void Append(Guid instanceId, Event[] newEvents, int expectedVersion);
}
Мета: Реалізація optimistic concurrency control.
При додаванні нових подій вказується версія сутності, на якій базуються рішення.
Якщо сутність застаріла (після expectedVersion були додані нові події іншим процесом) → Event Store має порушувати concurrency exception.
Приклад:
// Process A
var events = eventStore.Fetch(leadId); // version = 5
var lead = new Lead();
foreach (var e in events) lead.Apply(e);
lead.Escalate(); // Генерує нову подію
eventStore.Append(leadId, lead.NewEvents, expectedVersion: 5);
// ✅ Успіх, якщо version все ще = 5
// Process B (concurrent)
eventStore.Append(leadId, otherEvents, expectedVersion: 5);
// ❌ Exception! Version вже = 6 (Process A випередив)
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;
}
}
- Додаються до uncommitted events
- Застосовуються до стану (через
Apply()) - Зберігаються у Event Store
Snapshots: Оптимізація продуктивності
Механізм Snapshots
Замість завантаження всіх 150 подій:
- Завантажуємо snapshot версії 100
- Застосовуємо тільки події 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);
}
}
Кожні N подій:
if (ticket.Version % 100 == 0)
{
_snapshotStore.Save(ticket.ToSnapshot());
}
За розкладом:
- Асинхронний процес створює snapshots у фоні
On-demand:
- Якщо завантаження > X секунд → trigger snapshot creation
Переваги Event Sourcing
Audit Trail
Time Travel
Кілька проекцій
Debugging
Бізнес-інсайти
Недоліки Event Sourcing
❌ Event versioning — еволюція схеми подій складна
❌ Projections — потрібна інфраструктура для підтримки
❌ Eventual consistency — read models не завжди актуальні
❌ Debugging — складніше, ніж traditional CRUD
❌ Event Store — потребує спеціалізованих рішень (EventStoreDB, Kafka)
❌ Snapshots — додаткова складність і storage
Коли використовувати Event Sourcing?
Підходить для
НЕ підходить для
Інтеграція з CQRS
Event Sourcing природно поєднується з CQRS (Command Query Responsibility Segregation):
Висновок
У цій главі розглянули паттерн Event Sourcing для моделювання темпоральної розмірності:
Події як джерело істини
Замость поточного стану зберігаємо всі зміни як події.
Projections
З подій створюємо кілька різних представлень для різних потреб.
Event Store
Append-only БД є єдиним джерелом істини.
Event-Based Aggregates
Команди генерують події, які застосовуються до стану.
Snapshots
Оптимізація через періодичні знімки стану.
- Глава 5: Прості паттерни (Transaction Script, Active Record)
- Глава 6: Складна логіка (Domain Model, Aggregates)
- Глава 7: Темпоральність (Event Sourcing)
Практичні вправи
Дано події банківського рахунку:
[
{ "type": "account-opened", "balance": 0 },
{ "type": "deposited", "amount": 1000 },
{ "type": "withdrawn", "amount": 200 },
{ "type": "deposited", "amount": 500 },
{ "type": "withdrawn", "amount": 300 }
]
Питання:
- Який поточний баланс?
- Скількиопераццій було виконано?
- Який був баланс між подією 2 і 3?
- Поточний баланс: 0 + 1000 - 200 + 500 - 300 = 1000
- Кількість операцій: 4 (excluding account-opened)
- Баланс після події 2: 0 + 1000 = 1000
::
У вас є aggregate з 10,000 подій. Завантаження всіх подій займає 5 секунд.
Питання:
- Як часто створювати snapshots?
- Який trade-off між частотою snapshots і storage?
Варіанти:
A) Кожні 100 подій:
- 100 snapshots по 10KB = 1MB
- Завантаження: snapshot + 100 подій ≈ 0.05s
B) Кожні 1000 подій:
- 10 snapshots по 10KB = 100KB
- Завантаження: snapshot + 1000 подій ≈ 0.5s
C) Кожні 5000 подій:
- 2 snapshots по 10KB = 20KB
- Завантаження: snapshot + 5000 подій ≈ 2.5s
Рекомендація: Варіант B — баланс між storage і performance.
::