Раздел 27 · Gleam на практике

Aggregateless ES: единый стрим и Facts вместо агрегата

lead~35 мин

открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

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 и Fact account_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, про границу агрегата. Сегодня мы научились эту границу осознанно не проводить.