function escapeHtml(value) { return String(value) .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); } function paragraph(text) { return '

' + text + '

'; } function heading(text) { return '

' + text + '

'; } function codeBlock(lines) { return '
' + escapeHtml(Array.isArray(lines) ? lines.join('\n') : lines) + '
'; } function figure(src, alt, caption) { return '
' + alt + '
' + caption + '
'; } function orderedList(items) { return '
    ' + items.map((item) => '
  1. ' + item + '
  2. ').join('') + '
'; } function dataTable(caption, headers, rows) { const head = '' + headers.map((header) => '' + header + '').join('') + ''; const body = '' + rows.map((row) => '' + row.map((cell) => '' + cell + '').join('') + '').join('') + ''; return '
' + head + body + '
' + escapeHtml(caption) + '
'; } function sourceList(items) { return ''; } function plainText(content) { return content .replace(/<[^>]+>/g, ' ') .replaceAll(' ', ' ') .replaceAll('"', '"') .replaceAll(''', "'") .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('&', '&') .replace(/\s+/g, ' ') .trim(); } function bodyText(content) { return plainText(content.replace(/

Проверяемые источники<\/h2>[\s\S]*?(?=

|$)/, '')); } function createRevision(meta, bodyParts, sources) { if (sources.length < 2) { throw new Error(meta.slug + ': нужно минимум два проверяемых источника'); } const contentHtml = bodyParts.join('\n') + '\n' + heading('Проверяемые источники') + '\n' + sourceList(sources); const proseLength = bodyText(contentHtml).length; if (proseLength < 5000 || proseLength > 15000) { throw new Error(meta.slug + ': основной текст вне 5 000–15 000 знаков: ' + proseLength); } return { ...meta, contentHtml, proseLength }; } const c4Model202112 = { title: 'C4 model — заархивированный официальный снимок от 1 декабря 2021 года', url: 'https://web.archive.org/web/20211201095833/https://c4model.com/', note: 'снимок подтверждает, что к декабрю 2021 C4 уже описывала уровни context, container, component и code. Наши SVG используют только идею явной границы и не заявляют соответствие нотации или инструменту.', }; const rfc2119 = { title: 'RFC 2119: Key words for use in RFCs to Indicate Requirement Levels — март 1997 года', url: 'https://www.rfc-editor.org/rfc/rfc2119.html', note: 'первичный документ IETF для слов MUST, SHOULD и MAY. В статьях они обозначают только условия учебного решения, а не требования к чужой системе.', }; const awsIdempotency202101 = { title: 'AWS Builders’ Library: Making retries safe with idempotent APIs — 15 января 2021 года', url: 'https://aws.amazon.com/builders-library/making-retries-safe-with-idempotent-APIs/', note: 'источник разделяет повтор запроса и семантический effect. Fixture использует локальный ключ intent, но не реализует HTTP API, сеть, таймауты или policy AWS.', }; const awsIdempotencyRelease202101 = { title: 'AWS What’s New: публикация статьи Making retries safe with idempotent APIs — 15 января 2021 года', url: 'https://aws.amazon.com/about-aws/whats-new/2021/01/new-abl-article-making-retries-safe-with-idempotent-APIs/', note: 'официальная публикационная запись подтверждает дату Builder’s Library article. Дата нужна только для исторической рамки декабря 2021, а не как доказательство свойств учебной модели.', }; const transactionalOutbox202111 = { title: 'Transactional outbox: заархивированный авторский снимок паттерна от 18 ноября 2021 года', url: 'https://web.archive.org/web/20211118092826/https://microservices.io/patterns/data/transactional-outbox.html', note: 'снимок до исторической границы описывает запись сообщения в транзакции с данными и отдельный message relay. Он задаёт терминологию паттерна, но fixture не реализует БД, broker, 2PC или exactly-once delivery.', }; export const architectureReviewBrief = Object.freeze({ id: 'training-adr-public-notes-2021-12', subject: 'публикация рабочих заметок', requirements: Object.freeze([ 'canonical-write-record', 'recorded-publication-intent', 'independent-public-read', 'delayed-read-is-explicit', 'replay-key-is-explicit', ]), nonGoals: Object.freeze([ 'distributed-transaction', 'measured-production-slo', 'broker-selection', 'cloud-provider-selection', ]), }); export const trainingAssumption = Object.freeze({ kind: 'assumption', id: 'assumption-single-local-commit-boundary', subject: architectureReviewBrief.id, statement: 'Учебная функция может подготовить запись заметки и intent в одной локальной операции с копиями Map.', consequenceIfFalse: 'Нужна новая проверка границы хранения; текущий выбор outbox не переносится автоматически.', }); export const trainingEvidence = Object.freeze({ kind: 'fixture-evidence', id: 'evidence-brief-reviewed-v1', subject: architectureReviewBrief.id, claim: 'requirements-are-explicit', observedIn: 'deterministic-node-fixture', observation: 'Fixture проверяет, что решение хранит requirements, отдельно принимает evidence и оставляет assumption непроверенным.', }); export const trainingArchitectureOptions = Object.freeze([ Object.freeze({ id: 'synchronous-write-projection', label: 'Синхронная запись и проекция', supports: Object.freeze(['canonical-write-record', 'independent-public-read', 'replay-key-is-explicit']), excludes: Object.freeze(['recorded-publication-intent', 'delayed-read-is-explicit']), reversibleBy: 'удалить локальную projection-ветку до появления внешних читателей', }), Object.freeze({ id: 'transactional-outbox', label: 'Локальная запись плюс transactional outbox', supports: Object.freeze([ 'canonical-write-record', 'recorded-publication-intent', 'independent-public-read', 'delayed-read-is-explicit', 'replay-key-is-explicit', ]), excludes: Object.freeze([]), reversibleBy: 'остановить relay, сохранить intent и вернуть public read к явно согласованному временному режиму', }), Object.freeze({ id: 'synchronous-query-model', label: 'Синхронное чтение канонической заметки', supports: Object.freeze(['canonical-write-record', 'recorded-publication-intent', 'replay-key-is-explicit']), excludes: Object.freeze(['independent-public-read', 'delayed-read-is-explicit']), reversibleBy: 'снять read adapter без переноса данных между хранилищами', }), ]); function hasOwn(value, key) { return Object.prototype.hasOwnProperty.call(value, key); } function hasNonEmptyString(value) { return typeof value === 'string' && value.trim().length > 0; } function isFixtureEvidence(value, subject) { return Boolean(value) && value.kind === 'fixture-evidence' && value.subject === subject && hasNonEmptyString(value.id) && hasNonEmptyString(value.claim) && hasNonEmptyString(value.observedIn); } function isAssumption(value, subject) { return Boolean(value) && value.kind === 'assumption' && value.subject === subject && hasNonEmptyString(value.id) && hasNonEmptyString(value.statement); } export function compareArchitectureOptions(brief = architectureReviewBrief, options = trainingArchitectureOptions) { return options.map((option) => { const gaps = brief.requirements.filter((requirement) => !option.supports.includes(requirement)); return Object.freeze({ id: option.id, label: option.label, acceptedForBrief: gaps.length === 0, gaps: Object.freeze(gaps), reversibleBy: option.reversibleBy, }); }); } /** * Здесь нет выбора универсально «лучшей» архитектуры. Функция показывает, * удовлетворяет ли заранее объявленный вариант ровно учебному brief. */ export function decidePublicationArchitecture({ brief = architectureReviewBrief, evidence = trainingEvidence, assumption = trainingAssumption, selectedOptionId = 'transactional-outbox', } = {}) { const comparison = compareArchitectureOptions(brief); const selected = comparison.find((option) => option.id === selectedOptionId); const evidenceAccepted = isFixtureEvidence(evidence, brief.id); const assumptionSeparated = isAssumption(assumption, brief.id) && assumption.kind !== evidence.kind; if (!selected) { return Object.freeze({ status: 'review-needed', reason: 'unknown-option', comparison: Object.freeze(comparison), }); } if (!evidenceAccepted) { return Object.freeze({ status: 'review-needed', reason: 'evidence-missing-or-outside-brief', comparison: Object.freeze(comparison), }); } if (!assumptionSeparated) { return Object.freeze({ status: 'review-needed', reason: 'assumption-not-separated-from-evidence', comparison: Object.freeze(comparison), }); } if (!selected.acceptedForBrief) { return Object.freeze({ status: 'review-needed', reason: 'selected-option-has-requirement-gaps', gaps: selected.gaps, comparison: Object.freeze(comparison), }); } return Object.freeze({ id: 'adr-training-public-notes-2021-12-v1', status: 'accepted-for-training', briefId: brief.id, selectedOptionId, requirements: brief.requirements, evidenceId: evidence.id, assumptionId: assumption.id, comparison: Object.freeze(comparison), reversibility: selected.reversibleBy, }); } export function createArchitectureTrainingState() { return { decisions: new Map(), notes: new Map(), outbox: new Map(), projection: new Map(), acceptedIntentKeys: new Set(), }; } export function recordTrainingDecision(state, decision) { if (!decision || decision.status !== 'accepted-for-training') { return Object.freeze({ state: 'decision-not-recorded', reason: 'decision-is-not-accepted-for-training' }); } state.decisions.set(decision.id, decision); return Object.freeze({ state: 'decision-recorded', decisionId: decision.id }); } function makeIntentKey(noteId, revision) { return noteId + ':' + revision; } /** * Копирование двух Map — только детерминированная модель проверки policy. * Оно не даёт atomicity между БД, broker, HTTP, процессами или машинами. */ export function commitTrainingPublication(state, { decisionId, note }) { const decision = state.decisions.get(decisionId); if (!decision || decision.status !== 'accepted-for-training') { return Object.freeze({ state: 'commit-rejected', reason: 'accepted-decision-is-required' }); } if (!note || !hasNonEmptyString(note.id) || !hasNonEmptyString(note.title) || !Number.isInteger(note.revision)) { return Object.freeze({ state: 'commit-rejected', reason: 'invalid-training-note' }); } const intentKey = makeIntentKey(note.id, note.revision); if (state.notes.has(note.id) || state.outbox.has(intentKey)) { return Object.freeze({ state: 'commit-rejected', reason: 'note-or-intent-already-recorded', intentKey }); } const nextNotes = new Map(state.notes); const nextOutbox = new Map(state.outbox); const canonicalNote = Object.freeze({ ...note, decisionId, status: 'published-in-training' }); const intent = Object.freeze({ key: intentKey, noteId: note.id, revision: note.revision, decisionId, state: 'pending', }); nextNotes.set(canonicalNote.id, canonicalNote); nextOutbox.set(intent.key, intent); state.notes = nextNotes; state.outbox = nextOutbox; return Object.freeze({ state: 'local-pair-recorded', note: canonicalNote, intent }); } export function relayTrainingPublication(state, intentKey) { const intent = state.outbox.get(intentKey); if (!intent) { return Object.freeze({ state: 'relay-rejected', reason: 'intent-missing' }); } const note = state.notes.get(intent.noteId); if (!note || note.revision !== intent.revision) { return Object.freeze({ state: 'relay-rejected', reason: 'canonical-note-mismatch' }); } if (state.acceptedIntentKeys.has(intentKey)) { return Object.freeze({ state: 'duplicate-relay-suppressed', intentKey }); } const nextOutbox = new Map(state.outbox); const relayedIntent = Object.freeze({ ...intent, state: 'relayed' }); const projection = Object.freeze({ noteId: note.id, revision: note.revision, title: note.title, intentKey, decisionId: note.decisionId, }); nextOutbox.set(intentKey, relayedIntent); state.outbox = nextOutbox; state.projection.set(note.id, projection); state.acceptedIntentKeys.add(intentKey); return Object.freeze({ state: 'projection-recorded', projection, intent: relayedIntent }); } export function diagnoseTrainingPublication(state, { decisionId, noteId, revision }) { const decision = state.decisions.get(decisionId); const intentKey = makeIntentKey(noteId, revision); const note = state.notes.get(noteId); const intent = state.outbox.get(intentKey); const projection = state.projection.get(noteId); if (!decision) { return Object.freeze({ stage: 'decision-missing', action: 'record-context-options-evidence-and-reversibility-before-writing-note', destructiveAction: false }); } if (!note) { return Object.freeze({ stage: 'canonical-note-missing', action: 'check-the-canonical-write-evidence-before-retrying-publication', destructiveAction: false }); } if (!intent) { return Object.freeze({ stage: 'publication-intent-missing', action: 'stop-and-review-the-write-boundary-before-creating-a-second-note', destructiveAction: false }); } if (intent.state === 'pending') { return Object.freeze({ stage: 'outbox-pending', action: 'preserve-intent-key-and-observe-the-relay-path-before-changing-the-source', destructiveAction: false }); } if (!projection || projection.revision !== note.revision) { return Object.freeze({ stage: 'projection-missing-or-stale', action: 'compare-intent-and-projection-evidence-before-replaying-the-current-key', destructiveAction: false }); } return Object.freeze({ stage: 'public-read-ready-in-training', action: 'verify-the-declared-read-contract-and-record-the-result', destructiveAction: false }); } export function runArchitectureReviewFixture() { const assertions = []; const assert = (label, condition) => assertions.push(Object.freeze({ label, passed: Boolean(condition) })); const comparison = compareArchitectureOptions(); const syncWrite = comparison.find((option) => option.id === 'synchronous-write-projection'); const outbox = comparison.find((option) => option.id === 'transactional-outbox'); const syncQuery = comparison.find((option) => option.id === 'synchronous-query-model'); assert('three options are compared', comparison.length === 3); assert('synchronous write projection has declared gaps', syncWrite && !syncWrite.acceptedForBrief && syncWrite.gaps.includes('recorded-publication-intent')); assert('outbox covers the declared brief', outbox && outbox.acceptedForBrief && outbox.gaps.length === 0); assert('synchronous query has declared independent-read gap', syncQuery && syncQuery.gaps.includes('independent-public-read')); assert('assumption is not evidence', trainingAssumption.kind !== trainingEvidence.kind); assert('fixture evidence is scoped to the brief', isFixtureEvidence(trainingEvidence, architectureReviewBrief.id)); const rejectedWithoutEvidence = decidePublicationArchitecture({ evidence: trainingAssumption }); assert('an assumption cannot accept a decision', rejectedWithoutEvidence.status === 'review-needed' && rejectedWithoutEvidence.reason === 'evidence-missing-or-outside-brief'); const decision = decidePublicationArchitecture(); assert('declared decision is accepted for training', decision.status === 'accepted-for-training'); assert('selected option is transactional outbox', decision.selectedOptionId === 'transactional-outbox'); const state = createArchitectureTrainingState(); const recorded = recordTrainingDecision(state, decision); assert('accepted decision is recorded before write', recorded.state === 'decision-recorded'); const note = Object.freeze({ id: 'note-2021-12-architecture', revision: 1, title: 'Граница решения' }); const commit = commitTrainingPublication(state, { decisionId: decision.id, note }); assert('local model records canonical note and intent together', commit.state === 'local-pair-recorded' && state.notes.size === 1 && state.outbox.size === 1); assert('local model is not described as distributed transaction', !hasOwn(commit, 'distributedTransaction')); const pendingDiagnosis = diagnoseTrainingPublication(state, { decisionId: decision.id, noteId: note.id, revision: note.revision }); assert('pending intent has a non-destructive diagnosis', pendingDiagnosis.stage === 'outbox-pending' && pendingDiagnosis.destructiveAction === false); const relay = relayTrainingPublication(state, commit.intent.key); assert('relay records one projection with the intent key', relay.state === 'projection-recorded' && relay.projection.intentKey === commit.intent.key); const duplicate = relayTrainingPublication(state, commit.intent.key); assert('duplicate relay is suppressed by explicit key', duplicate.state === 'duplicate-relay-suppressed' && state.projection.size === 1); const readyDiagnosis = diagnoseTrainingPublication(state, { decisionId: decision.id, noteId: note.id, revision: note.revision }); assert('ready projection has a non-destructive diagnosis', readyDiagnosis.stage === 'public-read-ready-in-training' && readyDiagnosis.destructiveAction === false); const unknown = decidePublicationArchitecture({ selectedOptionId: 'mystery-solution' }); assert('unknown option remains review-needed', unknown.status === 'review-needed' && unknown.reason === 'unknown-option'); const passed = assertions.every((assertion) => assertion.passed); if (!passed) { throw new Error('architecture review fixture failed: ' + assertions.filter((assertion) => !assertion.passed).map((assertion) => assertion.label).join(', ')); } return Object.freeze({ assertionCount: assertions.length, passed, assertions: Object.freeze(assertions), boundary: 'Map-based deterministic training model; not a database, broker, network, distributed transaction, production deployment, SLO, incident record, or architecture approval for a real project.', }); } const decisionCode = [ "const decision = decidePublicationArchitecture({", " brief: architectureReviewBrief,", " evidence: trainingEvidence,", " assumption: trainingAssumption,", "});", "if (decision.status !== 'accepted-for-training') {", " throw new Error('review before implementation');", "}", ]; const comparisonCode = [ 'const options = compareArchitectureOptions(architectureReviewBrief);', 'for (const option of options) {', " console.log(option.id, option.acceptedForBrief, option.gaps);", '}', '// Здесь gaps — требования учебного brief, а не оценка любой архитектуры.', ]; const commitCode = [ '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.', ]; const diagnosisCode = [ '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'", ]; const fixtureCode = [ '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.', ]; const practiceArticle = createRevision( { slug: 'editorial-2021-12-practice-architecture-review', title: 'Архитектурный разбор: как выбрать границу публикации рабочих заметок', categories: ['Архитектура', 'Команда'], cover: '/assets/editorial/2021/architecture-review-context-2021.svg', excerpt: 'Учебный разбор трёх способов публикации рабочих заметок: какие инварианты назвать до кода, где проходит граница записи и чтения, зачем отделять assumption от evidence и как оставить решение обратимым.', readingMinutes: 16, }, [ paragraph('Симптом архитектурного спора обычно звучит безобидно: «после сохранения заметка должна появиться в публичном чтении». Цена появляется позже. Один разработчик делает синхронную запись и проекцию, другой предлагает очередь, третий читает ту же таблицу напрямую. Все три варианта могут вывести текст на экран, но после повторной доставки или частичного сбоя невозможно ответить, какая запись была источником, был ли зафиксирован сам факт публикации и что безопасно повторить. Начинать с технологии в такой ситуации дорого: схема быстро станет обязательством, а причина выбора останется в памяти участников.'), paragraph('Разберём учебный кейс: сервис публикует рабочие заметки. Нужны каноническая запись, явный intent публикации, отдельное публичное чтение и ключ для controlled replay. Разрешено, что read model отстанет; запрещено выдавать маленькую fixture за транзакцию между базой и broker. Это не описание существующей команды, продукта, SLO или инцидента. Проверка отвечает на конкретные условия, а не на красивое имя паттерна.'), heading('Сначала сужаем вопрос до границы ответственности'), paragraph('Вопрос «нужен ли нам outbox?» слишком широк. Его нельзя проверить, пока не названо, какая часть данных считается канонической и какой факт должен пережить повтор. В учебном brief канонической является заметка, а intent публикации — отдельная запись с ключом noteId:revision. Публичная проекция принадлежит read path, поэтому она не может сама доказывать, что заметка была принята write path. Это различие защищает от частой ошибки: принять успешный HTTP-ответ или видимый заголовок за доказательство всей цепочки.'), paragraph('Условие recorded-publication-intent не означает «сразу отправить сообщение». Оно означает, что решение обязано назвать состояние, по которому потом можно спросить: была ли публикация запланирована, какой версии заметки она соответствует и какой decision это разрешил. Условие delayed-read-is-explicit не обещает скорость. Оно только не позволяет скрыть отставание projection за фразой «в конце концов появится». Если проекту требуется строго синхронное чтение, brief меняется, а старое решение получает статус review-needed.'), codeBlock(decisionCode), heading('Сравниваем варианты по инвариантам, а не по модным названиям'), dataTable( 'Три допустимых варианта для учебного brief о публикации заметок', ['Вариант', 'Что доказывает', 'Не закрывает в этом 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, не перенося данные между хранилищами'], ], ), paragraph('Синхронная запись с проекцией подходит, когда read model действительно локальна, её обновление входит в ту же понятную границу и ей не нужен собственный recovery path. В учебном brief это условие не доказано: требуется отдельно записанный intent и независимое публичное чтение. Поэтому вариант не объявлен плохим; у него просто есть две открытые строки в матрице. Если убрать их из brief, он может стать более дешёвым и достаточным.'), paragraph('Синхронная query model сохраняет меньше состояний. Это сильный аргумент, если public read может обращаться к канонической заметке и не нужен отдельный владелец проекции. Но учебный кейс требует независимый read boundary. Пытаться удержать оба требования одной таблицей без явного решения — значит спрятать runtime-сцепление за словом «прямой запрос». Здесь честнее признать ограничение, чем добавлять будущую очередь ради абстрактной масштабируемости.'), paragraph('Outbox выбран только потому, что его declared capabilities совпадают с пятью пунктами brief. Каноническая запись и intent фиксируются в одной локальной учебной операции, а relay может обновить public projection позже. Это не равенство между Map и transactional storage. В реальном проекте ещё придётся доказать границу commit, доступность записи intent, стратегию retry, порядок версий и поведение consumer. Fixture проверяет, что эти вопросы не забыты; она не заменяет их ответами.'), figure( '/assets/editorial/2021/architecture-review-context-2021.svg', 'Контекст учебного решения: автор сохраняет каноническую рабочую заметку, одна локальная граница фиксирует заметку и publication intent, затем relay по ключу noteId:revision обновляет независимую публичную проекцию; рядом явно отделены fixture evidence и непроверенное assumption', 'Контекст не показывает инфраструктуру. Он показывает владельцев состояния и доказательство, которое нужно до реализации.', ), heading('Фиксируем решение до первой строки прикладного кода'), paragraph('Решение полезно только тогда, когда его можно опровергнуть. Для этого достаточно короткого ADR-подобного набора: контекст, requirements, допустимые варианты, выбранная граница, evidence, assumption, последствия и условие пересмотра. Формат не обязан быть большим документом. Он обязан позволять следующему человеку понять, почему transactional-outbox был выбран именно для независимой public projection, а не как универсальный ответ на любую запись.'), codeBlock(comparisonCode), heading('Запись, intent и relay — три разных факта'), paragraph('Когда brief принят, write path создаёт две сущности: каноническую заметку и intent. В fixture commitTrainingPublication не публикует ничего наружу. Она копирует две Map, добавляет note и intent, затем назначает новые Map в state. Такое поведение удобно для детерминированного теста: assertion видит либо обе учебные записи, либо ни одной. Но оно ничего не говорит о crash window между реальными сервисами и не даёт права назвать этот код распределённой транзакцией.'), codeBlock(commitCode), paragraph('Relay получает только intent key, проверяет соответствующую каноническую revision и создаёт projection. Повтор с тем же ключом возвращает duplicate-relay-suppressed; новый effect не записывается. Это пример того, как ключ превращает словесное «можно переиграть» в конкретное условие. Он не решает idempotency внешнего письма, webhook или cache invalidation. У каждого внешнего effect свой owner и свой contract, который должен появиться в следующем decision, а не быть приписан этому примеру.'), heading('Evidence отвечает на один вопрос, assumption — на другой'), paragraph('Хорошее evidence связано с конкретным claim. Для учебной модели claim звучит так: «requirements явны, evidence отделено от assumption, выбранный вариант закрывает declared gaps». Это проверяется функцией и seventeen assertions. Evidence не звучит так: «outbox будет надёжным в production». Чтобы получить второй вывод, нужны совсем другие данные: фактическая модель хранилища, граница транзакции, права relay, нагрузка, failure modes и разрешённый способ измерения.'), paragraph('Assumption нужен не для стыда, а для управления неопределённостью. Здесь assumption один: локальная операция может считать пару note+intent неделимой для fixture. В проекте он превратится в вопрос: какая именно БД, таблица или журнал гарантирует нужную запись? Пока ответа нет, решение остаётся условным. Плохой review записывает assumption мелким шрифтом в конце. Хороший — ставит его рядом с выбором и назначает триггер пересмотра: «если write и intent не входят в одну фактическую границу, возвращаемся к варианту и не строим relay». '), heading('Маршрут архитектурного разбора'), paragraph('Маршрут нужен, чтобы сначала собрать границы, а потом рисовать box-and-arrow. Он короткий, но каждый шаг даёт артефакт, который можно показать следующему reviewer. Ни один из шагов не требует выдумывать инцидент, SLO или будущий масштаб.'), orderedList([ 'Записать один наблюдаемый симптом: какая заметка, какая версия и какой 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 подавляется.', ]), heading('Что fixture проверяет и где она заканчивается'), paragraph('В 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, очереди, сети или операции реальной команды.'), codeBlock(fixtureCode), heading('Ограничения и следующий шаг'), paragraph('Эта статья не предлагает внедрить outbox во все сервисы. Она показывает, как не сделать технологию ответом до появления вопроса. Синхронная запись и projection может быть разумнее при одной локальной границе. Synchronous query может быть разумнее, когда read не нужно отделять. Outbox требует больше состояний, relay, ключа replay и отдельного наблюдения. Его цена должна быть признана до запуска реализации, а не после первого отставания проекции.'), paragraph('Следующий шаг для своего проекта — взять одну реальную, но безопасно обезличенную запись и пройти по маршруту без изменения кода: источник, intent, reader, вариант, evidence, assumption, rollback. Если после этого всё ещё нельзя сказать, какой факт должен пережить повтор, решение пока не готово. Если можно, оформите маленькую fixture или интеграционный тест вокруг этого факта. Тогда будущая схема будет описывать проверяемую границу, а не иллюстрировать уже принятое на веру решение.'), ], [c4Model202112, rfc2119, awsIdempotency202101, awsIdempotencyRelease202101, transactionalOutbox202111], ); const mechanismArticle = createRevision( { slug: 'editorial-2021-12-mechanism-architecture-review', title: 'Под капотом: как архитектурное решение превращается в проверяемый контракт', categories: ['Архитектура', 'Команда'], cover: '/assets/editorial/2021/architecture-review-decision-2021.svg', excerpt: 'Механика архитектурного review для учебной публикации заметок: requirements вместо вкусов, evidence отдельно от assumptions, сравнение вариантов по gaps, ключ replay и граница, которую можно откатить.', readingMinutes: 16, }, [ paragraph('Симптом здесь другой: схема уже нарисована, но любой reviewer читает её по-своему. Один считает стрелку «записать → отправить» атомарной, другой — асинхронной, третий не видит, где хранится факт публикации. Цена не в красоте диаграммы. Когда появляется повтор или нужно заменить read model, команда не может отделить обязательное свойство решения от случайной реализации. Тогда любое исправление становится спором о намерении, а не проверкой состояния.'), paragraph('Ниже я разбираю механизм, который превращает архитектурный выбор в маленький проверяемый contract. Учебный объект содержит requirements, вариант, evidence, assumption, reversibility и non-goals. Он моделирует публикацию одной рабочей заметки через каноническую запись, publication intent и независимую projection. Никакой Map в тексте не обещает database transaction, очередь, delivery guarantee, реальную метрику или одобрение дизайна существующего проекта. Его работа скромнее: не дать незаданному условию спрятаться между блоками схемы.'), heading('Схема показывает элементы, решение — обязательства'), paragraph('Решение начинается с двух списков. Первый — requirements: свойства, без которых учебный кейс не выполнен. Второй — non-goals: свойства, за которые этот ADR не отвечает. В brief они разделены намеренно. canonical-write-record означает, что есть владелец исходной заметки. recorded-publication-intent требует отдельный след намерения. independent-public-read запрещает назвать read model просто ещё одним полем write path. А distributed-transaction лежит в non-goals, чтобы никто не получил его по умолчанию из слова «outbox». '), dataTable( 'Минимальный 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 выполнится без проверки окружения'], ], ), heading('Requirements проверяют вариант, а не подгоняются под него'), paragraph('Три варианта в fixture специально выглядят правдоподобно. Синхронная запись и projection может дать быструю картину, но для этого brief не фиксирует отдельный persisted intent и не объявляет задержку чтения. Synchronous query бережёт состояния, но не создаёт независимый read model. Transactional outbox закрывает все пять требований, потому что note и intent разделены, relay имеет ключ, а projection разрешено обновить позже. Это не соревнование паттернов. Это проверка соответствия выбранному набору условий.'), codeBlock(comparisonCode), paragraph('Функция возвращает gaps именно вместо score. Score создаёт ложную точность: разница между 7 и 8 не объясняет, какое условие потеряно. Gap даёт предметный вопрос. У синхронной projection это recorded-publication-intent; у query model — independent-public-read. Разговор можно продолжить двумя способами: изменить brief, если требование оказалось лишним, или изменить вариант, если требование настоящее. Нельзя честно закрыть gap переименованием схемы.'), figure( '/assets/editorial/2021/architecture-review-decision-2021.svg', 'Схема проверки решения: brief содержит пять requirements и non-goals; три варианта проходят через сравнение gaps; fixture evidence подтверждает форму учебного decision, assumption остаётся отдельной веткой; выбранный transactional outbox ведёт к локальной паре note плюс intent и затем к relay', 'Механика решения: evidence подтверждает только заявленный claim, а assumption сохраняет условие пересмотра.', ), heading('Evidence не усиливает assumption задним числом'), paragraph('Самая опасная строка в ADR часто выглядит убедительно: «хранилище атомарно запишет заметку и событие». До проверки это assumption. В fixture она названа прямо: локальная функция способна подготовить две копии Map и применить их вместе. Такое свойство существует только внутри процесса и лишь для этой операции. Evidence модели доказывает другое: fixture-evidence имеет правильный subject, claim и observedIn; assumption с другим kind не может принять decision. Это не педантизм типов, а защита от перехода «мы надеемся» → «мы гарантируем» без нового наблюдения.'), codeBlock(decisionCode), heading('Локальная пара note + intent моделирует policy, а не транспорт'), paragraph('После accepted decision fixture разрешает commitTrainingPublication. Она проверяет decision id, форму note и отсутствие предыдущего note или intent с тем же ключом. Затем создаёт canonicalNote и intent, копирует обе Map и устанавливает новые значения. Результат local-pair-recorded означает только одно: policy модели не оставила наполовину созданную учебную пару. В коде прямо написано, что это не atomicity между БД, broker, HTTP, процессами или машинами.'), codeBlock(commitCode), paragraph('Зачем тогда такая модель? Потому что она не даёт тексту скрыть порядок. Public projection не создаётся внутри commit. До relay диагноз обязан вернуть outbox-pending. Это отделяет «намерение записано» от «читатель уже видит». Когда реальная система будет выбрана, те же состояния можно сопоставить с её таблицей, журналом, CDC или worker. Если сопоставления нет, решение не следует переносить: новая технология не обязана иметь те же failure modes.'), heading('Ключ replay — часть механизма, а не подпись на схеме'), paragraph('Intent key в учебном кейсе строится как noteId:revision. Его назначение ограничено: отличить один intent публикации версии заметки от его повторной доставки. Relay добавляет этот ключ в acceptedIntentKeys; повтор возвращает duplicate-relay-suppressed и не создаёт вторую projection. Это не доказательство exactly-once. Никакая Map не моделирует падение между внешним effect и записью receipt, потерю сети, TTL, split brain или повтор от внешнего consumer.'), heading('Диагноз опирается на state, а не на догадку'), paragraph('Когда публичная заметка не видна, полезно не перезапускать relay первым действием. diagnoseTrainingPublication различает отсутствие decision, отсутствие canonical note, отсутствие intent, pending intent, stale projection и ready read. Для каждой ветки задано действие без удаления source. Это не runbook production; это минимальный contract, который не позволяет притянуть одну реакцию к разным причинам.'), codeBlock(diagnosisCode), paragraph('Внутри учебной модели pending означает только состояние Map. В живой среде аналог может быть очередью, таблицей, change stream или ошибкой permissions. Поэтому правильный перенос — не копировать строку outbox-pending, а выбрать наблюдение, которое подтверждает эту же границу: key, версия, время постановки, состояние relay и владелец следующего шага. Если наблюдения нет, сначала добавить его в design. Править source до этого рискованно: можно создать вторую версию, а исходное evidence потерять.'), heading('Маршрут механического review до реализации'), paragraph('Этот порядок позволяет провести разбор без фиктивных цифр и без выбора cloud provider. На каждом шаге появляется небольшой проверяемый артефакт: brief, matrix, evidence, assumption, code-level policy или diagnostic state.'), orderedList([ 'Выбрать один 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, проверке границы и обратимому действию.', ]), heading('Fixture даёт отрицательные проверки, а не только happy path'), paragraph('В отчёте fixture есть семнадцать assertions. Самые важные — отрицательные: synchronous write и synchronous query не проходят текущий brief; assumption не может стать evidence; неизвестный вариант не получает accepted status; pending intent не разрешает destructive action; duplicate relay не создаёт второй effect. Такие проверки важнее успешного projection-recorded, потому что именно на границе «не делай этого» обычно появляется скрытый долг.'), codeBlock(fixtureCode), heading('Историческая рамка и последствия выбора'), paragraph('Историческая рамка проверена: C4 snapshot — 1 декабря 2021, AWS publication record и Builders’ Library article — 15 января 2021, авторский snapshot transactional outbox — 18 ноября 2021, RFC 2119 — март 1997. Это не ссылки из будущего. Они задают язык границ, требований, retry и отдельного relay, но не назначают технологический стек учебному проекту.'), paragraph('Последствие выбранного outbox в этой модели — больше состояний: note, intent, relay result, projection и ключ replay. Это цена независимого read path и записанного intent. Если команда не готова владеть этими состояниями, честный результат review — не «внедрить половину outbox», а изменить brief и рассмотреть simpler option. Следующий шаг — проверить фактическую границу хранения на одном безопасном prototype, затем обновить evidence или пересмотреть decision. Именно эта возможность пересмотра делает архитектурное решение рабочим, а не окончательным лозунгом.'), ], [c4Model202112, rfc2119, awsIdempotency202101, awsIdempotencyRelease202101, transactionalOutbox202111], ); const fieldArticle = createRevision( { slug: 'editorial-2021-12-field-architecture-review', title: 'Разбор: как диагностировать архитектурное решение до опасного исправления', categories: ['Архитектура', 'Команда'], cover: '/assets/editorial/2021/architecture-review-diagnosis-2021.svg', excerpt: 'Полевой маршрут для случая, когда заметка сохранена, а public read не подтверждает публикацию: какие evidence собрать, как отличить missing decision от pending intent, когда replay допустим и почему не стоит пересоздавать source первым действием.', readingMinutes: 17, }, [ paragraph('Симптом в полевом разборе конкретен: рабочая заметка уже сохранена, но публичное чтение её не подтверждает. Дорогая реакция — удалить заметку, создать вторую или «на всякий случай» отправить ещё одно событие. После этого исчезает исходная версия, два intent становятся неотличимы, а исправление может создать ещё одну projection. Сначала нужен отчёт, который переживёт вмешательство: decision id, note id, revision, intent key, observed read result и граница, на которой сделано наблюдение. Цена ошибки — потерять исходную версию и усложнить повторную доставку.'), paragraph('Учебная fixture даёт такой маршрут без реального production. Она хранит decision, canonical note, outbox, projection и accepted intent keys в памяти. По одному ключу noteId:revision она различает pending relay и duplicate delivery. Она не знает БД, HTTP, broker, retry сети, внешнего consumer, инцидента, SLO или реальных прав. Поэтому результат «public-read-ready-in-training» не означает, что текст доступен пользователю; он означает только, что локальная модель дошла до своего объявленного состояния.'), heading('Собираем evidence раньше, чем меняем источник'), paragraph('Первый вопрос: «что уже доказано?». В карточке нужны immutable identifiers, а не пересказ симптома. Для кейса это decision id, note id, revision и intent key. Затем — фактическое read observation: какой путь чтения проверяли, какой результат получили, когда и в каком scope. В учебной модели нет времени и прав доступа, поэтому эти поля не выдуманы. Реальная система добавляет только разрешённые данные, которые различают ветки: storage commit, relay receipt, projection version, filter или permission check.'), dataTable( 'Диагностические состояния учебной публикации: наблюдение определяет следующий шаг', ['Наблюдение', 'Граница причины', 'Что проверить', 'Действие без потери evidence'], [ ['Нет decision id в state', 'Решение не зафиксировано', 'context, options, requirements, evidence и reversibility', 'остановить реализацию и записать decision до новой публикации'], ['Decision есть, note отсутствует', 'canonical write', 'id, revision, подтверждение write path', 'не создавать копию; сначала проверить исходную запись'], ['Note есть, intent отсутствует', 'write boundary', 'правило note + publication intent и фактическую границу хранения', 'не запускать relay; вернуть review к записи'], ['Intent pending', 'relay path', 'key, revision, owner следующего шага', 'сохранить intent и наблюдать обработку, не менять source'], ['Intent relayed, projection нет или stale', 'read projection', 'intent key и revision проекции', 'сравнить следы и controlled replay только текущего ключа'], ['Projection совпадает с note', 'read contract', 'scope, filter и ожидаемую форму публичного чтения', 'зафиксировать результат и проверить внешний query отдельно'], ], ), paragraph('Таблица не предсказывает причину по одной метрике. Она задаёт порядок, в котором доказательства становятся достаточными. Особенно важно не прыгать от «не видно в public read» к «outbox сломан». Публичный результат может отличаться из-за scope, фильтра, политики публикации или неверной версии. Пока не проверены note и intent, даже широкая повторная доставка ничего не доказывает. А после повторной доставки уже сложнее понять, какая именно операция была исходной.'), heading('Decision missing — не operational incident, а стоп-сигнал'), paragraph('Если state не содержит decision, диагностика возвращает decision-missing. Это не значит, что рабочая заметка обязательно потеряна. Это значит, что нельзя корректно решить, нужна ли independent projection, допустима ли задержка и как распознать replay. В такой ветке безопасное действие — зафиксировать context, варианты, evidence и reversibility до новой попытки write. Иначе команда может починить видимость одним способом, а затем обнаружить, что выбранный путь нарушил требование, которое просто не было записано.'), codeBlock(decisionCode), paragraph('В fixture decision можно получить только с корректным evidence и отдельным assumption. Попытка передать assumption вместо evidence возвращает review-needed. Это полезная отрицательная ветка: не подтверждённое свойство local commit boundary нельзя использовать для принятия outbox. Когда реальный storage проверен, вместо скрытого изменения строки нужно приложить новое evidence к следующей ревизии decision. Тогда видно, какая часть дизайна была условной и что именно изменилось.'), heading('Проверяем канонический write и publication intent раздельно'), paragraph('После recorded decision fixture позволяет одну операцию commitTrainingPublication. В ней note и intent появляются в новых копиях Map как local-pair-recorded. Это модель требования: canonical note и intent не должны расходиться внутри обучающей функции. Она не даёт гарантию между отдельными машинами, таблицами или процессами. Поэтому нельзя из наличия двух записей делать вывод, что delivery гарантирована. Relay ещё не запускался, а public projection ещё не существует.'), codeBlock(commitCode), heading('Pending intent не равен потерянной публикации'), codeBlock(diagnosisCode), paragraph('В живой системе аналог pending должен иметь собственное evidence: строка outbox, offset, receipt, job status или иной разрешённый след. Название может быть другим. Важно, что он отвечает на тот же вопрос: «есть ли подтверждённое намерение, которое ещё не дало read effect?» Если следа нет, не надо называть состояние pending по интуиции. Вернуться к write boundary честнее, чем достроить диагностику на ощущении, что «очередь обычно работает». '), figure( '/assets/editorial/2021/architecture-review-diagnosis-2021.svg', 'Дерево безопасной диагностики: сначала decision, затем canonical note, publication intent и его состояние; pending ведёт к наблюдению relay, relayed без текущей projection — к сравнению ключа и revision, совпадающая projection — к проверке read contract; на всех ветках запрещено удалять source без доказанной причины', 'Диаграмма сохраняет различие между отсутствием решения, записью, intent, relay и публичным чтением.', ), heading('Controlled replay требует текущий ключ и известный owner'), paragraph('Когда intent уже relayed, но projection не совпадает с note, нельзя говорить «переиграем публикацию» без уточнения. В модели relay проверяет intent key и revision canonical note, затем создаёт projection. Повтор ключа после успешного effect подавляется через acceptedIntentKeys. Это не полная стратегия recovery; это минимальная защита от невидимого двойного effect внутри Map. Она показывает, какие данные должны быть доступны перед replay: ключ, текущая revision, результат первого применения и владелец projection.'), heading('Когда projection есть, расследование меняет объект'), paragraph('Если projection с той же revision существует, fixture возвращает public-read-ready-in-training. Это не означает успех в браузере и не даёт права закрыть пользовательскую проблему. Теперь технический объект расследования меняется: не note и не relay, а read contract. Нужно проверить scope, фильтр, формат, права, кеш или конкретный query, которым пользуется внешний читатель. Попытка ещё раз обработать outbox в этой ветке не добавляет доказательства, потому что оно уже существует для projection state.'), paragraph('Такое переключение особенно полезно для сложных систем. Иногда прямое чтение по id подтверждает source, а поиск или список скрывает элемент из-за фильтра. Иногда read model корректна, но identity для URL не совпадает с identity source. Иногда пользователь видит устаревший cache. Все эти случаи требуют own evidence. Архитектурный review не должен заставлять outbox отвечать за них. Его задача — закончить свою причинную цепочку и передать расследование следующему owner без разрушения исходного состояния.'), heading('Маршрут безопасного разбора'), orderedList([ 'Зафиксировать decision id, note id, revision, intent key и точный public read, который не совпал с ожиданием. Не исправлять запись до фиксации этой карточки.', 'Проверить, существует ли decision и закрывает ли он актуальный brief. При missing decision остановиться и оформить требования, варианты, evidence, assumption и reversibility.', 'Проверить canonical note. Если его нет, исследовать write path; не создавать замену с новым id и не запускать relay без source.', 'Проверить publication intent для точной пары noteId:revision. Если его нет, вернуться к storage boundary, а не к очереди или public UI.', 'Если intent pending, сохранить ключ и собрать evidence relay path. Не менять source, пока не известно, был ли effect вообще разрешён и кому он принадлежит.', 'Если intent relayed, сравнить intent key и revision projection. Controlled replay допустим только для явно выбранного current key и отдельного contract consumer.', 'Если projection совпадает, завершить эту ветку и проверить read contract: scope, filter, rights, cache и формат. Затем записать новый evidence вместо повторной обработки старой причины.', ]), heading('Fixture проверяет также ветки, которых не хочется видеть'), paragraph('Семнадцать assertions фиксируют не только успешный relay. Они доказывают, что unknown architecture option не становится accepted, assumption не принимается за evidence, synchronous alternatives имеют declared gaps, commit отвергается без decision, pending diagnosis безопасен, duplicate relay не записывает вторую projection, а ready diagnosis остаётся недеструктивным. Утверждение «недеструктивный» здесь очень конкретно: объект результата содержит destructiveAction: false; он не удаляет Map и не создаёт новую note.'), codeBlock(fixtureCode), heading('Исторические источники, границы и следующий шаг'), paragraph('C4 snapshot от 1 декабря 2021 полезен как напоминание не смешивать context и code: диагноз начинается с владельцев и границ, затем доходит до конкретного ключа. RFC 2119 помогает различить обязательное требование от пожелания. AWS Builders’ Library от января 2021 объясняет осторожность с повторным effect. Заархивированный snapshot transactional outbox от 18 ноября 2021 называет отдельный relay после записи intent. Это документы, существовавшие к исторической дате статьи. Они не содержат нашу fixture и не подтверждают ни одну production-метрику.'), paragraph('Следующий практический шаг — не развернуть универсальный recovery worker. Возьмите один обезличенный flow, составьте evidence card и проверьте, на какой ветке он останавливается: decision, write, intent, relay, projection или read contract. Затем сделайте самое маленькое обратимое действие только на найденной границе. Если для этого действия нельзя назвать ключ, owner и evidence результата, остановитесь и дополните decision. Такой подход оставляет после разбора не только исправленный экран, но и понятную техническую причину, которую можно проверить при следующем изменении.'), ], [c4Model202112, rfc2119, awsIdempotency202101, awsIdempotencyRelease202101, transactionalOutbox202111], ); export const revisions = [practiceArticle, mechanismArticle, fieldArticle] .map(({ proseLength, ...revision }) => revision); if (process.argv.includes('--print-revisions')) { process.stdout.write(JSON.stringify(revisions)); } else if (process.argv.includes('--verify-fixture')) { process.stdout.write(JSON.stringify(runArchitectureReviewFixture(), null, 2) + '\n'); }