Симптом здесь другой: схема уже нарисована, но любой reviewer читает её по-своему. Один считает стрелку «записать → отправить» атомарной, другой — асинхронной, третий не видит, где хранится факт публикации. Цена не в красоте диаграммы. Когда появляется повтор или нужно заменить read model, команда не может отделить обязательное свойство решения от случайной реализации. Тогда любое исправление становится спором о намерении, а не проверкой состояния.
Ниже я разбираю механизм, который превращает архитектурный выбор в маленький проверяемый contract. Учебный объект содержит requirements, вариант, evidence, assumption, reversibility и non-goals. Он моделирует публикацию одной рабочей заметки через каноническую запись, publication intent и независимую projection. Никакой Map в тексте не обещает database transaction, очередь, delivery guarantee, реальную метрику или одобрение дизайна существующего проекта. Его работа скромнее: не дать незаданному условию спрятаться между блоками схемы.
Схема показывает элементы, решение — обязательства
Решение начинается с двух списков. Первый — requirements: свойства, без которых учебный кейс не выполнен. Второй — non-goals: свойства, за которые этот ADR не отвечает. В brief они разделены намеренно. canonical-write-record означает, что есть владелец исходной заметки. recorded-publication-intent требует отдельный след намерения. independent-public-read запрещает назвать read model просто ещё одним полем write path. А distributed-transaction лежит в non-goals, чтобы никто не получил его по умолчанию из слова «outbox».
| Поле | Что фиксирует | Как проверить в fixture | Чего не доказывает |
|---|---|---|---|
| Context / brief | Границу задачи: публикация заметки и три нужных владельца состояния | brief содержит id, requirements и non-goals | Что такая граница существует в production |
| Option | Конкретный путь: synchronous projection, outbox или synchronous query | матрица возвращает id и gaps каждого варианта | Что вариант оптимален для всех сервисов |
| Evidence | Наблюдение с claim и областью действия | kind fixture-evidence связан с brief.id | Что assumption подтвердилось во внешней системе |
| Assumption | Неизвестное условие и цена ошибки | kind assumption не проходит проверку evidence | Что риск уже устранён |
| Reversibility | Как вернуться из варианта без скрытой миграции | option содержит обратимое действие | Что rollback выполнится без проверки окружения |
Requirements проверяют вариант, а не подгоняются под него
Три варианта в fixture специально выглядят правдоподобно. Синхронная запись и projection может дать быструю картину, но для этого brief не фиксирует отдельный persisted intent и не объявляет задержку чтения. Synchronous query бережёт состояния, но не создаёт независимый read model. Transactional outbox закрывает все пять требований, потому что note и intent разделены, relay имеет ключ, а projection разрешено обновить позже. Это не соревнование паттернов. Это проверка соответствия выбранному набору условий.
const options = compareArchitectureOptions(architectureReviewBrief);
for (const option of options) {
console.log(option.id, option.acceptedForBrief, option.gaps);
}
// Здесь gaps — требования учебного brief, а не оценка любой архитектуры.
Функция возвращает gaps именно вместо score. Score создаёт ложную точность: разница между 7 и 8 не объясняет, какое условие потеряно. Gap даёт предметный вопрос. У синхронной projection это recorded-publication-intent; у query model — independent-public-read. Разговор можно продолжить двумя способами: изменить brief, если требование оказалось лишним, или изменить вариант, если требование настоящее. Нельзя честно закрыть gap переименованием схемы.
Evidence не усиливает assumption задним числом
Самая опасная строка в ADR часто выглядит убедительно: «хранилище атомарно запишет заметку и событие». До проверки это assumption. В fixture она названа прямо: локальная функция способна подготовить две копии Map и применить их вместе. Такое свойство существует только внутри процесса и лишь для этой операции. Evidence модели доказывает другое: fixture-evidence имеет правильный subject, claim и observedIn; assumption с другим kind не может принять decision. Это не педантизм типов, а защита от перехода «мы надеемся» → «мы гарантируем» без нового наблюдения.
const decision = decidePublicationArchitecture({
brief: architectureReviewBrief,
evidence: trainingEvidence,
assumption: trainingAssumption,
});
if (decision.status !== 'accepted-for-training') {
throw new Error('review before implementation');
}
Локальная пара note + intent моделирует policy, а не транспорт
После accepted decision fixture разрешает commitTrainingPublication. Она проверяет decision id, форму note и отсутствие предыдущего note или intent с тем же ключом. Затем создаёт canonicalNote и intent, копирует обе Map и устанавливает новые значения. Результат local-pair-recorded означает только одно: policy модели не оставила наполовину созданную учебную пару. В коде прямо написано, что это не atomicity между БД, broker, HTTP, процессами или машинами.
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.
Зачем тогда такая модель? Потому что она не даёт тексту скрыть порядок. Public projection не создаётся внутри commit. До relay диагноз обязан вернуть outbox-pending. Это отделяет «намерение записано» от «читатель уже видит». Когда реальная система будет выбрана, те же состояния можно сопоставить с её таблицей, журналом, CDC или worker. Если сопоставления нет, решение не следует переносить: новая технология не обязана иметь те же failure modes.
Ключ replay — часть механизма, а не подпись на схеме
Intent key в учебном кейсе строится как noteId:revision. Его назначение ограничено: отличить один intent публикации версии заметки от его повторной доставки. Relay добавляет этот ключ в acceptedIntentKeys; повтор возвращает duplicate-relay-suppressed и не создаёт вторую projection. Это не доказательство exactly-once. Никакая Map не моделирует падение между внешним effect и записью receipt, потерю сети, TTL, split brain или повтор от внешнего consumer.
Диагноз опирается на state, а не на догадку
Когда публичная заметка не видна, полезно не перезапускать relay первым действием. diagnoseTrainingPublication различает отсутствие decision, отсутствие canonical note, отсутствие intent, pending intent, stale projection и ready read. Для каждой ветки задано действие без удаления source. Это не runbook production; это минимальный contract, который не позволяет притянуть одну реакцию к разным причинам.
const beforeRelay = diagnoseTrainingPublication(state, {
decisionId: decision.id, noteId: note.id, revision: note.revision,
});
// beforeRelay.stage === 'outbox-pending'
relayTrainingPublication(state, commit.intent.key);
const afterRelay = diagnoseTrainingPublication(state, {
decisionId: decision.id, noteId: note.id, revision: note.revision,
});
// afterRelay.stage === 'public-read-ready-in-training'
Внутри учебной модели pending означает только состояние Map. В живой среде аналог может быть очередью, таблицей, change stream или ошибкой permissions. Поэтому правильный перенос — не копировать строку outbox-pending, а выбрать наблюдение, которое подтверждает эту же границу: key, версия, время постановки, состояние relay и владелец следующего шага. Если наблюдения нет, сначала добавить его в design. Править source до этого рискованно: можно создать вторую версию, а исходное evidence потерять.
Маршрут механического review до реализации
Этот порядок позволяет провести разбор без фиктивных цифр и без выбора cloud provider. На каждом шаге появляется небольшой проверяемый артефакт: brief, matrix, evidence, assumption, code-level policy или diagnostic state.
- Выбрать один bounded flow и назвать его source of truth, а не рисовать всю платформу. Для кейса это одна заметка и один public read.
- Записать requirements и non-goals отдельными списками. У каждого requirement должен быть владелец состояния либо действие, которое его проверяет.
- Выписать три реально допустимых варианта. Указать capability, gap и reversible action; не превращать отсутствующее требование в «минус к баллу».
- Создать evidence с claim, subject и границей наблюдения. Создать assumption с consequence, если она ложна. Никогда не давать assumption тип evidence.
- Принять decision только при нулевых gaps выбранного варианта. Если requirements поменялись, открыть новый decision, а не редактировать историю выбора задним числом.
- Смоделировать один write path: note, intent, key и relay. Явно написать, что модель не является storage transaction или transport.
- Добавить диагностические состояния до исправлений. Каждый state должен вести к сохранению evidence, проверке границы и обратимому действию.
Fixture даёт отрицательные проверки, а не только happy path
В отчёте fixture есть семнадцать assertions. Самые важные — отрицательные: synchronous write и synchronous query не проходят текущий brief; assumption не может стать evidence; неизвестный вариант не получает accepted status; pending intent не разрешает destructive action; duplicate relay не создаёт второй effect. Такие проверки важнее успешного projection-recorded, потому что именно на границе «не делай этого» обычно появляется скрытый долг.
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.
Историческая рамка и последствия выбора
Историческая рамка проверена: C4 snapshot — 1 декабря 2021, AWS publication record и Builders’ Library article — 15 января 2021, авторский snapshot transactional outbox — 18 ноября 2021, RFC 2119 — март 1997. Это не ссылки из будущего. Они задают язык границ, требований, retry и отдельного relay, но не назначают технологический стек учебному проекту.
Последствие выбранного outbox в этой модели — больше состояний: note, intent, relay result, projection и ключ replay. Это цена независимого read path и записанного intent. Если команда не готова владеть этими состояниями, честный результат review — не «внедрить половину outbox», а изменить brief и рассмотреть simpler option. Следующий шаг — проверить фактическую границу хранения на одном безопасном prototype, затем обновить evidence или пересмотреть decision. Именно эта возможность пересмотра делает архитектурное решение рабочим, а не окончательным лозунгом.
Проверяемые источники
- 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.