Aggregateless ES: единый стрим и Facts вместо агрегата
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Aggregateless ES: единый стрим и Facts вместо агрегата
Вторая ката R4, самая длинная в серии. Восемь уроков подряд агрегат был героем: граница, корень, инварианты внутри. Сегодня показываем, что бывает, когда граница агрегата мешает, и зрелую альтернативу: один стрим, факт-функции, решение прямо над историей. Это не отмена агрегата, это второй инструмент в наборе.
Сцена · когда граница агрегата искажает модель
Агрегат хорош, когда инвариант живёт внутри одного корня. Бронь это агрегат: её FSM, её правила, её транзакция. Никакая команда не трогает две брони сразу.
Теперь перевод денег между счетами. Инвариант: со счёта нельзя снять больше, чем на нём есть. Он живёт на счёте-источнике. Но операция перевода трогает два счёта: списать с одного, зачислить на другой, атомарно. Куда поставить границу агрегата?
- Счёт это агрегат. Тогда перевод трогает два агрегата в одной транзакции. Классический ES запрещает это: один append, один стрим. Приходится городить сагу даже для простого перевода.
- Оба счёта в одном агрегате “Кошелёк”. Тогда граница раздувается: при тысячах счетов один агрегат на всех нелеп, и параллельные переводы по несвязанным счетам сериализуются впустую.
Обе развилки плохи, потому что мы пытаемся сначала провести границу, а потом вписать в неё инвариант. Aggregateless ES разворачивает порядок: границ нет, есть один стрим фактов, и команда сама читает из него ровно то, что нужно для решения.
Карта урока · что заберёшь через 3.5 часа
- Поймёшь, чем aggregateless отличается от классической агрегатной модели и когда он уместен.
- Заведёшь единый стрим
LedgerEventбез привязки событий к корню. - Напишешь Fact-функции (
balance_of): вопросы к стриму вместо одного агрегатного состояния. - Соберёшь command handler, который читает факты по нескольким счетам и решает атомарно.
- Поймёшь, почему это не отменяет агрегат, а дополняет его.
Концепт · Fact вместо состояния
В классическом ES (урок 29) команда делала так: загрузи стрим одного агрегата, сверни его в одно состояние через evolve, реши. Состояние было единым снимком брони.
Aggregateless убирает шаг “сверни в одно состояние”. Вместо этого команда задаёт стриму вопросы, и каждый вопрос это отдельная свёртка нужного среза. Эти вопросы называют Fact-функциями: balance_of(events, account) отвечает “сколько на счёте”, account_exists(events, account) отвечает “есть ли счёт”. Команда читает столько фактов, сколько нужно, по любым счетам, и решает.
Разница с проекцией урока 30 тонкая, но важная. Проекция материализует read-model для чтения (UI). Fact-функция читается на write-стороне, внутри decide, чтобы принять решение. Технически обе это свёртки стрима, но живут на разных сторонах CQRS.
Языковые механики Gleam · стрим это список, Fact это fold с guard
Событие лога не привязано к корню. Это просто факт про счёт:
// examples/ddd-hotel/gleam/src/aggregateless/ledger.gleam
pub type LedgerEvent {
Deposited(account: Account, amount: Amount, occurred_at: Int)
Withdrawn(account: Account, amount: Amount, occurred_at: Int)
}
Fact-функция это fold с условием по счёту. Здесь пригождается guard в case:
pub fn balance_of(events: List(LedgerEvent), account: Account) -> Amount {
list.fold(events, 0, fn(total, event) {
case event {
Deposited(acc, amount, _) if acc == account -> total + amount
Withdrawn(acc, amount, _) if acc == account -> total - amount
_ -> total
}
})
}
balance_of свернул только события этого счёта, проигнорировав чужие. Никакого агрегата “Счёт” нет, есть вопрос к общему логу. Тот же лог ответит на вопрос про любой другой счёт.
Задача · сигнатуры
Собери aggregateless/ledger.gleam:
pub fn balance_of(events: List(LedgerEvent), account: Account) -> Amount
pub fn decide(events: List(LedgerEvent), command: Command, now: Int) -> Result(List(LedgerEvent), LedgerError)
pub fn handle(events: List(LedgerEvent), command: Command, now: Int) -> Result(List(LedgerEvent), LedgerError)
Команды и ошибки:
pub type Command {
Deposit(account: Account, amount: Amount)
Transfer(from: Account, to: Account, amount: Amount)
}
pub type LedgerError {
NonPositiveAmount
InsufficientFunds(account: Account, balance: Amount, requested: Amount)
SameAccount(account: Account)
}
Контракт:
decideпринимает весь релевантный стрим, не свёрнутое состояние. Это и есть отличие от урока 29.Transferпроверяет инвариант по фактуbalance_of(events, from): хватает ли денег на источнике. Если да, порождает два события (WithdrawnплюсDeposited) атомарно.handleэтоdecideплюс append новых событий в конец стрима.
Подсказки
decideдля перевода читаетbalance_of(events, from)прямо внутри себя. Это нормально: на write-стороне факт читается, чтобы решить. Состояние “сбоку” не нужно.- Перевод порождает список из двух событий. Вспомни урок 28:
decideвозвращаетList(event)именно для таких случаев. Списание и зачисление это два факта одного решения. - Guard
if from == toловит перевод самому себе до чтения баланса: это ошибка ввода, а не нехватка денег. - Порядок проверок в
decide: сначала дешёвые синтаксические (сумма, тот же счёт), потом дорогой факт (баланс). Не читай стрим, если команда невалидна и так.
Разбор · decide над стримом
pub fn decide(
events: List(LedgerEvent),
command: Command,
now: Int,
) -> Result(List(LedgerEvent), LedgerError) {
case command {
Deposit(_, amount) if amount <= 0 -> Error(NonPositiveAmount)
Deposit(account, amount) -> Ok([Deposited(account, amount, now)])
Transfer(_, _, amount) if amount <= 0 -> Error(NonPositiveAmount)
Transfer(from, to, _) if from == to -> Error(SameAccount(from))
Transfer(from, to, amount) -> {
let from_balance = balance_of(events, from)
case from_balance >= amount {
False ->
Error(InsufficientFunds(
account: from,
balance: from_balance,
requested: amount,
))
True -> Ok([Withdrawn(from, amount, now), Deposited(to, amount, now)])
}
}
}
}
Сравни с decide агрегата урока 28. Там первым аргументом было state: Reservation (свёртка одного стрима). Здесь первый аргумент events: List(LedgerEvent) (весь лог), и состояние не материализуется: decide сам спрашивает balance_of по нужным счетам. Перевод трогает два счёта в одном решении и порождает два факта, и никакая граница агрегата этому не мешает, потому что границ нет.
Тест показывает, что баланс это факт над всем стримом, а не свойство изолированного агрегата:
// examples/ddd-hotel/gleam/test/ledger_test.gleam
pub fn balance_is_a_fact_over_the_whole_stream_test() {
let assert Ok(s1) = ledger.handle([], Deposit("alice", 10_000), now)
let assert Ok(s2) = ledger.handle(s1, Deposit("bob", 5000), now)
let assert Ok(s3) = ledger.handle(s2, Transfer("alice", "bob", 4000), now)
ledger.balance_of(s3, "alice") |> should.equal(6000)
ledger.balance_of(s3, "bob") |> should.equal(9000)
}
Два счёта в одном стриме, ни один не “владеет” своими событиями. balance_of достаёт нужный срез по запросу.
Тот же приём над стримом: префикс событий сворачивается в состояние на любой позиции:
Концепт · это не отмена агрегата
Соблазн после такого урока: “агрегаты не нужны, всё в один стрим”. Это ошибка. Aggregateless и агрегат это два инструмента под разные задачи, и выбор это эвристика, а не догма (Матиас Верраес про это пишет отдельно).
- Агрегат уместен, когда инвариант замкнут в одном корне и команды естественно сериализуются по нему. Бронь: её правила про неё одну. Тут граница агрегата помогает, а не мешает: она даёт транзакцию и optimistic concurrency бесплатно.
- Aggregateless уместен, когда инвариант охватывает несколько сущностей и искусственная граница их сшивала бы зря. Перевод между счетами, проверка уникальности email по всем пользователям, лимит по группе.
Признак, что граница искажает модель: ты заводишь агрегат-”контейнер” только ради совместной транзакции, и внутри него сущности друг с другом почти не связаны. Тогда стоит распустить границу и читать факты из общего стрима.
Цена aggregateless: нет дешёвого optimistic concurrency по одному стриму (урок 29 защищал версией). Нужна более тонкая защита (по релевантному срезу, по диапазону версий), и читать факты на каждую команду дороже, чем держать снимок. Поэтому это не вариант по умолчанию, а осознанный выбор.
Критика · что не покрыто
- Нет проверки на существование счёта.
depositна несуществующий счёт проходит (счёт “появляется” из воздуха). Реальный леджер требуетAccountOpenedи Factaccount_exists(ДЗ). - Concurrency.
handleработает над списком в памяти, без защиты от параллельной записи. В проде нужен event store с защитой по срезу, а не по одному стриму. - Один большой стрим не масштабируется бесконечно. На больших объёмах вводят партиционирование стрима и индексы по факт-запросам.
balance_of, читающий всю историю, в проде заменяют на материализованный баланс с курсором. - Money упрощён до Int. Копейки вместо
Moneyс валютой. Перевод между валютами это отдельный инвариант (курс, округление), сюда не влез.
Takeaway
Одна фраза:
Aggregateless ES снимает границу агрегата, когда инвариант охватывает несколько сущностей: один стрим фактов, Fact-функции вместо единого состояния,
decideчитает факты по нужным сущностям и решает атомарно. Это не замена агрегату, а второй инструмент: агрегат для замкнутых инвариантов, aggregateless для сквозных.
ДЗ
Дальше
Финальная ката серии · 33. Кейс Decider: Uno. Закрываем восемнадцать уроков концентратом: тот же Decider, что строил бронь, раскроем на карточной игре. Другой домен, та же форма, и видно, насколько Decider универсальнее, чем казалось на уроке 28.
Параллельно полезно перечитать:
- 29. Event Sourcing, где
decideработал над свёрнутым состоянием одного агрегата. Сегодня он работал над сырым стримом, и разница стала видна. - 20. Aggregate Reservation, про границу агрегата. Сегодня мы научились эту границу осознанно не проводить.