АрхитектураПроектирование системАрхитектор программного обеспечения

Объясните, где должна находиться проверка допустимых переходов статуса заказа, если его меняют несколько ко...

Объясните, где должна находиться проверка допустимых переходов статуса заказа, если его меняют несколько компонентов?

Проходите собеседования с ИИ помощником Hintsage

Краткий ответ

Проверка допустимых переходов должна находиться в единственном компоненте, который владеет жизненным циклом заказа и является источником истины для его состояния. Остальные компоненты могут инициировать переход через его контракт, но не должны самостоятельно изменять статус напрямую.

Так сохраняется единое бизнес-правило: ни один путь записи не сможет перевести заказ в недопустимое состояние. Проверка только на уровне клиентов или отдельных вызывающих сервисов ненадёжна, потому что всегда может появиться новый путь изменения данных.

Исторический контекст

В монолитных системах правила переходов часто располагались рядом с моделью заказа или в сервисном слое. При разбиении системы на компоненты возникла проблема: разные модули начали дублировать правила и напрямую обновлять общую таблицу.

Подход с единым владельцем состояния развился из идей инкапсуляции бизнес-инвариантов, конечных автоматов и агрегатов в предметно-ориентированном проектировании. Его исходная цель — не дать внешним операциям нарушить правила жизненного цикла сущности.

Постановка проблемы

Заказ может проходить состояния, например: создан, подтверждён, оплачен, отправлен, завершён или отменён. Не каждый переход допустим: оплаченный заказ нельзя отменить тем же способом, которым отменяется ещё не оплаченный заказ, а завершённый заказ обычно нельзя вернуть в состояние оплаты.

Если платёжный, складской и клиентский компоненты каждый самостоятельно проверяют переходы, правила начинают расходиться. Один компонент может разрешить переход, который другой считает недопустимым; прямое обновление базы или новый интеграционный обработчик вообще может обойти все проверки.

Последствия — некорректные статусы, повторная отгрузка, возврат денег после завершения заказа, невозможность восстановить историю изменений и сложная диагностика. Особенно опасна ситуация, когда статус хранится централизованно, но менять его имеют право несколько независимых записчиков.

Подробное решение

Нужно определить владельца жизненного цикла заказа. Этот компонент хранит состояние заказа либо предоставляет единственную операцию его изменения и внутри неё проверяет текущий статус, требуемый переход, права инициатора и дополнительные бизнес-условия.

Остальные компоненты не должны выполнять произвольную запись поля статуса. Они отправляют владельцу команду или вызывают доменную операцию: например, запросить подтверждение оплаты или отметить передачу заказа в доставку. Владелец принимает решение, сохраняет новый статус и публикует результат для заинтересованных компонентов.

Проверка должна выполняться атомарно с изменением состояния. Иначе два параллельных запроса могут оба прочитать допустимый исходный статус и записать несовместимые результаты. Для этого применяют транзакцию с блокировкой записи, проверку версии или другой механизм конкурентного контроля; конкретный выбор зависит от хранилища и требований к конфликтам.

Полезно хранить историю переходов отдельно от текущего статуса. История позволяет расследовать ошибки и строить аудит, но сама по себе не заменяет проверки: запись события после уже выполненного некорректного изменения не исправляет нарушенный инвариант.

События о смене статуса должны публиковаться после успешного сохранения состояния. При необходимости надёжной доставки используют транзакционный outbox или эквивалентный механизм, чтобы изменение заказа и подготовка события не расходились. Это решает проблему доставки уведомления, но не переносит ответственность за допустимость перехода на подписчиков.

Есть важный компромисс. Единый владелец упрощает защиту инвариантов, но может стать узким местом при очень высокой нагрузке или при слишком широком наборе обязанностей. Масштабировать его следует через разбиение данных, очереди и независимые операции, а не через разрешение произвольных записей статуса из разных компонентов.

Ситуация из практики

В системе доставки платёжный компонент после подтверждения оплаты менял заказ на состояние «оплачен», складской компонент после резервирования товара — на «готов к сборке», а операторская панель могла отменить заказ. Каждый компонент содержал собственную проверку, поэтому при гонке отмена иногда происходила одновременно с подтверждением оплаты, а итог зависел от порядка записей.

Рассматривались три варианта. Можно было оставить прямые обновления и добавить одинаковые проверки во все компоненты, но правила пришлось бы синхронно дублировать. Можно было разрешить базе принимать только значения из фиксированного списка, однако список значений не описывает допустимые переходы и контекстные условия. Третий вариант — передать владельцу заказа все команды изменения состояния.

Выбрали третий вариант: компонент заказов стал единственным авторитетным владельцем статуса, а платёжный и складской компоненты начали отправлять ему команды. Проверка перехода и запись новой версии заказа выполнялись атомарно, а успешный переход порождал событие для остальных компонентов.

В результате конкурирующие операции стали предсказуемыми: одна принималась, а другая получала отказ из-за нового состояния или версии. Минусом стала дополнительная сетевой вызов и необходимость обрабатывать временную недоступность владельца, но этот компромисс оказался предпочтительнее повреждения бизнес-состояния.

Что кандидаты часто упускают

  1. Достаточно ли ограничить поле статуса перечислением допустимых значений в базе данных?

Нет. Такое ограничение защищает только от неизвестных значений, но не от неправильной последовательности. Оно может запретить строку «неизвестный статус», однако обычно не выражает правило, что переход из «завершён» в «оплачен» недопустим.

Проверка переходов должна находиться в логике владельца состояния, а ограничение на уровне базы может использоваться как дополнительный защитный слой. Если правила просты и полностью формализуемы ограничениями базы, их можно частично дублировать там, но полагаться только на перечисление недостаточно.

  1. Что делать, если один бизнес-процесс должен изменить несколько связанных сущностей?

Нельзя автоматически делать все компоненты одним владельцем только ради удобства транзакции. Сначала определяют, какой инвариант должен сохраняться атомарно, и помещают связанные данные в одну транзакционную границу, если это действительно необходимо.

Если участники находятся в разных компонентах, применяют согласованный процесс: команды, события, повторную обработку и компенсационные действия. При этом каждый компонент сохраняет собственные инварианты сам, а не рассчитывает на то, что другой компонент когда-нибудь исправит некорректное состояние.

  1. Как защитить владельца от устаревшей команды изменения статуса?

Команда должна содержать достаточно контекста для проверки актуальности: например, ожидаемую версию заказа, идентификатор операции или условие, при котором переход разрешён. Владелец сравнивает этот контекст с текущим состоянием и отклоняет устаревшую команду вместо безусловной перезаписи.

Одной идемпотентности недостаточно: она предотвращает повторное выполнение одной и той же операции, но не определяет, допустима ли новая команда после другого перехода. Поэтому обычно нужны оба свойства — безопасная обработка повторов и проверка актуальности состояния.