Автомат состояний спроектирован: нарисована диаграмма, известны состояния заказа, события и разрешённые переходы. Но диаграмма на бумаге ничего не гарантирует сама по себе, всё решает то, как вы перенесёте её в код. Легко написать так, что «случайно» окажется возможным перейти из отменённого заказа в оплаченный, или что две горутины одновременно сделают взаимоисключающие переходы. Задача одна: воплотить автомат так, чтобы недопустимые переходы были невозможны, а не просто «не предусмотрены». Способов несколько, от именованного типа со switch до готовой библиотеки; разберём их по нарастанию сложности и подскажем, что когда брать. Все примеры ниже собраны и прогнаны тестами на Go 1.26.
Именованный тип и switch
Самый прямой способ: состояния и события это константы именованного типа, а логика перехода это одна функция со switch, которая по текущему состоянию и событию решает, куда двигаться дальше. В Go нет перечислений, поэтому роль enum играет тип поверх строки: в базе и в логах он читается как есть, а компилятор не даст передать событие туда, где ждут состояние.
type State string
const (
New State = "NEW"
Paid State = "PAID"
Shipped State = "SHIPPED"
Delivered State = "DELIVERED"
Cancelled State = "CANCELLED"
)
type Event string
const (
Pay Event = "PAY"
Ship Event = "SHIP"
Deliver Event = "DELIVER"
Cancel Event = "CANCEL"
)
type IllegalTransitionError struct {
From State
Event Event
}
func (e *IllegalTransitionError) Error() string {
return fmt.Sprintf("переход из %s по событию %s запрещён", e.From, e.Event)
}
func Next(current State, event Event) (State, error) {
switch current {
case New:
switch event {
case Pay:
return Paid, nil
case Cancel:
return Cancelled, nil
}
case Paid:
switch event {
case Ship:
return Shipped, nil
case Cancel:
return Cancelled, nil
}
case Shipped:
if event == Deliver {
return Delivered, nil
}
}
return "", &IllegalTransitionError{From: current, Event: event}
}
Запрещённый переход это не паника и не error.New со строкой, а свой тип ошибки: обработчик HTTP узнаёт его через errors.As и отвечает 409 с кодом, а остальные ошибки остаются пятисоткой. Так устроен и сервис платежей практикума: там MoveTo возвращает *InvalidTransitionError, и ручка отличает «нельзя» от «сломалось».
Плюсы. Ничего не нужно подключать, всё видно в одном месте, читается сверху вниз. Для маленького автомата это идеальный вариант.
Про помощь компилятора надо сказать честно, потому что в Go её меньше, чем кажется. Java заставит перечислить все константы в switch без default; Go так не умеет: добавите шестое состояние, и код соберётся как ни в чём не бывало, а новое состояние тихо провалится мимо всех веток к IllegalTransitionError. Эту дыру закрывает линтер exhaustive из golangci-lint: он знает, что константы типа State образуют перечисление, и краснеет на switch, где какая-то из них не разобрана. Без него полнота переходов держится только на тестах, о которых ниже.
Минусы. Как только состояний и событий становится много, switch разрастается во вложенную «лесенку», в которой легко ошибиться и трудно увидеть картину целиком. Логика перехода размазана по коду, а не описана как данные: новую связку «состояние плюс событие» приходится вписывать руками в нужную ветку. Пока переходов десяток, терпимо, дальше становится больно.
Таблица переходов
Следующий шаг: вынести правила из кода в данные. Автомат это по сути таблица: для пары «состояние плюс событие» указано новое состояние. В Go такая таблица это map с ключом-структурой: структура из двух сравнимых полей сама годится в ключ, никаких hashCode писать не надо.
type transition struct {
from State
event Event
}
var table = map[transition]State{
{New, Pay}: Paid,
{New, Cancel}: Cancelled,
{Paid, Ship}: Shipped,
{Paid, Cancel}: Cancelled,
{Shipped, Deliver}: Delivered,
}
func Next(current State, event Event) (State, error) {
target, ok := table[transition{current, event}]
if !ok {
return "", &IllegalTransitionError{From: current, Event: event}
}
return target, nil
}
Теперь весь автомат виден одним взглядом, это буквально список разрешённых переходов. Чтобы изменить правила, не нужно трогать логику: добавили или убрали строку в таблице, и поведение поменялось.
Но у правил-данных есть та же слабость, что у switch: забытая пара «состояние плюс событие» никак себя не проявит, она просто окажется запрещённой, и никто этого не заметит. Поэтому таблицу покрывают тестом на полноту. Для перебора нужен список всех состояний и всех событий, а у Go нет values() у типа: список держат рядом с константами, и это единственное место, которое надо не забыть пополнить.
var AllStates = []State{New, Paid, Shipped, Delivered, Cancelled}
var AllEvents = []Event{Pay, Ship, Deliver, Cancel}
var forbidden = map[transition]bool{
{New, Ship}: true, {New, Deliver}: true,
// ...и так далее: каждый запрет записан осознанно
}
func TestEveryPairIsDecided(t *testing.T) {
for _, s := range AllStates {
for _, e := range AllEvents {
pair := transition{s, e}
_, allowed := table[pair]
if !allowed && !forbidden[pair] {
t.Errorf("не решено, что делать: %s + %s", s, e)
}
}
}
}
Добавили новое состояние в AllStates, и тест сразу покраснеет на четырёх непокрытых парах и заставит про каждую подумать. Без такого теста «правила в данных» теряют переходы так же молча, как switch.
Тот же автомат как таблица переходов: строка — текущее состояние, столбец — событие, клетка — куда перейти. Подсвечены только пять разрешённых переходов; пустые клетки (—) запрещены. Конечные состояния DELIVERED и CANCELLED — целиком пустые строки: из них выхода нет.
Когда подходит. Средний по размеру автомат, где переходов много, но каждый из них это просто «смена состояния», без сложной сопутствующей логики. Если же для каждого перехода нужно выполнять разное поведение (отправить письмо, списать деньги, проверить условие), таблица «состояние в состояние» этого не выражает, и тут напрашивается следующий приём.
Guard: где живёт условие перехода
Условие перехода это то, что отличает учебный автомат от рабочего: «отменить можно, но только пока не отгружено», «оплатить можно, только если сумма совпадает». В трёх самописных вариантах guard живёт в трёх разных местах, и это как раз та разница, ради которой между ними выбирают.
В switch guard это обычное условие внутри ветки:
case Pay:
if o.Paid != o.Total {
return ErrPartialPayment
}
o.Status = Paid
return nil
Просто и читаемо, пока ветвей мало. Минус в том, что условие и переход смешаны: глядя на код, нельзя отдельно ответить «какие вообще есть правила».
В таблице переходов guard становится полем записи, и вот это самое ценное свойство таблицы, потому что правила остаются данными. Отказ это именованная ошибка с кодом: по ней ручка отвечает нужным code в Problem Details, а тест проверяет errors.Is.
var ErrPartialPayment = errors.New("PARTIAL_PAYMENT")
var ErrAlreadyShipped = errors.New("ALREADY_SHIPPED")
type Rule struct {
To State
Guard func(*Order) error
}
func always(to State) Rule {
return Rule{To: to, Guard: func(*Order) error { return nil }}
}
var rules = map[transition]Rule{
{New, Pay}: {To: Paid, Guard: func(o *Order) error {
if o.Paid != o.Total {
return ErrPartialPayment
}
return nil
}},
{New, Cancel}: always(Cancelled),
{Paid, Ship}: always(Shipped),
{Paid, Cancel}: always(Cancelled),
{Shipped, Deliver}: always(Delivered),
}
func (o *Order) Apply(event Event) error {
rule, ok := rules[transition{o.Status, event}]
if !ok {
return &IllegalTransitionError{From: o.Status, Event: event}
}
if err := rule.Guard(o); err != nil {
return err
}
o.Status = rule.To
return nil
}
Что это даёт: весь набор правил читается одним взглядом, у каждого отказа есть свой код, и по этой же таблице автомат проверяется тестом (см. ниже) и рисуется схемой. Цена: условие теперь функция, а не код в контексте, и внутрь неё нельзя затащить лишние зависимости, что, впрочем, скорее плюс.
В состоянии как типе guard это часть метода того состояния, которое его проверяет: paidState.Ship() сам решает, можно ли. Это самый естественный вариант, когда правил много и они разные для каждого состояния.
Практическое правило: guard, который зависит только от самого объекта, живёт в автомате; guard, которому нужны внешние данные, нет. «Не отгружать, пока не оплачено» в автомате. «Не отгружать, если товара нет на складе» это проверка до вызова перехода, потому что для неё нужен запрос в другой контекст, а автомат не должен ходить по сети.
Состояние как тип
Если в каждом состоянии много собственного поведения, разумно сделать состояние отдельным типом. Это классический паттерн «Состояние» из каталога GoF: есть общий интерфейс состояния с методами-событиями, и по одному типу на каждое состояние. Каждый тип сам знает, как отвечать на события: и куда переходить, и что при этом делать.
Каждое состояние тут отдельный тип, а подпись стрелки это метод события: он не меняет поле, а возвращает значение следующего состояния.
В Java у интерфейса есть default-метод, который отвергает всё, чего состояние не переопределило. В Go ту же роль играет встраивание: базовый тип отвергает любое событие, а состояние встраивает его и переопределяет только то, что умеет.
type Handler interface {
State() State
Pay() (Handler, error)
Ship() (Handler, error)
Cancel() (Handler, error)
}
type rejecting struct{ state State }
func (r rejecting) State() State { return r.state }
func (r rejecting) Pay() (Handler, error) { return nil, &IllegalTransitionError{r.state, Pay} }
func (r rejecting) Ship() (Handler, error) { return nil, &IllegalTransitionError{r.state, Ship} }
func (r rejecting) Cancel() (Handler, error) { return nil, &IllegalTransitionError{r.state, Cancel} }
type newState struct{ rejecting }
func (newState) Pay() (Handler, error) { return paidState{rejecting{Paid}}, nil }
func (newState) Cancel() (Handler, error) { return rejecting{Cancelled}, nil }
type paidState struct{ rejecting }
func (paidState) Ship() (Handler, error) { return rejecting{Shipped}, nil }
func (paidState) Cancel() (Handler, error) { return rejecting{Cancelled}, nil }
Каждый тип отвечает только за одно состояние: видно, какие события оно принимает, а какие отвергает, и вся логика этого состояния собрана рядом. Добавить состояние значит добавить тип, не трогая остальные. Плата за это много мелких типов и рассеянность общей картины: чтобы понять весь автомат целиком, нужно открыть все реализации сразу (таблица переходов в этом смысле нагляднее). Поэтому состояние как тип оправдано именно тогда, когда важнее поведение внутри состояний, а не обзор переходов между ними.
Остаётся вопрос, который в описании паттерна обычно опускают: в базе-то лежит не значение-состояние, а строка. Мост между ними это карта «константа в конструктор»:
var handlers = map[State]func() Handler{
New: func() Handler { return newState{rejecting{New}} },
Paid: func() Handler { return paidState{rejecting{Paid}} },
Shipped: func() Handler { return rejecting{Shipped} },
Delivered: func() Handler { return rejecting{Delivered} },
Cancelled: func() Handler { return rejecting{Cancelled} },
}
func HandlerFor(s State) Handler { return handlers[s]() }
Цикл замыкается: загрузили заказ, взяли HandlerFor(State(row.Status)), вызвали событие, получили новое значение состояния и записали в колонку его State(). Сами значения-состояния при этом остаются без полей, всё изменяемое живёт в заказе, а состояние только решает, что с ним можно сделать.
Готовая библиотека: stateless и looplab/fsm
Когда автомат большой и обвешан требованиями, свою реализацию поддерживать становится дорого. В Go две живые библиотеки, и устроены они по-разному.
github.com/qmuntal/stateless это перенос .NET-библиотеки Stateless: автомат описывается конфигурацией состояний, у перехода есть условие, у состояния есть действия на входе и выходе, состояния умеют вкладываться друг в друга.
sm := stateless.NewStateMachine("NEW")
sm.Configure("NEW").
Permit("PAY", "PAID", func(_ context.Context, _ ...any) bool { return o.Paid == o.Total }).
Permit("CANCEL", "CANCELLED")
sm.Configure("PAID").
OnEntry(func(_ context.Context, _ ...any) error { return outbox.Add(ReserveStock{o.ID}) }).
Permit("SHIP", "SHIPPED").
Permit("CANCEL", "CANCELLED")
sm.Configure("SHIPPED").Permit("DELIVER", "DELIVERED")
err := sm.Fire("PAY")
Три вещи, которые стоит знать до того, как брать. Запрещённое событие не паника, а ошибка: Fire("DELIVER") из PAID вернёт «No valid leaving transitions are permitted from state 'PAID' for trigger 'DELIVER'»; событие, разрешённое по таблице, но не прошедшее условие, вернёт отдельную ошибку про guard, и обе хочется превращать в свои коды, а не отдавать наружу как есть. Состояние библиотека держит в памяти, но умеет работать и с внешним хранилищем: NewStateMachineWithExternalStorage принимает две функции, прочитать состояние и записать, и тогда Fire сам дёрнет запись в вашу таблицу. И ToGraph() отдаёт описание автомата на языке DOT: схему для документации рисует сама библиотека, а не человек по памяти.
github.com/looplab/fsm проще и ближе к switch: состояния и события это строки, переход описывается списком «откуда, по какому событию, куда», а всё поведение живёт в именованных обратных вызовах.
f := fsm.NewFSM("NEW",
fsm.Events{
{Name: "pay", Src: []string{"NEW"}, Dst: "PAID"},
{Name: "ship", Src: []string{"PAID"}, Dst: "SHIPPED"},
{Name: "cancel", Src: []string{"NEW", "PAID"}, Dst: "CANCELLED"},
},
fsm.Callbacks{
"before_pay": func(_ context.Context, e *fsm.Event) {
if o.Paid != o.Total {
e.Cancel(ErrPartialPayment)
}
},
"enter_PAID": func(_ context.Context, e *fsm.Event) { /* записать задание */ },
})
err := f.Event(ctx, "pay")
Условие здесь это вызов before_<событие>, который отменяет переход через e.Cancel(err): наружу уходит fsm.CanceledError, и errors.Is(err, ErrPartialPayment) по-прежнему находит причину. Событие не из текущего состояния даёт fsm.InvalidEventError. Вложенных состояний нет, хранение состояния тоже на вас: Current() прочитали, в базу записали.
Когда брать. Автомат по-настоящему большой, переходов десятки, нужны подсостояния, действия на входе и выходе и схема автомата, которую можно генерировать, а не рисовать. Тогда stateless экономит силы и даёт единообразный каркас, а looplab/fsm подходит, если нужны только переходы с условиями и действия на них.
Когда избыточна. Для автомата из пяти состояний и десятка переходов библиотека это из пушки по воробьям: конфигурация, зависимость и чужой словарь ошибок перевесят выгоду, а switch или таблица дадут тот же результат меньшими средствами. Обе библиотеки держат автомат внутри одного процесса, где переходы занимают секунды. Как только процесс растягивается на дни, уходит в несколько сервисов и начинает ждать решения человека, они перестают справляться, и не из-за размера автомата, а из-за того, чего в них нет: таймеров «напомнить через три дня», задач для людей, истории прохождения для бизнеса, компенсаций с порядком отката. Это уже территория движков процессов вроде Temporal или Camunda.
Хранение состояния и гонки
Любой из способов выше отвечает на вопрос «куда перейти». Остаётся второй вопрос: где хранить текущее состояние и как не сломать его при одновременных запросах. Обычно состояние живёт в колонке status таблицы заказа:
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
status text NOT NULL DEFAULT 'NEW'
CHECK (status IN ('NEW','PAID','SHIPPED','DELIVERED','CANCELLED')),
version bigint NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
Пара слов про типы, потому что здесь легко сделать по привычке и потом жалеть. text вместо varchar(20): в PostgreSQL они работают одинаково быстро, но у varchar(N) есть ограничение длины, которое однажды придётся менять, а у text нет. Сам список допустимых значений задаёт CHECK: его видно в схеме, он проверяется базой и правится обычной миграцией. Время это timestamptz, а не timestamp: без зоны момент времени восстановить невозможно, и первый же переезд сервера это покажет.
Проблема возникает, когда две горутины одновременно читают заказ в состоянии PAID и обе решают его обработать: одна шлёт SHIP, другая CANCEL. Каждая по отдельности видит допустимый переход, но вместе они конфликтуют, и без защиты выиграет та, что записала последней, затерев чужой результат. Есть два стандартных приёма.
Обе горутины прочитали PAID в один момент, и каждый переход по отдельности допустим; смотрите на две нижние строки: запись второй легла поверх первой, и SHIPPED исчез без единой ошибки.
Оптимистичная блокировка. В таблице держат колонку version. При чтении запоминают версию, при записи обновляют строку с условием «версия та же, что была». В Go никакой магии вроде @Version нет, и это даже честнее: условие пишется руками, а признак гонки это число затронутых строк.
tag, err := tx.Exec(ctx,
`UPDATE orders SET status = $1, version = version + 1, updated_at = now()
WHERE id = $2 AND version = $3`,
string(next), o.ID, o.Version)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrConcurrentUpdate
}
Если между чтением и записью кто-то уже поменял строку, версия не совпадёт, обновление затронет ноль строк, и мы понимаем, что случилась гонка. Повтор за вас не сделает никто: его пишут руками, и не внутри той же транзакции, а целиком заново: открыть новую, перечитать заказ, заново проверить переход и записать. Ручка на ErrConcurrentUpdate отвечает 409, а повторяет либо клиент, либо обработчик, если команда идемпотентна.
Пессимистичная блокировка (SELECT ... FOR UPDATE). Вторая горутина блокируется на чтении строки до тех пор, пока первая не завершит транзакцию. Строку читают под блокировкой, проверяют переход, записывают, коммитят, и только потом её увидит вторая. В практикуме так устроен репозиторий заказа: у метода чтения есть вариант с FOR UPDATE, и команда, которая сейчас будет менять состояние, зовёт именно его, а запрос на показ обходится без блокировки. Подробный разбор в статье Command side в CQRS.
Смысл обоих приёмов один: проверка перехода и его запись должны быть одной неделимой операцией, чтобы между «прочитали PAID» и «записали SHIPPED» никто не успел вклиниться. Оптимистичная блокировка дешевле, когда конфликты редки (просто повторяем при неудаче); пессимистичная надёжнее, когда за одну и ту же запись часто конкурируют. Без любой из них корректный на бумаге автомат в реальной работе будет иногда переходить не туда.
Действия при переходе: что делать с письмами и деньгами
Все варианты выше отвечают на вопрос «куда перейти» и молчат про то, что в реальном сервисе висит на переходе: списать деньги, отправить письмо, зарезервировать товар, опубликовать событие. Это половина работы, и здесь же живёт самая частая авария: состояние сменилось, а действие не выполнилось, или наоборот.
Первое правило: автомат не выполняет действия, он их объявляет. Метод перехода меняет состояние и возвращает список того, что нужно сделать. Сам он никуда не ходит: ни в сеть, ни в почту, ни к платёжному провайдеру.
func (o *Order) Pay(amount Money) ([]SideEffect, error) {
if o.Status != New {
return nil, &IllegalTransitionError{From: o.Status, Event: Pay}
}
if !amount.Equal(o.Total) {
return nil, ErrPartialPayment
}
o.Status = Paid
return []SideEffect{
ReserveStock{OrderID: o.ID, Lines: o.Lines},
NotifyCustomer{OrderID: o.ID, Status: Paid},
PublishEvent{OrderPaid{OrderID: o.ID, Amount: amount}},
}, nil
}
Почему так, а не «отправить письмо прямо здесь»: автомат остаётся проверяемым без всякой инфраструктуры (тест вызывает Pay и смотрит, какие действия вернулись), и появляется одно место, где решается, как эти действия выполнить.
Второе правило: что уходит наружу, идёт через таблицу исходящих сообщений. Действие, которое обращается к внешнему миру, нельзя выполнять внутри транзакции перехода: транзакция может откатиться после успешной отправки, и письмо уйдёт про заказ, который не оплачен. И нельзя после коммита: между коммитом и отправкой процесс может умереть, и письмо не уйдёт никогда. Рабочая схема одна: в той же транзакции, что и переход, записать задание в таблицу, а отправку сделать отдельным процессом.
return orders.WithTx(ctx, func(ctx context.Context) error {
o, err := orders.ForUpdate(ctx, cmd.OrderID)
if err != nil {
return err
}
effects, err := o.Pay(cmd.Amount)
if err != nil {
return err
}
if err := orders.Save(ctx, o); err != nil {
return err
}
if err := history.Record(ctx, o.ID, New, Paid, "PAYMENT_RECEIVED", cmd.Actor); err != nil {
return err
}
return outbox.SaveAll(ctx, effects)
})
Одна транзакция, три записи, ни одного обращения наружу. Дальше отправщик забирает задания из таблицы (FOR UPDATE SKIP LOCKED, как в шаге про outbox практикума) и выполняет их, повторяя при сбоях, и вот здесь нужно третье правило.
Третье правило: действия должны переносить повтор. Отправщик не знает, дошло ли письмо, если оборвалась сеть, и попробует снова. Значит, у каждого задания есть ключ, а получатель обязан отличить повтор: письмо с тем же ключом не отправляется дважды, резерв с тем же ключом не создаётся дважды. Механика в статье про пакетную обработку и идемпотентность, и без неё «надёжная доставка» превращается в «двойное списание».
Что делать с действиями, которые обязаны выполниться синхронно. Бывает: оплата не может «случиться потом», пользователь ждёт ответа платёжного провайдера. Тогда порядок обратный: сначала внешний вызов, потом переход. Если провайдер ответил успехом, а наша транзакция упала, состояние не сменилось, деньги списаны, и это расхождение закрывают сверкой плюс ключом идемпотентности на стороне провайдера. Правило простое: внешний вызов либо до транзакции, либо после неё через таблицу исходящих, но никогда внутри.
Порядок внутри перехода, который стоит запомнить как последовательность: проверить переход, проверить условия, сменить состояние, записать историю, записать задания, закоммитить. Всё внешнее за пределами этого списка.
Как тестировать автомат
У автомата есть свойство, которого нет у обычного кода: его правила конечны и перечислимы, а значит, тест может проверить их все. В Go это табличный тест с подтестами на каждую пару, и он же лучший аргумент в пользу таблицы переходов.
func TestEveryPairBehavesAsSpecified(t *testing.T) {
for _, s := range AllStates {
for _, e := range AllEvents {
t.Run(string(s)+"_"+string(e), func(t *testing.T) {
got, err := Next(s, e)
want, allowed := expected[transition{s, e}]
var illegal *IllegalTransitionError
if allowed && (err != nil || got != want) {
t.Fatalf("ждали %s, получили %s, %v", want, got, err)
}
if !allowed && !errors.As(err, &illegal) {
t.Fatalf("ждали запрет, получили %s, %v", got, err)
}
})
}
}
}
Ценность здесь не в том, что проверены разрешённые переходы, а в том, что проверены запрещённые: пять состояний и четыре события дают 20 пар, из которых разрешено пять, и тест утверждает, что остальные пятнадцать отвечают именно IllegalTransitionError, а не паникой и не пустым состоянием. Именно эти пятнадцать и есть содержание автомата, и обычными тестами их никто не покрывает.
Проверка достижимости. Второй тест ловит ошибки в правилах, а не в коде: обойти граф переходов от начального состояния и убедиться, что каждое состояние достижимо, а из каждого конечного нет выхода. Обход это десять строк с очередью по таблице:
func ReachableFrom(start State) map[State]bool {
seen := map[State]bool{start: true}
queue := []State{start}
for len(queue) > 0 {
cur := queue[0]
queue = queue[1:]
for t, to := range table {
if t.from == cur && !seen[to] {
seen[to] = true
queue = append(queue, to)
}
}
}
return seen
}
func TestAllStatesReachableAndTerminalsFinal(t *testing.T) {
reachable := ReachableFrom(New)
for _, s := range AllStates {
if !reachable[s] {
t.Errorf("состояние %s недостижимо", s)
}
}
for _, terminal := range []State{Delivered, Cancelled} {
if out := Outgoing(terminal); len(out) != 0 {
t.Errorf("из конечного %s есть выход: %v", terminal, out)
}
}
}
Недостижимое состояние это мёртвая константа (обычно остаток от старого процесса), а конечное состояние с выходом почти всегда ошибка в правилах, которую в проде обнаружит поддержка.
Тесты на условия и действия. Отдельно: guard проверяется парой тестов «условие выполнено, переход прошёл» и «не выполнено, отказ с нужной ошибкой» через errors.Is. Действия тем, что метод перехода вернул нужный список, а не тем, что письмо ушло: это как раз та польза от «автомат объявляет действия», о которой раздел выше.
И тест на гонку, если состояние живёт в базе: сто горутин на sync.WaitGroup пытаются перевести один объект на настоящей PostgreSQL, одна выигрывает, остальные получают понятную ошибку. В практикуме такой тест стоит на резерве товара, и устроен он так же.
Состояние приходит извне: опоздания и повторы
Отдельный класс задач, который ломает всё описанное выше: состояние меняет не ваш код, а внешняя система: перевозчик, платёжный провайдер, партнёр. Приходит вызов от них («посылка вручена»), и переход надо выполнить по чужим данным. Здесь три особенности, и все три встречаются в первый же месяц.
Сообщения приходят не по порядку. Перевозчик отправил «в пути» и «доставлено», а до вас дошло сначала второе. Если применять как пришло, заказ окажется «в пути» после того, как был доставлен. Правильный ответ: отбрасывать переход «назад», а не пытаться его выполнить. Для этого у состояний должен быть порядок или отметка времени события:
func (o *Order) ApplyCarrierStatus(incoming CarrierStatus, occurredAt time.Time) {
if !o.LastCarrierEventAt.IsZero() && occurredAt.Before(o.LastCarrierEventAt) {
slog.Info("опоздавшее событие перевозчика отброшено", "status", incoming, "at", occurredAt)
return
}
target, known := carrierToOrder[incoming]
if !known {
slog.Warn("неизвестный статус перевозчика", "status", incoming)
return
}
if !o.canMoveTo(target) {
slog.Info("обратный переход отброшен", "from", o.Status, "to", target)
return
}
o.Status = target
o.LastCarrierEventAt = occurredAt
}
Три вещи в этом коде важнее остального. Опоздавшее событие не ошибка, и на него нельзя отвечать ошибкой: внешняя система воспримет её как «не доставлено» и будет повторять бесконечно. Отвечать надо успехом и ничего не делать. Сравнение по времени события, а не по времени получения: время берут из полезной нагрузки, и если внешняя система его не присылает, это первое, о чём стоит попросить. И неизвестный чужой статус (перевозчик добавил новый) записывается в журнал, не меняет состояние и не роняет обработку: иначе один новый статус у партнёра остановит все уведомления.
Повторы приходят всегда. Внешняя система, не получившая подтверждения, отправит то же самое снова, иногда десятки раз. Значит, обработчик обязан быть идемпотентным: тот же переход, применённый дважды, не должен порождать двойных действий. Практически это отметка обработанных сообщений по идентификатору от внешней системы, в той же транзакции, что и переход, через INSERT ... ON CONFLICT DO NOTHING и проверку RowsAffected, как у потребителя событий в практикуме.
Чужие состояния не совпадают с вашими. У перевозчика двадцать статусов, у вас четыре. Отображение чужого на своё это решение, и его записывают явно, отдельной картой соответствия; заодно в ней видно, какие чужие статусы вы игнорируете.
Новое состояние в работающей системе
Добавить константу это одна строка. Проблема в том, что в базе уже лежат миллионы записей со старыми значениями, а рядом работают копии сервиса с предыдущей версией кода.
Добавление состояния это совместимое изменение, если делать в правильном порядке. Сначала база, потом код, потом использование: миграция расширяет CHECK новым значением; выкатывается код, который умеет читать новое состояние, но ещё не переводит в него; и только потом включается переход, новым выкатом или флагом. Почему порядок именно такой: при выкате по одной копии старая версия обязана понимать то, что пишет новая. Пропустили второй шаг, и старая копия встретит незнакомое значение; в Go она не упадёт на разборе, как Java на valueOf, а молча понесёт строку, которой нет ни в switch, ни в таблице, и ответит на любое событие запретом. Это то же правило совместимости, что для любой схемы данных: сначала научиться читать, потом начать писать.
Удаление состояния это обратный порядок и гораздо дольше. Перестать переводить, дождаться, пока записей в этом состоянии не останется (а они могут висеть месяцами), убрать из кода, убрать из ограничения. Последний шаг часто не делают вовсе, и это нормально: лишнее значение в проверке никому не мешает, а код, который его не знает, мешает.
Чего нельзя делать ни в каком порядке: переименовывать значение (это одновременно удаление и добавление, и при выкате по одной копии часть записей окажется с одним именем, часть с другим) и менять смысл существующего значения (старые записи начнут врать). Тест, который это страхует: прогнать тесты предыдущей версии кода против базы с новой миграцией. Зелёные, значит изменение совместимо; красные, значит при выкате по одной копии будет авария.
Что выбрать
Единственно правильного способа нет, выбор зависит от размера автомата и от того, сколько логики висит на переходах.
- Именованный тип и
switchдля маленького автомата: несколько состояний, простые переходы, никаких зависимостей; линтерexhaustiveвместо проверки компилятора. - Таблица переходов для среднего: переходов много, но они сводятся к «сменить состояние»; правила хочется видеть списком, проверять перебором и менять, не трогая код.
- Состояние как тип когда в каждом состоянии много собственного поведения и его важно держать вместе, рядом с состоянием.
- Библиотека (
stateless,looplab/fsm) для больших автоматов с условиями, действиями на входе и выходе, подсостояниями и схемой, которую генерируют; для мелких избыточна.
| Признак | тип и switch | Таблица переходов | Состояние как тип | Библиотека |
|---|---|---|---|---|
| Число состояний | до 5 | 5–20 | 3–10 | больше 10 |
| Число переходов | до 10 | десятки | десятки | десятки и больше |
| Условия перехода | внутри ветки | полем записи | внутри состояния | guard у перехода |
| Много поведения в состоянии | нет | нет | да | да |
| Правила нужны как данные (менять, рисовать, проверять) | нет | да | нет | да, схема из ToGraph |
| Подсостояния | нет | нет | с трудом | да (stateless) |
| Хранение состояния между запросами | своими руками | своими руками | своими руками | своими руками, у stateless через внешнее хранилище |
| Сторонняя зависимость | нет | нет | нет | да |
| Цена входа | минутная | часовая | часовая | день |
Как читать эту таблицу: два признака решают почти всегда. Если в состояниях много собственного поведения, состояние как тип, независимо от их числа. Если правила должны быть данными (их правят не только разработчики, по ним строят схему, их проверяют перебором), таблица переходов. Всё остальное при пяти состояниях это именованный тип и switch, и это честный выбор, а не упрощение. Библиотеку берут по одному настоящему признаку: нужны подсостояния или схема, которую не хочется рисовать руками. И независимо от выбора не забудьте про хранение и гонки: колонка status, а поверх неё version или FOR UPDATE.
Коротко
- Автомат на бумаге ничего не гарантирует, всё решает то, как перенести его в код, чтобы недопустимые переходы стали невозможны. Именованный тип и
switchэто простейший вариант; компилятор Go полнотуswitchне проверяет, это делает линтерexhaustive. - Таблица переходов это
mapс ключом-структурой «состояние плюс событие»: весь автомат виден списком, правила меняются без кода, а списокAllStatesрядом с константами нужен для перебора в тестах. - Состояние как тип делает каждое состояние значением со своим поведением; встраивание базового типа, который всё отвергает, заменяет
default-методы интерфейса. statelessдаёт условия, действия на входе и выходе, подсостояния, внешнее хранилище состояния и схему в DOT;looplab/fsmдаёт строки, список переходов и обратные вызовы с отменой черезe.Cancel(err). Обе для одного процесса; долгий процесс через сервисы это движок процессов.- Запрещённый переход это свой тип ошибки, который ручка узнаёт через
errors.Asи отвечает 409; отказ условия это именованная ошибка с кодом дляerrors.Is. - Состояние хранят в колонке
statusсCHECK; гонку ловят оптимистично (UPDATE ... WHERE version = $n, ноль затронутых строк значит конфликт, повтор новой транзакцией) или пессимистично (SELECT ... FOR UPDATEв транзакции команды). - Автомат не выполняет действия, а объявляет их: состояние, история и задания пишутся одной транзакцией, наружу уходит отправщик из таблицы исходящих, а действия обязаны переносить повтор.
- Автомат проверяется табличным тестом по всем парам «состояние на событие» (ценны именно запрещённые), обходом достижимости, тестами условий и тестом на гонку с горутинами на настоящей базе.
- Состояние извне: время события в данных, опоздавшие и обратные переходы отбрасывать без ошибки, идемпотентность по идентификатору сообщения, явная карта соответствия и безопасное поведение при неизвестном чужом статусе.
- Новое состояние вводят в три шага: разрешить в базе, научить код читать, включить переход; удаление в обратном порядке и дольше; переименование и смена смысла запрещены, а страхует это прогон тестов предыдущей версии.
Что пощупать
Самая простая реализация из статьи, именованный тип со switch, работает в сервисе платежей практикума remodov/marketplace-system-go: Status.CanMoveTo перечисляет разрешённое, Payment.MoveTo возвращает *InvalidTransitionError на запрещённом переходе, а повторный возврат обрабатывается в сервисе как безопасный повтор, а не разрешается в автомате. Хранение это голый database/sql, чтобы автомат и хранение не смешивались; в сервисе заказа тот же подход с pgx, чтением под FOR UPDATE и статусами заказа в агрегате.
Код: payment.go, transitions_test.go, репозиторий заказа с FOR UPDATE.
Сделаем сами
Ветка step-11-saga-and-state-machine: CanMoveTo пустой, повторная авторизация и безопасный возврат не сделаны, проверки переходов красные.
Что почитать дальше
- Что такое конечный автомат — состояния, переходы и когда автомат нужен.
- BPM-движки и оркестрация — долгие процессы через сервисы, сага и хореография.
- Command side в CQRS на Go — чтение агрегата под блокировкой и команда как единица работы.
- Пакетная обработка и идемпотентность — отправщик из таблицы исходящих и повторы без двойных действий.