Разбираем идею Data on the Outside vs Inside и почему приложениям нужны четкие границы, идемпотентность и сверка, когда сеть дает сбои.

Проблема не в том, что разработчики «плохо пишут код». Проблема в том, что сеть не дает гарантий. Запрос может идти дольше обычного, ответ может потеряться по дороге, а клиент может отправить тот же запрос еще раз, потому что «ничего не произошло». Для пользователя это выглядит как ошибка приложения, хотя на самом деле сеть просто ведет себя непредсказуемо.
Сеть обычно «врет» тремя способами: задерживает, теряет и дублирует. Задержка опасна тем, что вы не понимаете, выполнена операция или нет. Потеря приводит к частичному успеху: одна часть системы уже приняла действие, а другая о нем не узнала. Дубли создают повторное выполнение: один и тот же шаг сработал дважды.
Из этого и рождаются знакомые симптомы:
Реальная система почти всегда состоит из нескольких компонентов: фронтенд, бэкенд, база, платежи, доставка, уведомления, аналитика, иногда очередь сообщений и внешние интеграции. Даже если вы собираете продукт быстро на платформе вроде TakProsto, как только появляются внешние сервисы и несколько шагов в процессе, вы оказываетесь в мире распределенных систем.
Цель проектирования простая: ошибки сети не должны ломать бизнес-результат. Пользователь может нажать «Оплатить» два раза, а система все равно должна прийти к правильному итогу: деньги списаны один раз, заказ создан один раз, статусы понятны, а расхождения можно найти и исправить. Для этого нужны четкие границы ответственности, идемпотентность и механизмы сверки. Практический смысл идеи Data on the Outside vs Inside как раз в этом.
Пэт Хелланд много лет разбирал сбои в реальных системах и свел их к простой мысли, полезной для продакшена. Его подход помогает перестать рассчитывать на «идеальную распределенную транзакцию» и начать проектировать так, чтобы приложение выживало при потерях сети.
Фраза Data on the Outside vs Inside делит мир на две зоны.
Снаружи (outside) живут сообщения, интеграции и ожидания. Здесь всегда есть неопределенность: запрос может дойти два раза, ответ может потеряться, а порядок событий может поменяться. Поэтому снаружи вы не «гарантируете», а договариваетесь: через контракты, идентификаторы, правила повторов и способы проверки результата.
Внутри (inside) находится то, что вы контролируете: локальная база данных, транзакции, инварианты. Здесь можно быть строгими: либо изменение записалось целиком, либо не записалось вовсе. Внутри вы держите порядок: уникальность, балансы, статусы, запреты на «невозможные» переходы.
Главная мысль: внутри можно гарантировать, снаружи нужно принимать хаос как норму и строить защиту.
Удобный прием - выписать границу системы и ответить на несколько вопросов:
Пример: сервис заказов пометил заказ как «оплачен», но подтверждение платежа не дошло до витрины или склада. Внутри сервиса заказов статус уже истина, а снаружи другие системы еще «не в курсе». Это нормально, если у вас есть правила сверки и повторной доставки.
Пока вы не провели границу, у вас нет системы, есть только набор взаимных ожиданий. Граница - это место, где вы точно знаете, кто принимает решение, кто хранит правду и кто отвечает за последствия. Это может быть отдельный сервис, модуль в монолите или bounded context предметной области.
Внутри границы вы фиксируете «данные внутри»: их можно проверять, менять и защищать правилами. Обычно это значит:
Снаружи границы начинается «данные снаружи»: сеть, очереди, другие сервисы и непредсказуемые задержки. Тут нельзя обещать мгновенную согласованность. Здесь нормальны асинхронность, повторные попытки, дубли и eventual consistency. Важно другое: внешний мир не должен обходить ваши правила напрямую.
Хороший тест границы: может ли чужой компонент обойти ваши проверки и записать состояние напрямую? Если да, граница не работает.
Пример: сервис «Платежи» не должен напрямую менять «Склад». Он может только сообщить факт: «платеж подтвержден» или «платеж отклонен». А уже «Склад» сам решает, резервировать ли товар, списывать ли остатки и что делать при отмене. Тогда сбой сети превращается в задержку обработки, а не в тихую порчу данных.
Идемпотентность в простых словах: вы можете повторить одно и то же действие несколько раз, а итог останется тем же, как если бы вы сделали его один раз. Нажали кнопку «Отправить» дважды, а письмо ушло только один раз. Это не про красоту кода, а про выживание приложения, когда сеть ведет себя плохо.
Повторы неизбежны. Клиент не получил ответ из-за таймаута, но сервер успел выполнить запрос. Очередь доставила событие повторно. Сервис упал после записи в базу, но до отправки подтверждения. В модели Data on the Outside vs Inside это нормально: «снаружи» нет надежной доставки, поэтому «внутри» нужно спокойно переживать повторы.
Практический прием - идемпотентный ключ. Это уникальный идентификатор операции (для создания заказа, списания, выдачи доступа), который вы принимаете с запросом и сохраняете внутри своей границы вместе с результатом.
Как это обычно работает: клиент отправляет операцию и idempotency_key. Сервис проверяет ключ. Если он уже был, возвращает тот же результат. Если ключ новый - выполняет действие, сохраняет ключ и итог, отвечает.
Важно, где хранить ключ: внутри того сервиса, который отвечает за эффект. Если платежный сервис списывает деньги, он и должен помнить ключи списаний в своей базе, а не надеяться, что «где-то снаружи» все будет один раз.
Для «почти идемпотентных» действий (деньги, бонусы) правило еще жестче. Делайте их как отдельные, явно пронумерованные операции: «списание N» или «начисление бонусов за заказ X». Тогда повтор превращается не во второе списание, а в повторный запрос того же списания.
Как только данные вышли наружу, вы теряете полный контроль над тем, когда и в каком виде они вернутся. Поэтому важно различать команды и события.
Команда просит что-то сделать: «создай заказ», «спиши деньги». Событие сообщает о факте: «заказ создан», «платеж прошел». Команда может не выполниться, выполниться позже или выполниться дважды. Событие описывает то, что уже случилось, но может прийти с задержкой или повториться.
Порядок сообщений ненадежен. Даже если вы отправили «Платеж успешен» после «Заказ создан», другой сервис может увидеть их наоборот. Поэтому бизнес-логика не должна опираться на очередность доставки. Она должна опираться на состояние и правила: например, заказ можно перевести в «оплачен» только если он существует, а повторное событие об оплате не меняет итог.
Чтобы сообщения можно было безопасно обрабатывать, обычно хватает нескольких полей: идентификатор сообщения для дедупликации, тип и версия схемы, идентификатор сущности (например, order_id), корреляция (correlation_id или request_id) и время события или логическая версия (revision).
Отдельная боль - изменения формата. Часто спасает дисциплина совместимости: добавляйте поля, не ломая старые; делайте новые поля необязательными с понятными значениями по умолчанию; поддерживайте чтение старых версий хотя бы один релизный цикл; фиксируйте контракт (что означает каждое поле и когда оно может отсутствовать).
Расхождения неизбежны: сеть рвется, сервис не отвечает, сообщение приходит дважды или слишком поздно. Если «снаружи» у вас сообщения и чужие данные, то нужно уметь не только принимать их, но и регулярно проверять факты и поправлять то, что «поехало».
Сверка (reconciliation) - периодическая проверка: что система считает правдой сейчас и что действительно произошло по журналам, платежам, статусам у партнеров или в соседних сервисах. Дальше идет согласование: подтянуть недостающий статус, повторить безопасную операцию, закрыть зависшую попытку, исправить локальную запись.
Фоновые задачи нужны там, где нельзя гарантировать мгновенный ответ. Например: заказ создан, а подтверждение оплаты потерялось. Пользователь видит «в обработке», а фоновая задача через минуту спрашивает платежный сервис и доводит заказ до «оплачен». То же самое работает для доставки статусов, возвратов, выдачи доступа.
Чаще всего помогают два приема:
Чтобы согласование не превратилось в «черный ящик», заранее задайте метрики: доля «зависших» операций, время до выравнивания, количество повторных доставок и обработок, число ручных вмешательств.
Сбой сети почти всегда выглядит как «ничего не произошло», хотя на другом конце что-то уже изменилось. Поэтому проектируйте так, будто каждое сообщение может потеряться, дойти дважды или прийти позже.
Внутри вашей границы (одного сервиса или модуля с общей базой) выпишите инварианты: что нельзя нарушать ни при каких обстоятельствах. Например: «у заказа не может быть двух активных оплат», «баланс не уходит в минус», «статус меняется только вперед по цепочке».
Затем решите, какие данные окончательны внутри, а какие приходят «снаружи» и требуют проверки.
Для каждого внешнего вызова зафиксируйте: что вы отправляете (команда или событие), что ждете в ответ и что делаете, если ответа нет. Таймаут не означает «не сделано». Таймаут означает «не знаю».
Почти все проблемы начинаются с повторов. Для каждого входящего запроса введите идемпотентный ключ и храните реестр обработанных запросов (например, таблицу processed_requests). Если пришло повторно, возвращайте тот же результат, не выполняя действие заново.
Сами по себе повторы полезны, но без ограничений они превращаются в шторм. Обычно достаточно: backoff, лимит попыток и общий таймаут по времени, понятный статус для пользователя, отдельный путь в «нужна проверка человеком», защита от массовых повторов при общей аварии.
Сверка должна быть регулярной: что сравниваем, как часто, где фиксируем итог (например, «расхождение найдено», «исправлено», «требует решения»). Добавьте корреляцию: один идентификатор на весь путь запроса, чтобы собирать историю в логах.
Покупатель оформляет заказ. Система делает три шага: создает заказ, резервирует товар на складе и отправляет пользователя на оплату. После оплаты платежный сервис должен вернуть подтверждение, чтобы заказ перешел в финальный статус и ушло уведомление.
Сбой начинается в самом обычном месте: сеть. Платеж прошел, деньги списались, но callback или ответ API с подтверждением не дошел до сервиса заказов (таймаут, обрыв соединения, перегрузка очереди). Для магазина выглядит так, будто оплаты не было. Пользователь видит ошибку и нажимает «Оплатить еще раз».
Идемпотентность защищает от двойного списания. Магазин отправляет запрос на оплату не «просто так», а с ключом идемпотентности, например order_id + попытка_оплаты. Платежный сервис хранит этот ключ и действует по правилу: если запрос уже был обработан, вернуть тот же результат, а не создавать новый платеж.
Но даже с идемпотентностью заказ может зависнуть в неопределенности, если подтверждение потерялось. Тогда нужна сверка: периодическая проверка, которая находит платеж по стабильному ключу (например, payment_id или order_id) и приводит заказ в правильное состояние. Границы здесь критичны: платежный сервис отвечает за факт списания, сервис заказов - за статус заказа и выдачу товара.
Пользователю лучше показывать статусы, которые не обещают лишнего: «Ожидаем подтверждение оплаты», «Оплата принята, проверяем», «Оплачено, собираем заказ», «Оплата не подтверждена, попробуйте позже», «Нужна помощь поддержки».
Распределенные системы ломаются не из-за «сложного алгоритма», а из-за мелочей вокруг сети: таймаутов, повторов запросов и частичных успехов. И именно тут идея Data on the Outside vs Inside помогает увидеть, где вы незаметно смешали «внутреннее» и «внешнее».
Самая частая ловушка - вести себя так, будто несколько сервисов делят одну общую транзакцию, как в одной базе. Внутри сервиса транзакция уместна, но между сервисами всегда есть «снаружи»: сообщения, очереди, повторы и потеря связи.
Типичные ошибки, которые потом дорого чинить:
request_id, ключ идемпотентности, связь между командой и результатомЕсли платежный сервис ответил клиенту «успех», а подтверждение в сервис заказов не дошло, поддержка быстро окажется в режиме гадания. Это почти всегда следствие того, что не продуманы статусы, сверка и правила восстановления.
Перед релизом полезно проверить базовые вещи:
Отдельно проверьте наблюдаемость. Должен быть корреляционный идентификатор, который проходит через все шаги. По логам и метрикам вы должны быстро восстанавливать цепочку: запрос, команда, событие, повтор, итоговое состояние.
Выберите один поток, который чаще всего болит: оформление заказа, регистрация, начисление бонусов, выдача доступа. Сделайте его максимально конкретным: где начинается ответственность одной части системы и где она заканчивается.
Рабочая последовательность:
Затем соберите маленький прототип и прогоните тестовые данные. Важно проверить три вещи: статусы не «врут», идемпотентность реально гасит повторы, а сверка находит и исправляет зависшие состояния.
Если вы делаете продукт в TakProsto, полезно сразу описать границы модулей и контракты сообщений, даже если приложение собирается быстро через чат. TakProsto (takprosto.ai) позволяет быстро собрать UI, сервер и базу, а затем безопасно проверять сценарии со сбоями с помощью снапшотов и отката, не боясь сломать рабочую версию.
Потому что сеть не дает гарантий: запрос может задержаться, потеряться или дойти дважды. Для системы это выглядит как «непонятно, выполнилось или нет», и без защитных механизмов это превращается в дубли, пропажи статусов и зависшие операции.
«Внутри» — это зона, где вы контролируете данные и правила: ваша база, транзакции, инварианты, допустимые переходы статусов.
«Снаружи» — это вызовы по сети, очереди, интеграции и ответы, которым нельзя полностью доверять по времени, порядку и уникальности.
Сначала проведите границу ответственности: какой модуль/сервис принимает решение и хранит истину.
Практичный критерий: если другой компонент может обойти ваши проверки и записать состояние «напрямую», границы нет. Внешний мир должен только отправлять команды/события, а ваша часть — сама решать, как менять свои данные.
Таймаут означает «не знаю», а не «не сделано».
Правильная реакция:
Идемпотентность — это когда повтор одного и того же запроса не меняет итог (как будто он был выполнен один раз).
Обычно делается так: клиент передает idempotency_key, сервис хранит ключ и результат у себя. Если приходит повтор — возвращает уже сохраненный результат, не выполняя действие заново.
Храните ключ там, где происходит эффект.
Примеры:
Если хранить ключ «где-то снаружи», вы снова зависите от сети и получаете дубли при сбоях.
Команда — это просьба что-то сделать: «создай заказ», «спиши деньги». Она может прийти повторно и не обязана выполниться мгновенно.
Событие — это сообщение о факте: «заказ создан», «платеж подтвержден». Оно тоже может прийти поздно или повториться.
Полезное правило: не строить бизнес-логику на ожидании правильного порядка доставки; опираться на состояние и проверки.
Потому что сообщения могут прийти в другом порядке или с задержкой.
Чтобы не сломаться:
message_id)Сверка — это регулярная проверка фактов и устранение расхождений: найти «зависшие» операции, подтянуть статус, повторить безопасный шаг.
Обычно помогает:
Да, но правила те же: как только появляются несколько шагов и внешние сервисы, вы в распределенной системе.
Практика для проектов на TakProsto: