Симптом архитектурного спора обычно звучит безобидно: «после сохранения заметка должна появиться в публичном чтении». Цена появляется позже. Один разработчик делает синхронную запись и проекцию, другой предлагает очередь, третий читает ту же таблицу напрямую. Все три варианта могут вывести текст на экран, но после повторной доставки или частичного сбоя невозможно ответить, какая запись была источником, был ли зафиксирован сам факт публикации и что безопасно повторить. Начинать с технологии в такой ситуации дорого: схема быстро станет обязательством, а причина выбора останется в памяти участников.
Разберём учебный кейс: сервис публикует рабочие заметки. Нужны каноническая запись, явный intent публикации, отдельное публичное чтение и ключ для controlled replay. Разрешено, что read model отстанет; запрещено выдавать маленькую fixture за транзакцию между базой и broker. Это не описание существующей команды, продукта, SLO или инцидента. Проверка отвечает на конкретные условия, а не на красивое имя паттерна.
Сначала сужаем вопрос до границы ответственности
Вопрос «нужен ли нам outbox?» слишком широк. Его нельзя проверить, пока не названо, какая часть данных считается канонической и какой факт должен пережить повтор. В учебном brief канонической является заметка, а intent публикации — отдельная запись с ключом noteId:revision. Публичная проекция принадлежит read path, поэтому она не может сама доказывать, что заметка была принята write path. Это различие защищает от частой ошибки: принять успешный HTTP-ответ или видимый заголовок за доказательство всей цепочки.
Условие recorded-publication-intent не означает «сразу отправить сообщение». Оно означает, что решение обязано назвать состояние, по которому потом можно спросить: была ли публикация запланирована, какой версии заметки она соответствует и какой decision это разрешил. Условие delayed-read-is-explicit не обещает скорость. Оно только не позволяет скрыть отставание projection за фразой «в конце концов появится». Если проекту требуется строго синхронное чтение, brief меняется, а старое решение получает статус review-needed.
const decision = decidePublicationArchitecture({
brief: architectureReviewBrief,
evidence: trainingEvidence,
assumption: trainingAssumption,
});
if (decision.status !== 'accepted-for-training') {
throw new Error('review before implementation');
}
Сравниваем варианты по инвариантам, а не по модным названиям
| Вариант | Что доказывает | Не закрывает в этом brief | Обратимое действие |
|---|---|---|---|
| Синхронная write + projection | Каноническая запись и попытка сразу обновить read model | Отдельный записанный intent и явную допустимость delayed read | Убрать локальную projection-ветку, пока нет внешних читателей |
| Transactional outbox | Каноническую заметку и intent внутри объявленной локальной границы; relay идёт потом | Не даёт мгновенную видимость и не делает consumer exactly-once | Остановить relay, сохранить intent и вернуть согласованный временный read path |
| Синхронная query model | Чтение канонической записи без отдельной projection | Независимый public read и управляемое отставание | Снять adapter, не перенося данные между хранилищами |
Синхронная запись с проекцией подходит, когда read model действительно локальна, её обновление входит в ту же понятную границу и ей не нужен собственный recovery path. В учебном brief это условие не доказано: требуется отдельно записанный intent и независимое публичное чтение. Поэтому вариант не объявлен плохим; у него просто есть две открытые строки в матрице. Если убрать их из brief, он может стать более дешёвым и достаточным.
Синхронная query model сохраняет меньше состояний. Это сильный аргумент, если public read может обращаться к канонической заметке и не нужен отдельный владелец проекции. Но учебный кейс требует независимый read boundary. Пытаться удержать оба требования одной таблицей без явного решения — значит спрятать runtime-сцепление за словом «прямой запрос». Здесь честнее признать ограничение, чем добавлять будущую очередь ради абстрактной масштабируемости.
Outbox выбран только потому, что его declared capabilities совпадают с пятью пунктами brief. Каноническая запись и intent фиксируются в одной локальной учебной операции, а relay может обновить public projection позже. Это не равенство между Map и transactional storage. В реальном проекте ещё придётся доказать границу commit, доступность записи intent, стратегию retry, порядок версий и поведение consumer. Fixture проверяет, что эти вопросы не забыты; она не заменяет их ответами.
Фиксируем решение до первой строки прикладного кода
Решение полезно только тогда, когда его можно опровергнуть. Для этого достаточно короткого ADR-подобного набора: контекст, requirements, допустимые варианты, выбранная граница, evidence, assumption, последствия и условие пересмотра. Формат не обязан быть большим документом. Он обязан позволять следующему человеку понять, почему transactional-outbox был выбран именно для независимой public projection, а не как универсальный ответ на любую запись.
const options = compareArchitectureOptions(architectureReviewBrief);
for (const option of options) {
console.log(option.id, option.acceptedForBrief, option.gaps);
}
// Здесь gaps — требования учебного brief, а не оценка любой архитектуры.
Запись, intent и relay — три разных факта
Когда brief принят, write path создаёт две сущности: каноническую заметку и intent. В fixture commitTrainingPublication не публикует ничего наружу. Она копирует две Map, добавляет note и intent, затем назначает новые Map в state. Такое поведение удобно для детерминированного теста: assertion видит либо обе учебные записи, либо ни одной. Но оно ничего не говорит о crash window между реальными сервисами и не даёт права назвать этот код распределённой транзакцией.
const state = createArchitectureTrainingState();
recordTrainingDecision(state, decision);
const note = { id: 'note-2021-12-architecture', revision: 1, title: 'Граница решения' };
const commit = commitTrainingPublication(state, { decisionId: decision.id, note });
if (commit.state !== 'local-pair-recorded') throw new Error(commit.reason);
// Map copies model only one local policy boundary, not a distributed transaction.
Relay получает только intent key, проверяет соответствующую каноническую revision и создаёт projection. Повтор с тем же ключом возвращает duplicate-relay-suppressed; новый effect не записывается. Это пример того, как ключ превращает словесное «можно переиграть» в конкретное условие. Он не решает idempotency внешнего письма, webhook или cache invalidation. У каждого внешнего effect свой owner и свой contract, который должен появиться в следующем decision, а не быть приписан этому примеру.
Evidence отвечает на один вопрос, assumption — на другой
Хорошее evidence связано с конкретным claim. Для учебной модели claim звучит так: «requirements явны, evidence отделено от assumption, выбранный вариант закрывает declared gaps». Это проверяется функцией и seventeen assertions. Evidence не звучит так: «outbox будет надёжным в production». Чтобы получить второй вывод, нужны совсем другие данные: фактическая модель хранилища, граница транзакции, права relay, нагрузка, failure modes и разрешённый способ измерения.
Assumption нужен не для стыда, а для управления неопределённостью. Здесь assumption один: локальная операция может считать пару note+intent неделимой для fixture. В проекте он превратится в вопрос: какая именно БД, таблица или журнал гарантирует нужную запись? Пока ответа нет, решение остаётся условным. Плохой review записывает assumption мелким шрифтом в конце. Хороший — ставит его рядом с выбором и назначает триггер пересмотра: «если write и intent не входят в одну фактическую границу, возвращаемся к варианту и не строим relay».
Маршрут архитектурного разбора
Маршрут нужен, чтобы сначала собрать границы, а потом рисовать box-and-arrow. Он короткий, но каждый шаг даёт артефакт, который можно показать следующему reviewer. Ни один из шагов не требует выдумывать инцидент, SLO или будущий масштаб.
- Записать один наблюдаемый симптом: какая заметка, какая версия и какой public read должны совпасть. Не использовать «нужна событийная архитектура» как симптом.
- Назвать source of truth, public read owner и эффект, который нельзя потерять. Если владельца нет, сначала исправить модель ответственности.
- Сформулировать requirements в проверяемых словах: каноническая запись, publication intent, допустимость задержки, ключ replay и независимость read path.
- Выписать минимум три допустимых варианта. Для каждого указать не только достоинство, но и requirement gap и обратимый способ выйти из решения.
- Отделить evidence от assumption. Evidence обязан иметь claim и источник; assumption обязан иметь consequence, если он окажется ложным.
- Выбрать вариант только после матрицы. Зафиксировать decision id, последствия, non-goals и условие пересмотра до подключения broker или создания новой таблицы.
- Проверить один детерминированный поток: decision записан, note+intent появились в учебной модели, pending relay диагностируется без удаления source, duplicate key подавляется.
Что fixture проверяет и где она заканчивается
В runArchitectureReviewFixture() семнадцать assertions. Они проверяют, что есть ровно три варианта, outbox закрывает declared brief, two alternatives имеют конкретные gaps, assumption не принимает решение вместо evidence, accepted decision записывается до write, а local pair note+intent появляется вместе. Затем fixture показывает pending diagnosis, один projection effect, suppression duplicate relay и безопасное состояние after relay. Это не демонстрация AWS, C4 tooling, SQL isolation, очереди, сети или операции реальной команды.
const report = runArchitectureReviewFixture();
if (!report.passed) throw new Error('training assertions failed');
console.log(report.assertionCount); // 17
// Assertions are evidence about this local model only.
Ограничения и следующий шаг
Эта статья не предлагает внедрить outbox во все сервисы. Она показывает, как не сделать технологию ответом до появления вопроса. Синхронная запись и projection может быть разумнее при одной локальной границе. Synchronous query может быть разумнее, когда read не нужно отделять. Outbox требует больше состояний, relay, ключа replay и отдельного наблюдения. Его цена должна быть признана до запуска реализации, а не после первого отставания проекции.
Следующий шаг для своего проекта — взять одну реальную, но безопасно обезличенную запись и пройти по маршруту без изменения кода: источник, intent, reader, вариант, evidence, assumption, rollback. Если после этого всё ещё нельзя сказать, какой факт должен пережить повтор, решение пока не готово. Если можно, оформите маленькую fixture или интеграционный тест вокруг этого факта. Тогда будущая схема будет описывать проверяемую границу, а не иллюстрировать уже принятое на веру решение.
Проверяемые источники
- C4 model — заархивированный официальный снимок от 1 декабря 2021 года — снимок подтверждает, что к декабрю 2021 C4 уже описывала уровни context, container, component и code. Наши SVG используют только идею явной границы и не заявляют соответствие нотации или инструменту.
- RFC 2119: Key words for use in RFCs to Indicate Requirement Levels — март 1997 года — первичный документ IETF для слов MUST, SHOULD и MAY. В статьях они обозначают только условия учебного решения, а не требования к чужой системе.
- AWS Builders’ Library: Making retries safe with idempotent APIs — 15 января 2021 года — источник разделяет повтор запроса и семантический effect. Fixture использует локальный ключ intent, но не реализует HTTP API, сеть, таймауты или policy AWS.
- AWS What’s New: публикация статьи Making retries safe with idempotent APIs — 15 января 2021 года — официальная публикационная запись подтверждает дату Builder’s Library article. Дата нужна только для исторической рамки декабря 2021, а не как доказательство свойств учебной модели.
- Transactional outbox: заархивированный авторский снимок паттерна от 18 ноября 2021 года — снимок до исторической границы описывает запись сообщения в транзакции с данными и отдельный message relay. Он задаёт терминологию паттерна, но fixture не реализует БД, broker, 2PC или exactly-once delivery.