DarkRiDDeR16 мин

Под капотом: как архитектурное решение превращается в проверяемый контракт

АрхитектураКоманда

Симптом здесь другой: схема уже нарисована, но любой 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».

Минимальный contract архитектурного решения
ПолеЧто фиксируетКак проверить в 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 переименованием схемы.

Схема проверки решения: brief содержит пять requirements и non-goals; три варианта проходят через сравнение gaps; fixture evidence подтверждает форму учебного decision, assumption остаётся отдельной веткой; выбранный transactional outbox ведёт к локальной паре note плюс intent и затем к relay
Механика решения: evidence подтверждает только заявленный claim, а assumption сохраняет условие пересмотра.

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.

  1. Выбрать один bounded flow и назвать его source of truth, а не рисовать всю платформу. Для кейса это одна заметка и один public read.
  2. Записать requirements и non-goals отдельными списками. У каждого requirement должен быть владелец состояния либо действие, которое его проверяет.
  3. Выписать три реально допустимых варианта. Указать capability, gap и reversible action; не превращать отсутствующее требование в «минус к баллу».
  4. Создать evidence с claim, subject и границей наблюдения. Создать assumption с consequence, если она ложна. Никогда не давать assumption тип evidence.
  5. Принять decision только при нулевых gaps выбранного варианта. Если requirements поменялись, открыть новый decision, а не редактировать историю выбора задним числом.
  6. Смоделировать один write path: note, intent, key и relay. Явно написать, что модель не является storage transaction или transport.
  7. Добавить диагностические состояния до исправлений. Каждый 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. Именно эта возможность пересмотра делает архитектурное решение рабочим, а не окончательным лозунгом.

Проверяемые источники