revise February 2024 modular monolith articles
Build and deploy / deploy (push) Successful in 15s

This commit is contained in:
2026-07-31 15:37:40 +03:00
parent e74907ff32
commit ecb66c82b3
7 changed files with 803 additions and 1 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# Производство редакционных партий # Производство редакционных партий
На 31 июля 2026 года строгий аудит проходит 217 из 358 созданных материалов. Остальные 141 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить. На 31 июля 2026 года строгий аудит проходит 220 из 358 созданных материалов. Остальные 138 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить.
## Одна партия ## Одна партия
+40
View File
@@ -0,0 +1,40 @@
# P72 · 2024-02 · Модульный монолит — три прохода саморевью
## Рамка sidecar-партии
- Slug: `editorial-2024-02-practice-modular-monolith`, `editorial-2024-02-mechanism-modular-monolith`, `editorial-2024-02-field-modular-monolith`.
- Голос: M7, февраль 2024. Автор начинает с цены неявной межмодульной связи, затем ведёт короткую техническую цепочку «симптом → причина → проверка → действие». Тон системный и прагматичный: он называет owner, surface, разрешённое направление и границу доказательства, но не имитирует production-опыт.
- Созданы ровно пять sidecar-файлов: этот review, один import-safe script и три локальные SVG. Overlay, README, `articles.json`, очередь, Git и файлы других агентов не менялись; пакет не интегрирован.
- Fixture создаёт и проверяет только fixed synthetic JS-объекты в памяти. Он не читает проект, файлы, package graph, environment, CI, сеть, HTTP, trace, часы или production-конфигурацию. Его PASS не заявляет реальный import, границу репозитория, CI result, trace, release decision, надёжность или effect в production.
## Проход 1 — факты, источники и модель
- Источники проверены 31.07.2026 и исторически доступны к февралю 2024: [Java Language Specification, Java SE 17, chapter 7](https://docs.oracle.com/javase/specs/jls/se17/html/jls-7.html) различает иерархию package names и доступ, а для named modules фиксирует exported packages и explicit dependencies; [Spring Modulith 1.1 Fundamentals](https://docs.spring.io/spring-modulith/reference/1.1/fundamentals.html) описывает public API, internal packages и allowed dependencies для Spring Boot; [release Spring Modulith 1.1.0](https://github.com/spring-projects/spring-modulith/releases/tag/1.1.0) опубликован 24.11.2023; [release ArchUnit 1.1.0](https://github.com/TNG/ArchUnit/releases/tag/v1.1.0) — 09.08.2023.
- Ограничения источников названы в каждой статье. JLS не является моделью доменных модулей для любого языка; Spring Modulith — framework-specific пример, а не требование использовать Spring; ArchUnit release подтверждает доступность инструмента, но не доказывает запуск CI и не выбирает границы приложения.
- `inspectSyntheticModuleBoundaries()` принимает только `synthetic-module-boundary-input-v1` с fixed картой четырёх условных модулей: `catalog`, `checkout`, `payments`, `notifications`. Карта не описывает реальный проект. Разрешены только `checkout → catalog.api`, `checkout → payments.api`, `payments → notifications.api`.
- Модель проверяет тройку `from → to.surface`, а не имя папки: surface обязан совпасть с опубликованным `*.api`, direction — с fixed allow-list. Она отдельно классифицирует unknown module, non-public surface, forbidden direction, duplicate reference, malformed record, self-dependency и directed cycle.
- На самостоятельном модельном проходе проверены отрицательные ветки. Лишний `repositoryUrl` отвергается до какой-либо работы с ним; поле `path` также отвергается, чтобы fixture не создавал впечатления source scan. Проверка цикла идёт только по уже переданным memory references. Функция возвращает `projectScan=not-performed`, `filesystem=not-read`, `ci=not-touched`, `network=not-used`, `releaseAuthority=not-granted`.
- `restoreSyntheticBoundaryDraft()` принимает только report с точным обязательным набором полей и валидным versioned snapshot; он отвергает разрежённый список ссылок и внешний `accepted=true` report с неполным snapshot. Возвращается нормализованная копия synthetic договора, а не откат файлов, запуск CI или управление release.
- `node --check` — PASS; `node web/scripts/upgrade-2024-02.mjs --verify-fixture` — PASS, 22/22 assertions. Это доказывает согласованность учебной модели и её ограничений, не состояние каких-либо внешних систем.
## Проход 2 — редактура, объём и голос
- Practice отвечает на вопрос «как превратить папки в договорённость»: карта, owner, public surface, направление и первый обратимый перенос. Mechanism объясняет, почему зависимость — тройка `consumer → owner.surface`, как отличить technical public от архитектурного API и зачем проверять граф на цикл. Field разбирает три fixed synthetic imports и отделяет evidence реального review от результата fixture. Тексты не пересказывают один материал под разными заголовками.
- В первых двух абзацах каждой статьи названы ситуация и цена: скрытые потребители внутренней детали, недоопределённое разрешение на import и накопление обратных ссылок. Затем каждый случай разбирается в порядке «симптом → причина → проверка → действие» без общих деклараций о важности архитектуры.
- В каждой ревизии есть рабочая таблица, figure с содержательным `alt` и подписью, исполнимый fixed synthetic пример, упорядоченный маршрут, ограничения, источники и следующий проверяемый шаг. Код не утверждает, что прочёл source files или выполнил реальную архитектурную проверку.
- `npm run audit:draft -- scripts/upgrade-2024-02.mjs` зафиксировал объём основного текста без источников: practice — **9 432**, mechanism — **9 709**, field — **9 743** знака. Это целевой коридор 8–11 тыс. и обязательный диапазон 5–15 тыс. знаков.
- Убраны ложные operational утверждения: synthetic case не назван production-кейсом, fixture не назван CI, а зелёная связь не приравнена к корректности данных, latency, безопасности или продуктовой ценности. Голос M7 проявляется в границах ответственности и стоимости следующего изменения, а не в лозунге о «микросервисах».
## Проход 3 — визуал, безопасность и выпуск
- Три SVG разделяют задачи: матрица показывает allowed cells, схема направлений — public API против internal, петля — границу fixed synthetic проверки и ручного review. В каждом изображении есть понятный заголовок и описательный `desc`.
- После первого Sharp-render на ширине 375 px у подписи `checkout → payments.api` обнаружилось обрезание справа. Подпись разбита на две короткие строки слева от вертикальной стрелки; повторный render показывает полную читаемую надпись. В финальном ручном проходе нижняя смешанная подпись заменена на короткое русское правило: «не весь модуль, а его public API по правилу». Остальные карточки, стрелки, legend и ограничение модели не перекрываются.
- `xmllint --noout` для трёх SVG — PASS. SVG safety scan не нашёл `script`, `foreignObject`, `javascript:`, `data:image` или event-handler attributes. В SVG нет внешних URL и пользовательского ввода.
- Sharp-render всех трёх визуалов на 375 px открыт вручную: `dependency-matrix`, `allowed-directions`, `boundary-test-loop` — PASS. Матрица читается как таблица, направления оканчиваются на API, а петля отдельно сообщает, что filesystem, CI, сеть, trace и production не используются.
- Выполнены проверки: `node --check web/scripts/upgrade-2024-02.mjs` — PASS; fixture — 22/22; `audit:draft` — PASS для трёх slug; XML — PASS; SVG safety — clean; Sharp 375 px — PASS. До интеграции sidecar не менял overlay, README, `articles.json`, очередь, Git или файлы других агентов; production build выполняется главным редактором после подключения ревизий.
## Выпуск после трёх проходов
- Главный редактор подключил ровно три февральские ревизии в `web/data/editorial-revisions.mjs`, не меняя архивный `articles.json`, и обновил счётчик производства до 220 из 358 материалов.
- После подключения `npm run audit:articles -- <три slug>` подтвердил для каждой статьи объём, одну figure, одну таблицу и один пример; registry содержит 211 уникальных ревизий без повторов slug.
- `npm run build` завершился успешно: Next.js сгенерировал 374 статические страницы. Изменение подготовлено отдельным пакетом; файлы незавершённых апрельской и майской партий и пользовательские правки не входят в выпуск.
+2
View File
@@ -68,6 +68,7 @@ import { revisions as october2023Revisions } from '../scripts/upgrade-2023-10.mj
import { revisions as november2023Revisions } from '../scripts/upgrade-2023-11.mjs'; import { revisions as november2023Revisions } from '../scripts/upgrade-2023-11.mjs';
import { revisions as december2023Revisions } from '../scripts/upgrade-2023-12.mjs'; import { revisions as december2023Revisions } from '../scripts/upgrade-2023-12.mjs';
import { revisions as january2024Revisions } from '../scripts/upgrade-2024-01.mjs'; import { revisions as january2024Revisions } from '../scripts/upgrade-2024-01.mjs';
import { revisions as february2024Revisions } from '../scripts/upgrade-2024-02.mjs';
// This layer replaces archived source entries without losing their stable slug and date. // This layer replaces archived source entries without losing their stable slug and date.
export const editorialRevisions = [ export const editorialRevisions = [
@@ -141,4 +142,5 @@ export const editorialRevisions = [
...november2023Revisions, ...november2023Revisions,
...december2023Revisions, ...december2023Revisions,
...january2024Revisions, ...january2024Revisions,
...february2024Revisions,
]; ];
@@ -0,0 +1,56 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 820 1040" role="img" aria-labelledby="title desc">
<title id="title">Разрешённые направления и публичные поверхности учебных модулей</title>
<desc id="desc">Модули catalog, checkout, payments и notifications разделены на API и internal. Стрелки разрешают checkout обращаться к API catalog и payments, а payments к API notifications. Пунктирная красная стрелка к catalog internal запрещена.</desc>
<defs>
<marker id="arrow-green" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto"><path d="M0,0 L10,5 L0,10 Z" fill="#39D49A"/></marker>
<marker id="arrow-red" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto"><path d="M0,0 L10,5 L0,10 Z" fill="#FB7185"/></marker>
</defs>
<rect width="820" height="1040" rx="28" fill="#0B1220"/>
<rect x="24" y="24" width="772" height="992" rx="20" fill="#111C30" stroke="#263653" stroke-width="2"/>
<text x="54" y="78" fill="#F8FAFC" font-family="Arial, sans-serif" font-size="31" font-weight="700">Разрешённые направления</text>
<text x="54" y="112" fill="#B9C8DE" font-family="Arial, sans-serif" font-size="19">Каждая стрелка заканчивается на public API, не на весь модуль.</text>
<g font-family="Arial, sans-serif">
<rect x="58" y="184" width="276" height="176" rx="18" fill="#172740" stroke="#5D79A5" stroke-width="2"/>
<text x="82" y="222" fill="#F8FAFC" font-size="26" font-weight="700">catalog</text>
<rect x="80" y="242" width="232" height="43" rx="9" fill="#1E5A4E"/>
<text x="96" y="270" fill="#C9F7E1" font-size="19" font-weight="700">public: catalog.api</text>
<rect x="80" y="298" width="232" height="40" rx="9" fill="#263247"/>
<text x="96" y="324" fill="#B5C4D8" font-size="18">internal: catalog.internal</text>
<rect x="474" y="144" width="276" height="176" rx="18" fill="#172740" stroke="#5D79A5" stroke-width="2"/>
<text x="498" y="182" fill="#F8FAFC" font-size="26" font-weight="700">checkout</text>
<rect x="496" y="202" width="232" height="43" rx="9" fill="#1E5A4E"/>
<text x="512" y="230" fill="#C9F7E1" font-size="19" font-weight="700">public: checkout.api</text>
<rect x="496" y="258" width="232" height="40" rx="9" fill="#263247"/>
<text x="512" y="284" fill="#B5C4D8" font-size="18">internal: checkout.internal</text>
<rect x="474" y="498" width="276" height="176" rx="18" fill="#172740" stroke="#5D79A5" stroke-width="2"/>
<text x="498" y="536" fill="#F8FAFC" font-size="26" font-weight="700">payments</text>
<rect x="496" y="556" width="232" height="43" rx="9" fill="#1E5A4E"/>
<text x="512" y="584" fill="#C9F7E1" font-size="19" font-weight="700">public: payments.api</text>
<rect x="496" y="612" width="232" height="40" rx="9" fill="#263247"/>
<text x="512" y="638" fill="#B5C4D8" font-size="18">internal: payments.internal</text>
<rect x="58" y="748" width="276" height="176" rx="18" fill="#172740" stroke="#5D79A5" stroke-width="2"/>
<text x="82" y="786" fill="#F8FAFC" font-size="26" font-weight="700">notifications</text>
<rect x="80" y="806" width="232" height="43" rx="9" fill="#1E5A4E"/>
<text x="96" y="834" fill="#C9F7E1" font-size="19" font-weight="700">public: notifications.api</text>
<rect x="80" y="862" width="232" height="40" rx="9" fill="#263247"/>
<text x="96" y="888" fill="#B5C4D8" font-size="18">internal: notifications.internal</text>
</g>
<path d="M474 230 C420 230 390 252 334 260" fill="none" stroke="#39D49A" stroke-width="5" marker-end="url(#arrow-green)"/>
<text x="354" y="223" fill="#B6F4D8" font-family="Arial, sans-serif" font-size="17" font-weight="700">checkout → catalog.api</text>
<path d="M612 320 L612 498" fill="none" stroke="#39D49A" stroke-width="5" marker-end="url(#arrow-green)"/>
<text x="486" y="394" fill="#B6F4D8" font-family="Arial, sans-serif" font-size="17" font-weight="700">checkout →</text>
<text x="486" y="418" fill="#B6F4D8" font-family="Arial, sans-serif" font-size="17" font-weight="700">payments.api</text>
<path d="M474 606 C400 635 370 748 334 826" fill="none" stroke="#39D49A" stroke-width="5" marker-end="url(#arrow-green)"/>
<text x="230" y="604" fill="#B6F4D8" font-family="Arial, sans-serif" font-size="17" font-weight="700">payments → notifications.api</text>
<path d="M474 276 C412 316 378 328 312 316" fill="none" stroke="#FB7185" stroke-width="4" stroke-dasharray="11 9" marker-end="url(#arrow-red)"/>
<text x="350" y="351" fill="#FDA4AF" font-family="Arial, sans-serif" font-size="17" font-weight="700">запрещено: checkout → catalog.internal</text>
<rect x="358" y="920" width="392" height="68" rx="12" fill="#0E1727" stroke="#2B3A54" stroke-width="2"/>
<text x="378" y="950" fill="#B9C8DE" font-family="Arial, sans-serif" font-size="17">Не «весь модуль», а</text>
<text x="378" y="974" fill="#B9C8DE" font-family="Arial, sans-serif" font-size="17">«его public API по правилу».</text>
</svg>

After

Width:  |  Height:  |  Size: 5.1 KiB

@@ -0,0 +1,56 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 820 1000" role="img" aria-labelledby="title desc">
<title id="title">Петля проверки границ в fixed synthetic модели</title>
<desc id="desc">Шесть шагов: fixed input from-to-surface, проверка формы, сверка public API, сверка разрешённого направления и цикла, отчёт accepted или violations, ручной review карты. Боковая панель говорит, что файл, CI, сеть, trace и production не используются.</desc>
<defs>
<marker id="arrow" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto"><path d="M0,0 L10,5 L0,10 Z" fill="#7DD3FC"/></marker>
<marker id="arrow-small" markerWidth="9" markerHeight="9" refX="7" refY="4.5" orient="auto"><path d="M0,0 L9,4.5 L0,9 Z" fill="#39D49A"/></marker>
</defs>
<rect width="820" height="1000" rx="28" fill="#0B1220"/>
<rect x="24" y="24" width="772" height="952" rx="20" fill="#111C30" stroke="#263653" stroke-width="2"/>
<text x="54" y="78" fill="#F8FAFC" font-family="Arial, sans-serif" font-size="31" font-weight="700">Петля проверки boundary policy</text>
<text x="54" y="112" fill="#B9C8DE" font-family="Arial, sans-serif" font-size="19">PASS подтверждает карту в памяти, затем человек решает, менять ли policy.</text>
<g font-family="Arial, sans-serif">
<rect x="58" y="162" width="322" height="106" rx="16" fill="#193252" stroke="#4D7DB8" stroke-width="2"/>
<text x="82" y="199" fill="#E5F0FF" font-size="23" font-weight="700">1. fixed synthetic input</text>
<text x="82" y="231" fill="#B8D4F5" font-size="18">from · to · surface · map version</text>
<rect x="58" y="320" width="322" height="106" rx="16" fill="#172740" stroke="#526A91" stroke-width="2"/>
<text x="82" y="357" fill="#E5F0FF" font-size="23" font-weight="700">2. форма и fixed map</text>
<text x="82" y="389" fill="#B8C7DE" font-size="18">лишнее поле → reject</text>
<rect x="58" y="478" width="322" height="106" rx="16" fill="#172740" stroke="#526A91" stroke-width="2"/>
<text x="82" y="515" fill="#E5F0FF" font-size="23" font-weight="700">3. public surface</text>
<text x="82" y="547" fill="#B8C7DE" font-size="18">*.internal → violation</text>
<rect x="58" y="636" width="322" height="106" rx="16" fill="#172740" stroke="#526A91" stroke-width="2"/>
<text x="82" y="673" fill="#E5F0FF" font-size="23" font-weight="700">4. direction и DAG</text>
<text x="82" y="705" fill="#B8C7DE" font-size="18">forbidden link или cycle → violation</text>
<rect x="440" y="404" width="322" height="132" rx="16" fill="#164237" stroke="#39D49A" stroke-width="2"/>
<text x="464" y="443" fill="#D7FCE7" font-size="23" font-weight="700">5. synthetic report</text>
<text x="464" y="475" fill="#B6F4D8" font-size="18">accepted | violations | not-granted</text>
<text x="464" y="504" fill="#B6F4D8" font-size="16">не CI result и не repository scan</text>
<rect x="440" y="628" width="322" height="132" rx="16" fill="#2C254D" stroke="#9B8AF7" stroke-width="2"/>
<text x="464" y="667" fill="#EEEAFE" font-size="23" font-weight="700">6. ручной review карты</text>
<text x="464" y="699" fill="#D3CCFF" font-size="18">scenario · owner · API · removal</text>
<text x="464" y="728" fill="#D3CCFF" font-size="16">может изменить policy, не fixture</text>
</g>
<path d="M219 268 L219 320" fill="none" stroke="#7DD3FC" stroke-width="4" marker-end="url(#arrow)"/>
<path d="M219 426 L219 478" fill="none" stroke="#7DD3FC" stroke-width="4" marker-end="url(#arrow)"/>
<path d="M219 584 L219 636" fill="none" stroke="#7DD3FC" stroke-width="4" marker-end="url(#arrow)"/>
<path d="M380 689 C422 689 406 498 440 470" fill="none" stroke="#7DD3FC" stroke-width="4" marker-end="url(#arrow)"/>
<path d="M601 536 L601 628" fill="none" stroke="#39D49A" stroke-width="4" marker-end="url(#arrow-small)"/>
<path d="M440 720 C402 870 178 880 170 742" fill="none" stroke="#9B8AF7" stroke-width="3" stroke-dasharray="10 8" marker-end="url(#arrow)"/>
<text x="75" y="895" fill="#C6BAFF" font-family="Arial, sans-serif" font-size="16">policy меняется после review; fixture остаётся fixed</text>
<rect x="440" y="162" width="322" height="172" rx="16" fill="#0E1727" stroke="#2B3A54" stroke-width="2"/>
<text x="464" y="202" fill="#F8FAFC" font-family="Arial, sans-serif" font-size="22" font-weight="700">Вне границы модели</text>
<g fill="#B9C8DE" font-family="Arial, sans-serif" font-size="18">
<text x="464" y="238">нет чтения filesystem или project</text>
<text x="464" y="266">нет CI, network, HTTP или trace</text>
<text x="464" y="294">нет production effect или release</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

@@ -0,0 +1,65 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 820 920" role="img" aria-labelledby="title desc">
<title id="title">Матрица разрешённых зависимостей учебного модульного монолита</title>
<desc id="desc">Четыре модуля. Разрешены checkout к catalog API, checkout к payments API и payments к notifications API. Остальные направления заблокированы; internal поверхности не входят в матрицу разрешений.</desc>
<rect width="820" height="920" rx="28" fill="#0B1220"/>
<rect x="24" y="24" width="772" height="872" rx="20" fill="#111C30" stroke="#263653" stroke-width="2"/>
<text x="56" y="78" fill="#F8FAFC" font-family="Arial, sans-serif" font-size="31" font-weight="700">Карта зависимостей: не папки, а правила</text>
<text x="56" y="112" fill="#B9C8DE" font-family="Arial, sans-serif" font-size="19">Строка — consumer, столбец — owner. Зелёная клетка допускает только public API.</text>
<text x="56" y="182" fill="#8FA4C3" font-family="Arial, sans-serif" font-size="19" font-weight="700">from \ to</text>
<g font-family="Arial, sans-serif" font-size="18" font-weight="700" text-anchor="middle">
<text x="298" y="172" fill="#D7E3F5">catalog</text>
<text x="440" y="172" fill="#D7E3F5">checkout</text>
<text x="582" y="172" fill="#D7E3F5">payments</text>
<text x="724" y="172" fill="#D7E3F5">notifications</text>
</g>
<g fill="#18263E" stroke="#30425F" stroke-width="1.5">
<rect x="190" y="198" width="130" height="108" rx="12"/>
<rect x="332" y="198" width="130" height="108" rx="12"/>
<rect x="474" y="198" width="130" height="108" rx="12"/>
<rect x="616" y="198" width="130" height="108" rx="12"/>
<rect x="190" y="320" width="130" height="108" rx="12"/>
<rect x="332" y="320" width="130" height="108" rx="12"/>
<rect x="474" y="320" width="130" height="108" rx="12"/>
<rect x="616" y="320" width="130" height="108" rx="12"/>
<rect x="190" y="442" width="130" height="108" rx="12"/>
<rect x="332" y="442" width="130" height="108" rx="12"/>
<rect x="474" y="442" width="130" height="108" rx="12"/>
<rect x="616" y="442" width="130" height="108" rx="12"/>
<rect x="190" y="564" width="130" height="108" rx="12"/>
<rect x="332" y="564" width="130" height="108" rx="12"/>
<rect x="474" y="564" width="130" height="108" rx="12"/>
<rect x="616" y="564" width="130" height="108" rx="12"/>
</g>
<g font-family="Arial, sans-serif" font-size="20" font-weight="700" fill="#D7E3F5">
<text x="56" y="260">catalog</text>
<text x="56" y="382">checkout</text>
<text x="56" y="504">payments</text>
<text x="56" y="626">notifications</text>
</g>
<g font-family="Arial, sans-serif" font-size="20" text-anchor="middle">
<g fill="#6F849F"><text x="255" y="260">—</text><text x="397" y="260">—</text><text x="539" y="260">—</text><text x="681" y="260">—</text></g>
<g fill="#6F849F"><text x="397" y="382">—</text><text x="681" y="382">—</text></g>
<g fill="#6F849F"><text x="255" y="504">—</text><text x="397" y="504">—</text><text x="539" y="504">—</text></g>
<g fill="#6F849F"><text x="255" y="626">—</text><text x="397" y="626">—</text><text x="539" y="626">—</text><text x="681" y="626">—</text></g>
</g>
<g fill="#163D38" stroke="#39D49A" stroke-width="2">
<rect x="198" y="328" width="114" height="92" rx="10"/>
<rect x="482" y="328" width="114" height="92" rx="10"/>
<rect x="624" y="450" width="114" height="92" rx="10"/>
</g>
<g fill="#B6F4D8" font-family="Arial, sans-serif" font-size="18" font-weight="700" text-anchor="middle">
<text x="255" y="367">✓ API</text><text x="255" y="394">catalog</text>
<text x="539" y="367">✓ API</text><text x="539" y="394">payments</text>
<text x="681" y="489">✓ API</text><text x="681" y="516">notifications</text>
</g>
<rect x="56" y="718" width="708" height="130" rx="16" fill="#0E1727" stroke="#2B3A54" stroke-width="2"/>
<circle cx="88" cy="759" r="9" fill="#39D49A"/>
<text x="110" y="766" fill="#D7E3F5" font-family="Arial, sans-serif" font-size="19">разрешено: consumer обращается к названному owner.api</text>
<circle cx="88" cy="801" r="9" fill="#7486A0"/>
<text x="110" y="808" fill="#D7E3F5" font-family="Arial, sans-serif" font-size="19">— : нет declared direction; не добавлять без сценария и review</text>
<text x="56" y="879" fill="#8FA4C3" font-family="Arial, sans-serif" font-size="17">internal поверхности не становятся допустимыми от того, что лежат в соседней папке.</text>
</svg>

After

Width:  |  Height:  |  Size: 4.8 KiB

+583
View File
@@ -0,0 +1,583 @@
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
const p = (text) => '<p>' + text + '</p>';
const h2 = (text) => '<h2>' + text + '</h2>';
const code = (text) => '<pre><code>' + escapeHtml(text) + '</code></pre>';
const ol = (items) => '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
const figure = (src, alt, caption) => '<figure><img src="' + src + '" alt="' + alt + '" loading="lazy" /><figcaption>' + caption + '</figcaption></figure>';
const table = (caption, headers, rows) => '<div class="table-scroll"><table><caption>' + caption + '</caption><thead><tr>' + headers.map((item) => '<th scope="col">' + item + '</th>').join('') + '</tr></thead><tbody>' + rows.map((row) => '<tr>' + row.map((item) => '<td>' + item + '</td>').join('') + '</tr>').join('') + '</tbody></table></div>';
function plainText(content) {
return content
.replace(/<[^>]+>/g, ' ')
.replaceAll('&nbsp;', ' ')
.replaceAll('&quot;', '"')
.replaceAll('&#039;', "'")
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&amp;', '&')
.replace(/\s+/g, ' ')
.trim();
}
function bodyText(content) {
return plainText(content.replace(/<h2>Проверяемые источники<\/h2>[\s\S]*?(?=<h2>|$)/, ''));
}
const sources = [
{
title: 'Java Language Specification, Java SE 17, chapter 7: Packages and Modules',
url: 'https://docs.oracle.com/javase/specs/jls/se17/html/jls-7.html',
note: 'Первичный нормативный текст Oracle для Java SE 17: иерархия имён пакетов сама по себе не создаёт привилегированный доступ; именованный модуль явно задаёт exported packages и зависимости. Это правило языка Java, а не готовая модель предметных модулей для любого стека.',
},
{
title: 'Spring Modulith 1.1: Fundamentals',
url: 'https://docs.spring.io/spring-modulith/reference/1.1/fundamentals.html',
note: 'Версионная официальная документация Spring Modulith 1.1, выпущенного 24.11.2023: показывает module API, internal packages и allowed dependencies. Это framework-specific пример для Spring Boot, а не доказательство устройства неизвестного репозитория и не требование применять Spring.',
},
{
title: 'Spring Modulith 1.1.0 release, 24.11.2023',
url: 'https://github.com/spring-projects/spring-modulith/releases/tag/1.1.0',
note: 'Официальный release проекта фиксирует, что версия 1.1.0 существовала до февраля 2024. Он подтверждает историческую доступность версии, но не подтверждает состав или поведение чужого приложения.',
},
{
title: 'ArchUnit 1.1.0 release, 09.08.2023',
url: 'https://github.com/TNG/ArchUnit/releases/tag/v1.1.0',
note: 'Официальный release инструмента архитектурных тестов существовал до февраля 2024. Он показывает, что проверка структурных правил могла быть выделена в тест, но не делает конкретный DSL универсальным и не доказывает запуск CI.',
},
];
function sourceList() {
return '<ul>' + sources.map((item) => '<li><a href="' + item.url + '" target="_blank" rel="noopener noreferrer">' + item.title + '</a> — ' + item.note + '</li>').join('') + '</ul>';
}
function revision(meta, parts) {
const contentHtml = parts.join('\n') + '\n' + h2('Проверяемые источники') + '\n' + sourceList();
const proseLength = bodyText(contentHtml).length;
if (proseLength < 5000 || proseLength > 15000) {
throw new Error(meta.slug + ': основной текст вне диапазона 5 000–15 000 знаков: ' + proseLength);
}
return Object.freeze({ ...meta, contentHtml, proseLength });
}
const MODEL_LIMIT = 'in-memory-fixed-synthetic-module-map-no-project-read-no-filesystem-no-environment-no-ci-no-network-no-http-no-trace-no-source-scan-no-production';
const MODEL_VERSION = 'synthetic-modular-monolith-map-2024-02-v1';
const MODULES = Object.freeze([
Object.freeze({ name: 'catalog', api: 'catalog.api', internal: 'catalog.internal' }),
Object.freeze({ name: 'checkout', api: 'checkout.api', internal: 'checkout.internal' }),
Object.freeze({ name: 'payments', api: 'payments.api', internal: 'payments.internal' }),
Object.freeze({ name: 'notifications', api: 'notifications.api', internal: 'notifications.internal' }),
]);
const MODULE_NAMES = Object.freeze(MODULES.map((module) => module.name));
const API_BY_MODULE = Object.freeze(Object.fromEntries(MODULES.map((module) => [module.name, module.api])));
const ALLOWED_DIRECTIONS = Object.freeze([
Object.freeze({ from: 'checkout', to: 'catalog', surface: 'catalog.api' }),
Object.freeze({ from: 'checkout', to: 'payments', surface: 'payments.api' }),
Object.freeze({ from: 'payments', to: 'notifications', surface: 'notifications.api' }),
]);
const ALLOWED_DIRECTION_KEYS = new Set(ALLOWED_DIRECTIONS.map((item) => item.from + '>' + item.to + ':' + item.surface));
/**
* Учебная модель работает только с заранее зафиксированными JS-значениями.
* Она не читает проект, файлы, package graph, переменные окружения, CI, сеть,
* HTTP, traces, часы или production-конфигурацию. Результат не является
* сканом репозитория, подтверждением импорта, CI-проверкой либо решением о релизе.
*/
function rejectSyntheticBoundary(reason) {
return Object.freeze({
kind: 'synthetic-modular-boundary-report-v1',
syntheticOnly: true,
accepted: false,
reason,
modelLimit: MODEL_LIMIT,
projectScan: 'not-performed',
ci: 'not-touched',
network: 'not-used',
productionEffect: 'not-attempted',
});
}
function hasExactKeys(value, keys) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
const prototype = Object.getPrototypeOf(value);
return (prototype === Object.prototype || prototype === null)
&& Object.keys(value).length === keys.length
&& keys.every((key) => Object.hasOwn(value, key));
}
function hasDenseArray(value) {
return Array.isArray(value)
&& Object.keys(value).length === value.length
&& Array.from({ length: value.length }, (_, index) => Object.hasOwn(value, index)).every(Boolean);
}
function hasFixedModules(modules) {
if (!hasDenseArray(modules) || modules.length !== MODULES.length) return false;
return modules.every((module, index) => {
const expected = MODULES[index];
return hasExactKeys(module, ['name', 'api', 'internal'])
&& module.name === expected.name
&& module.api === expected.api
&& module.internal === expected.internal;
});
}
function referenceKey(reference) {
return reference.from + '>' + reference.to + ':' + reference.surface;
}
function hasDirectedCycle(references) {
const graph = new Map(MODULE_NAMES.map((name) => [name, new Set()]));
for (const reference of references) {
if (MODULE_NAMES.includes(reference.from) && MODULE_NAMES.includes(reference.to)) {
graph.get(reference.from).add(reference.to);
}
}
const visiting = new Set();
const visited = new Set();
const visit = (name) => {
if (visiting.has(name)) return true;
if (visited.has(name)) return false;
visiting.add(name);
for (const next of graph.get(name)) {
if (visit(next)) return true;
}
visiting.delete(name);
visited.add(name);
return false;
};
return MODULE_NAMES.some((name) => visit(name));
}
function isAllowedSyntheticReference(reference) {
if (!hasExactKeys(reference, ['from', 'to', 'surface'])
|| typeof reference.from !== 'string'
|| typeof reference.to !== 'string'
|| typeof reference.surface !== 'string') return false;
if (!MODULE_NAMES.includes(reference.from) || !MODULE_NAMES.includes(reference.to)) return false;
if (reference.from === reference.to || reference.surface !== API_BY_MODULE[reference.to]) return false;
return ALLOWED_DIRECTION_KEYS.has(referenceKey(reference));
}
function isSyntheticBoundarySnapshot(snapshot) {
if (!hasExactKeys(snapshot, ['mapVersion', 'modules', 'references'])) return false;
if (snapshot.mapVersion !== MODEL_VERSION || !hasFixedModules(snapshot.modules)) return false;
if (!hasDenseArray(snapshot.references)) return false;
const keys = snapshot.references.map(referenceKey);
return snapshot.references.every(isAllowedSyntheticReference)
&& new Set(keys).size === keys.length
&& !hasDirectedCycle(snapshot.references);
}
function copySyntheticBoundarySnapshot(snapshot) {
return Object.freeze({
mapVersion: snapshot.mapVersion,
modules: Object.freeze(snapshot.modules.map((module) => Object.freeze({ name: module.name, api: module.api, internal: module.internal }))),
references: Object.freeze(snapshot.references.map((reference) => Object.freeze({ from: reference.from, to: reference.to, surface: reference.surface }))),
});
}
function isRestorableSyntheticBoundaryReport(report) {
const reportKeys = ['kind', 'syntheticOnly', 'accepted', 'reason', 'modelLimit', 'model', 'decision', 'rollback', 'projectScan', 'filesystem', 'ci', 'network', 'productionEffect'];
return hasExactKeys(report, reportKeys)
&& report.kind === 'synthetic-modular-boundary-report-v1'
&& report.syntheticOnly === true
&& report.accepted === true
&& report.reason === 'synthetic-boundary-map-accepted'
&& report.modelLimit === MODEL_LIMIT
&& hasExactKeys(report.rollback, ['action', 'snapshot'])
&& report.rollback.action === 'restore-synthetic-boundary-draft'
&& isSyntheticBoundarySnapshot(report.rollback.snapshot)
&& report.projectScan === 'not-performed'
&& report.filesystem === 'not-read'
&& report.ci === 'not-touched'
&& report.network === 'not-used'
&& report.productionEffect === 'not-attempted';
}
/**
* Проверяет только synthetic карту четырёх учебных модулей. `references` — не
* импорт из кода, а переданный в память список, поэтому функция не даёт
* основания говорить, что в каком-либо репозитории найдена или не найдена связь.
*/
export function inspectSyntheticModuleBoundaries(input) {
if (!input || input.synthetic !== true || input.kind !== 'synthetic-module-boundary-input-v1') {
return rejectSyntheticBoundary('synthetic-input-required');
}
const topLevel = ['synthetic', 'kind', 'mapVersion', 'modules', 'references'];
if (!hasExactKeys(input, topLevel)) return rejectSyntheticBoundary('unexpected-input-field');
if (input.mapVersion !== MODEL_VERSION) return rejectSyntheticBoundary('unexpected-synthetic-map-version');
if (!hasFixedModules(input.modules)) return rejectSyntheticBoundary('fixed-module-map-required');
if (!hasDenseArray(input.references)) return rejectSyntheticBoundary('synthetic-references-array-required');
const violations = [];
const checkedReferences = [];
const seenReferences = new Set();
for (const reference of input.references) {
if (!hasExactKeys(reference, ['from', 'to', 'surface'])
|| typeof reference.from !== 'string'
|| typeof reference.to !== 'string'
|| typeof reference.surface !== 'string') {
violations.push('invalid-reference-shape');
continue;
}
const item = Object.freeze({ from: reference.from, to: reference.to, surface: reference.surface });
checkedReferences.push(item);
const key = referenceKey(item);
if (seenReferences.has(key)) violations.push('duplicate-reference:' + key);
seenReferences.add(key);
if (!MODULE_NAMES.includes(item.from) || !MODULE_NAMES.includes(item.to)) {
violations.push('unknown-module:' + key);
continue;
}
if (item.from === item.to) violations.push('self-dependency:' + key);
if (item.surface !== API_BY_MODULE[item.to]) {
violations.push('non-public-or-unknown-surface:' + key);
continue;
}
if (!ALLOWED_DIRECTION_KEYS.has(key)) violations.push('direction-not-allowed:' + key);
}
if (hasDirectedCycle(checkedReferences)) violations.push('cycle-detected-in-synthetic-references');
const uniqueViolations = Object.freeze([...new Set(violations)]);
const accepted = uniqueViolations.length === 0;
const snapshot = Object.freeze({
mapVersion: MODEL_VERSION,
modules: MODULES.map((module) => Object.freeze({ name: module.name, api: module.api, internal: module.internal })),
references: Object.freeze(checkedReferences.map((reference) => Object.freeze({ ...reference }))),
});
return Object.freeze({
kind: 'synthetic-modular-boundary-report-v1',
syntheticOnly: true,
accepted,
reason: accepted ? 'synthetic-boundary-map-accepted' : 'synthetic-boundary-violations-found',
modelLimit: MODEL_LIMIT,
model: Object.freeze({
mapVersion: MODEL_VERSION,
modules: Object.freeze(MODULE_NAMES.slice()),
allowedDirections: Object.freeze(ALLOWED_DIRECTIONS.map((item) => Object.freeze({ ...item }))),
checkedReferences: Object.freeze(checkedReferences),
violations: uniqueViolations,
sourceImports: 'not-read',
graphOrigin: 'fixed-memory-input-only',
}),
decision: Object.freeze({
proposal: accepted ? 'review-synthetic-boundary-draft' : 'correct-synthetic-boundary-draft',
releaseAuthority: 'not-granted',
realRepositoryBoundary: 'not-claimed',
realCiResult: 'not-claimed',
realTrace: 'not-claimed',
}),
rollback: Object.freeze({ action: 'restore-synthetic-boundary-draft', snapshot }),
projectScan: 'not-performed',
filesystem: 'not-read',
ci: 'not-touched',
network: 'not-used',
productionEffect: 'not-attempted',
});
}
export function restoreSyntheticBoundaryDraft(report) {
if (!isRestorableSyntheticBoundaryReport(report)) {
return Object.freeze({ restored: false, syntheticOnly: true, reason: 'no-accepted-synthetic-boundary-draft' });
}
return Object.freeze({
restored: true,
syntheticOnly: true,
reason: 'synthetic-boundary-draft-restored',
snapshot: copySyntheticBoundarySnapshot(report.rollback.snapshot),
projectScan: 'not-performed',
filesystem: 'not-read',
ci: 'not-touched',
network: 'not-used',
productionEffect: 'not-attempted',
});
}
const fixedSyntheticInput = Object.freeze({
synthetic: true,
kind: 'synthetic-module-boundary-input-v1',
mapVersion: MODEL_VERSION,
modules: Object.freeze(MODULES.map((module) => Object.freeze({ name: module.name, api: module.api, internal: module.internal }))),
references: Object.freeze([
Object.freeze({ from: 'checkout', to: 'catalog', surface: 'catalog.api' }),
Object.freeze({ from: 'checkout', to: 'payments', surface: 'payments.api' }),
Object.freeze({ from: 'payments', to: 'notifications', surface: 'notifications.api' }),
]),
});
export function runModularMonolithFixture() {
const valid = inspectSyntheticModuleBoundaries(fixedSyntheticInput);
const nonSynthetic = inspectSyntheticModuleBoundaries({ ...fixedSyntheticInput, synthetic: false });
const networkLikeField = inspectSyntheticModuleBoundaries({ ...fixedSyntheticInput, repositoryUrl: 'https://example.invalid/repository' });
const wrongVersion = inspectSyntheticModuleBoundaries({ ...fixedSyntheticInput, mapVersion: 'other-map' });
const changedModuleMap = inspectSyntheticModuleBoundaries({
...fixedSyntheticInput,
modules: fixedSyntheticInput.modules.map((module) => module.name === 'payments' ? { ...module, api: 'billing.api' } : module),
});
const internalReference = inspectSyntheticModuleBoundaries({
...fixedSyntheticInput,
references: [{ from: 'checkout', to: 'catalog', surface: 'catalog.internal' }],
});
const forbiddenDirection = inspectSyntheticModuleBoundaries({
...fixedSyntheticInput,
references: [{ from: 'catalog', to: 'payments', surface: 'payments.api' }],
});
const unknownModule = inspectSyntheticModuleBoundaries({
...fixedSyntheticInput,
references: [{ from: 'checkout', to: 'ledger', surface: 'ledger.api' }],
});
const duplicateReference = inspectSyntheticModuleBoundaries({
...fixedSyntheticInput,
references: [
{ from: 'checkout', to: 'catalog', surface: 'catalog.api' },
{ from: 'checkout', to: 'catalog', surface: 'catalog.api' },
],
});
const malformedReference = inspectSyntheticModuleBoundaries({
...fixedSyntheticInput,
references: [{ from: 'checkout', to: 'catalog', surface: 'catalog.api', path: 'src/checkout.js' }],
});
const selfDependency = inspectSyntheticModuleBoundaries({
...fixedSyntheticInput,
references: [{ from: 'checkout', to: 'checkout', surface: 'checkout.api' }],
});
const cycle = inspectSyntheticModuleBoundaries({
...fixedSyntheticInput,
references: [
{ from: 'checkout', to: 'payments', surface: 'payments.api' },
{ from: 'payments', to: 'checkout', surface: 'checkout.api' },
],
});
const sparseReferences = new Array(2);
sparseReferences[0] = { from: 'checkout', to: 'catalog', surface: 'catalog.api' };
const sparseReferenceArray = inspectSyntheticModuleBoundaries({ ...fixedSyntheticInput, references: sparseReferences });
const restored = restoreSyntheticBoundaryDraft(valid);
const rejectedRestore = restoreSyntheticBoundaryDraft(forbiddenDirection);
const fabricatedRestore = restoreSyntheticBoundaryDraft({
kind: 'synthetic-modular-boundary-report-v1',
syntheticOnly: true,
accepted: true,
reason: 'synthetic-boundary-map-accepted',
modelLimit: MODEL_LIMIT,
model: {},
decision: {},
rollback: { action: 'restore-synthetic-boundary-draft', snapshot: {} },
projectScan: 'not-performed',
filesystem: 'not-read',
ci: 'not-touched',
network: 'not-used',
productionEffect: 'not-attempted',
});
return Object.freeze({
assertions: Object.freeze({
acceptsFixedMemoryOnlyMap: valid.accepted === true && valid.reason === 'synthetic-boundary-map-accepted',
keepsFourFixedModules: valid.model.modules.join(',') === 'catalog,checkout,payments,notifications',
keepsOnlyDeclaredDirections: valid.model.allowedDirections.length === 3 && valid.model.allowedDirections[1].from === 'checkout' && valid.model.allowedDirections[1].to === 'payments',
checksPublicSurfacesNotFolders: valid.model.checkedReferences.every((reference) => reference.surface === API_BY_MODULE[reference.to]),
doesNotClaimRepositoryScan: valid.projectScan === 'not-performed' && valid.model.sourceImports === 'not-read' && valid.decision.realRepositoryBoundary === 'not-claimed',
doesNotClaimCiOrTrace: valid.ci === 'not-touched' && valid.decision.realCiResult === 'not-claimed' && valid.decision.realTrace === 'not-claimed',
doesNotGrantReleaseAuthority: valid.decision.releaseAuthority === 'not-granted' && valid.decision.proposal === 'review-synthetic-boundary-draft',
rejectsNonSyntheticInput: nonSynthetic.accepted === false && nonSynthetic.reason === 'synthetic-input-required',
rejectsNetworkLikeFieldWithoutUsingIt: networkLikeField.accepted === false && networkLikeField.reason === 'unexpected-input-field' && networkLikeField.network === 'not-used',
rejectsOtherMapVersion: wrongVersion.accepted === false && wrongVersion.reason === 'unexpected-synthetic-map-version',
rejectsChangedModuleMap: changedModuleMap.accepted === false && changedModuleMap.reason === 'fixed-module-map-required',
rejectsInternalSurface: internalReference.accepted === false && internalReference.model.violations.some((item) => item.startsWith('non-public-or-unknown-surface:')),
rejectsForbiddenDirection: forbiddenDirection.accepted === false && forbiddenDirection.model.violations.some((item) => item.startsWith('direction-not-allowed:')),
rejectsUnknownModule: unknownModule.accepted === false && unknownModule.model.violations.some((item) => item.startsWith('unknown-module:')),
rejectsDuplicateReference: duplicateReference.accepted === false && duplicateReference.model.violations.some((item) => item.startsWith('duplicate-reference:')),
rejectsSyntheticPathClaim: malformedReference.accepted === false && malformedReference.model.violations.includes('invalid-reference-shape'),
rejectsSelfDependency: selfDependency.accepted === false && selfDependency.model.violations.some((item) => item.startsWith('self-dependency:')),
detectsCycleInProvidedMemoryGraph: cycle.accepted === false && cycle.model.violations.includes('cycle-detected-in-synthetic-references'),
rejectsSparseInputAndFabricatedRestore: sparseReferenceArray.accepted === false && sparseReferenceArray.reason === 'synthetic-references-array-required' && fabricatedRestore.restored === false && fabricatedRestore.reason === 'no-accepted-synthetic-boundary-draft',
restoresOnlySyntheticDraft: restored.restored === true && restored.snapshot !== valid.rollback.snapshot && restored.snapshot.mapVersion === MODEL_VERSION && restored.projectScan === 'not-performed',
restoreDoesNotTouchSystems: restored.filesystem === 'not-read' && restored.ci === 'not-touched' && restored.network === 'not-used' && restored.productionEffect === 'not-attempted',
rejectedReportCannotRestore: rejectedRestore.restored === false && rejectedRestore.reason === 'no-accepted-synthetic-boundary-draft',
}),
samples: Object.freeze({ valid, nonSynthetic, networkLikeField, wrongVersion, changedModuleMap, internalReference, forbiddenDirection, unknownModule, duplicateReference, malformedReference, selfDependency, cycle, sparseReferenceArray, restored, rejectedRestore, fabricatedRestore }),
});
}
const syntheticExample = `import { runModularMonolithFixture } from './upgrade-2024-02.mjs';
const report = runModularMonolithFixture();
if (!Object.values(report.assertions).every(Boolean)) throw new Error('fixture failed');
console.log(report.samples.valid.model.allowedDirections);
console.log(report.samples.internalReference.model.violations[0]);
// non-public-or-unknown-surface:checkout>catalog:catalog.internal
// Внутри модуля только fixed JS-данные. Нет чтения файлов, package graph,
// репозитория, CI, сети, HTTP, trace или production-конфигурации.`;
const fixtureCommand = syntheticExample + '\n\nnode web/scripts/upgrade-2024-02.mjs --verify-fixture\n\n# PASS подтверждает лишь согласованность fixed synthetic политики и отрицательных веток.';
const practice = revision({
slug: 'editorial-2024-02-practice-modular-monolith',
title: 'Модульный монолит: сначала разрешённые зависимости, потом новые папки',
categories: ['Архитектура', 'Практики разработки'],
cover: '/assets/editorial/2024/modular-monolith-2024-dependency-matrix.svg',
excerpt: 'Как превратить «папки по доменам» в проверяемую договорённость: назвать публичный API модуля, разрешённые направления зависимостей, правило для internal-кода и короткую проверку без выдуманного сканирования репозитория.',
readingMinutes: 12,
}, [
p('Проблема редко начинается с большой архитектуры. В монолите появляется папка `checkout`, рядом — `catalog`, затем кто-то импортирует внутренний helper из `catalog/internal`, потому что так быстрее. Через месяц другой модуль повторяет этот путь, а изменение каталога требует читать чужие вызовы. На схеме всё ещё четыре домена. В коде уже нет границы: папка даёт имя, но не определяет, что разрешено использовать. Цена ошибки — локальная правка получает скрытых потребителей, план работ становится неточным, а «разделение на модули» превращается в спор о расположении файлов.'),
p('Ускоренный ответ — переместить код в ещё более глубокие директории. Он не меняет правило доступа. Java Language Specification прямо отмечает, что иерархия имён пакетов удобна для организации, но сама по себе не создаёт специального отношения доступа. В других стеках ситуация та же по смыслу: дерево помогает искать код, а договорённость о зависимости должна быть отдельной. Поэтому первым артефактом берём не новую структуру каталогов, а маленькую карту: модуль, его публичная поверхность, internal-часть и список направлений, которые допустимы.'),
h2('Модуль отвечает на два вопроса'),
p('Первый вопрос: что другой модуль вправе вызвать или получить? Это публичный API: команда, событие, тип данных или ограниченный адаптер. Второй: от кого этот модуль вправе зависеть? Если второй ответ отсутствует, public API постепенно становится транзитной дверью во все соседние области. Если отсутствует первый, код либо копируют, либо тянут внутреннюю реализацию. Модуль — не папка и не количество файлов. Это контракт исходящих и входящих связей, который можно объяснить одной таблицей и затем проверить.'),
p('Для учебной карты выбраны четыре нейтральных имени: `catalog`, `checkout`, `payments`, `notifications`. У каждого есть ровно один видимый API и отдельная internal-область. `checkout` может обращаться к API `catalog` и `payments`; `payments` — к API `notifications`. Обратные и боковые направления не разрешены. Такое дерево не описывает реальный продукт и не предлагает универсальную декомпозицию. Оно намеренно маленькое, чтобы показать проверяемую форму правила: сначала откуда, затем куда, затем к какой поверхности.'),
table('Рабочая карта зависимости для учебной модели', ['Откуда', 'Куда и к какой поверхности', 'Статус', 'Причина правила', 'Что не следует заключать'], [
['checkout', 'catalog.api', 'разрешено', 'checkout запрашивает опубликованную возможность каталога', 'что checkout знает внутреннее хранение каталога'],
['checkout', 'payments.api', 'разрешено', 'оплата остаётся отдельной ответственностью с узким входом', 'что payment workflow уже существует в коде'],
['payments', 'notifications.api', 'разрешено', 'уведомление вызывается через явную поверхность', 'что доставка сообщения гарантирована'],
['любой модуль', 'чужой *.internal', 'запрещено', 'потребитель не должен получать право на детали реализации', 'что все внутренние типы обязаны быть физически недоступны в любом языке'],
['catalog', 'payments.api', 'запрещено в этой карте', 'не добавлять связь без названного сценария и владельца', 'что такая связь невозможна в другой обоснованной карте'],
]),
figure('/assets/editorial/2024/modular-monolith-2024-dependency-matrix.svg', 'Матрица зависимостей учебного модульного монолита: строки — источник, столбцы — получатель. Зелёными стрелками отмечены checkout к catalog и payments, payments к notifications; остальные клетки заблокированы, internal-поверхности вынесены в отдельную легенду.', 'Матрица фиксирует направление, а не файловую структуру. Она не получена сканированием проекта и не утверждает, что такие зависимости существуют в production.'),
h2('Симптом → причина → проверка → действие'),
p('Симптом: изменение внутреннего parser-а каталога требует искать обращения в checkout. Причина: зависимость была записана как «checkout зависит от catalog», без указания доступной поверхности; consumer получил не контракт, а деталь. Проверка: для каждого межмодульного обращения выписать `from`, `to`, `surface` и спросить, относится ли surface к опубликованному API. Действие: оставить один вход, а internal тип либо скрыть, либо вынести требуемую операцию в API с названием и владельцем. Это не означает, что API должен повторить каждую внутреннюю функцию. Он обязан выражать нужный чужому модулю смысл, а не сохранить удобный импорт.'),
p('Следующий симптом: два модуля начинают ссылаться друг на друга. Причина бывает разной — общая операция, неверный владелец процесса, удобный shared-helper. Но проверка одна: нарисовать стрелки источника к получателю, а не читать названия папок. Цикл означает, что порядок изменения и запуска уже нельзя объяснить одной направленной картой. Действие не обязательно «вынести сервис». Сначала можно уточнить владельца операции, выделить один узкий API или сделать явное сообщение. Важно не маскировать цикл общим пакетом `common`: он легко превращается в новую неописанную центральную зависимость.'),
h2('Договор должен быть уже инструмента'),
p('Инструмент способен вычислить, что одно имя ссылается на другое. Он не способен сам решить, почему это разрешено. Поэтому до архитектурного теста фиксируем правило в четырёх строках: список модулей, их public surfaces, разрешённые направления, исключение и его срок пересмотра. Spring Modulith 1.1 показывает похожее разделение на API, внутренности и allowed dependencies, но это не повод переносить его аннотации в любой проект. Берём форму вопроса, а не фреймворк как доказательство дизайна.'),
p('У правила также должен быть владелец. Не человек, которому можно передать все решения, а роль, которая поддерживает карту и собирает обсуждение при новом направлении. Без владельца исключение становится постоянным import-ом с комментарием «временно». С владельцем оно получает время пересмотра, сценарий и проверку: остался ли вызов единственным или он уже вырос в новый контракт. Так граница остаётся технической работой, а не презентационным слоем.'),
h2('Synthetic fixture проверяет правило, а не репозиторий'),
p('Ниже лежит fixed memory-only fixture. Он получает заранее подготовленные JavaScript-объекты: четыре synthetic модуля и небольшой список synthetic ссылок. Для каждой ссылки он проверяет форму, существование модулей, совпадение поверхности с публичным API, разрешённое направление, дубли и цикл. Ветка с `catalog.internal` отклоняется. Ветка `catalog → payments.api` отклоняется как неразрешённое направление. Это полезно, потому что проверяет, что правило не свелось к красивой диаграмме.'),
code(fixtureCommand),
p('PASS у этого fixture не говорит, что в данном репозитории нет forbidden import. Он не читает дерево файлов, AST, package graph, переменные окружения, CI, сеть, HTTP, trace или production-конфигурацию. Он не знает настоящий набор модулей, историю коммитов, права на релиз или влияние на пользователя. Его единственный результат — fixed политика различает разрешённую публичную связь и заранее заданные отрицательные случаи. Подменять этот результат аудитом кода было бы ложным заявлением.'),
h2('Короткий маршрут внедрения'),
ol([
'<strong>Выберите один болезненный стык.</strong> Не «весь монолит», а изменение, которое регулярно цепляет чужую внутренность или создаёт цикл.',
'<strong>Назовите consumer и owner.</strong> Запишите, кто нуждается в возможности и какой модуль отвечает за её смысл, данные и эволюцию.',
'<strong>Опишите surface.</strong> Дайте API короткое имя и перечислите, что остаётся internal. Не делайте исходный файл контрактом по умолчанию.',
'<strong>Зафиксируйте направление.</strong> Запишите `from → to.surface`; отдельно запишите запрещённую обратную связь, если она ожидаема и опасна.',
'<strong>Проверьте существующий переход.</strong> В разрешённой среде проведите реальный анализ кодовой базы выбранным инструментом, но не приписывайте его результат этой учебной модели.',
'<strong>Добавьте обратимое изменение.</strong> Сначала перенесите один вызов на API, оставьте план возврата и критерий, что старую внутренность действительно больше не потребляют.',
]),
h2('Ограничения и следующий шаг'),
p('Карта не решает вопросы транзакций, данных, авторизации, времени доставки, очередей и распределённых границ. Она не доказывает, что один public метод хорошего размера или что зависимость безопасна по latency. Java modules могут дать физическое ограничение на уровне языка, Spring Modulith — проверку для своего стека, ArchUnit — способ выразить правило тестом; ни один источник не выбирает доменные границы вместо команды. Когда нужна новая стрелка, её нельзя добавлять только для зелёного статуса. Надо снова назвать сценарий, API, владельца, альтернативу и цену связи.'),
p('Следующий практический шаг — взять одну реальную ссылку, которая сейчас выглядит как «быстрый helper», и прогнать её через таблицу. Если не удаётся назвать публичную возможность без имени внутреннего класса, граница пока не готова. Тогда лучше оставить работу локальной, уточнить владеющий модуль или спроектировать небольшой API. Это медленнее на один разговор, зато дешевле следующего массового rename.'),
h2('Историческая граница февраля 2024'),
p('К февралю 2024 уже существовали Java SE 17, Spring Modulith 1.1.0 от 24 ноября 2023 года и ArchUnit 1.1.0 от 9 августа 2023 года. Поэтому материал использует доступные к этому моменту понятия публичной поверхности, явно разрешённой связи и структурного теста. Голос M7 не обещает «распилить монолит» и не придумывает production-историю: он оставляет карту, проверку и ограничение полномочий.'),
]);
const mechanism = revision({
slug: 'editorial-2024-02-mechanism-modular-monolith',
title: 'Граница модуля: граф разрешённых направлений и публичная поверхность',
categories: ['Архитектура', 'Качество кода'],
cover: '/assets/editorial/2024/modular-monolith-2024-allowed-directions.svg',
excerpt: 'Почему «A зависит от B» недостаточно: как задать зависимость тройкой source → target.public API, обнаруживать internal-протечки и циклы в фиксированной модели, не выдавая её за анализ кода.',
readingMinutes: 12,
}, [
p('Проблема проявляется, когда архитектурное правило звучит убедительно, но не даёт ответа на конкретный import. «Заказы могут зависеть от каталога» не говорит, может ли `checkout` вызвать любой public класс, тронуть `catalog.internal`, забрать репозиторий или только получить карточку товара. Разные разработчики разумно читают одну фразу по-разному. Цена — граница перестаёт быть предсказуемой: каждый новый вызов выглядит маленьким, но сумма вызовов открывает соседний модуль целиком.'),
p('Причина в смешении трёх разных вещей: имя директории, видимость символа и архитектурное разрешение. Символ может быть технически public, потому что он нужен внутри одного модуля или фреймворку; это ещё не приглашение другим модулям. Java SE 17 формализует похожую разницу для named modules: внешний код получает доступ к public типу только из exported package, а читающая сторона должна явно зависеть от модуля. В обычном монолите такого физического заслона может не быть, поэтому архитектурное правило должно назвать поверхность явно и быть проверяемым отдельно.'),
h2('Зависимость — это тройка, не стрелка между папками'),
p('Запишем связь как `consumer → owner.surface`. `consumer` отвечает за то, зачем ему нужна возможность; `owner` отвечает за её смысл и эволюцию; `surface` — единственный разрешённый вход. Для карты ниже `checkout → catalog.api` допустима, а `checkout → catalog.internal` нет, даже если оба пути находятся под одним верхним namespace. Такая запись добавляет важный вопрос: является ли нужная операция действительно частью API или consumer тянет detail, потому что API пока отсутствует? Ответ нельзя вычислить из имени файла, но его можно потребовать в review.'),
p('Публичная поверхность не обязана быть одним типом. Это может быть команда, query, событие, порт или простая функция в разрешённом пакете. Но у неё должна быть граница смысла. Хорошее название говорит, что можно получить или попросить: `catalog.api` предлагает каталожную возможность, а не «все helpers». Если API начинает повторять структуру хранилища или передавать внутренние entity, consumer получает ту же связанность через другой вход. Тогда проверка направления пройдёт, но модульность останется декоративной.'),
table('Как классифицировать межмодульную ссылку', ['Наблюдаемый случай', 'Причина', 'Проверка', 'Действие', 'Ограничение'], [
['checkout обращается к catalog.api', 'есть названный потребительский сценарий', 'сверить тройку с картой и смысл API', 'оставить связь и назначить owner контракта', 'допустимость не доказывает качество payload'],
['checkout обращается к catalog.internal', 'consumer использует деталь или API не выражает нужную операцию', 'сравнить surface с опубликованным списком', 'заменить вызов API или спроектировать узкую возможность', 'не обещает мгновенно скрыть тип во всех языках'],
['catalog обращается к payments.api', 'направление добавлено без сценария или ownership ошибочен', 'проверить allowed matrix и альтернативный владелец процесса', 'отклонить либо оформить новую стрелку с review', 'карта не запрещает обоснованную будущую связь'],
['checkout и payments ссылаются друг на друга', 'две ответственности смешаны или общий процесс не имеет владельца', 'построить направленный граф и найти цикл', 'выделить один контракт или переприсвоить orchestration', 'граф не выбирает бизнес-решение автоматически'],
['все начинают импортировать common', 'общий пакет стал обходом boundary review', 'проверить входящие и исходящие связи common', 'сузить shared contract либо вернуть код владельцу', 'общий код не всегда ошибка, но нуждается в отдельной модели'],
]),
figure('/assets/editorial/2024/modular-monolith-2024-allowed-directions.svg', 'Схема разрешённых направлений: checkout обращается только к catalog.api и payments.api, payments — к notifications.api. У каждого модуля публичная верхняя зона отделена от internal-зоны; красная пунктирная стрелка к catalog.internal помечена как запрещённая.', 'На рисунке есть модель разрешений, а не graph, полученный из source code. Пунктир не утверждает, что такой import найден в реальном проекте.'),
h2('Почему API и internal нужно разделить даже без language enforcement'),
p('Когда компилятор не может запретить обращение к internal, появляется соблазн не различать их вовсе. Это как снять замок с двери, потому что у охраны есть список посетителей. Нужны оба слоя: техническая видимость там, где стек её даёт, и явная архитектурная карта там, где она нужна. Spring Modulith 1.1 описывает module root как API и отличает внутренние подпакеты; его документация полезна именно этим разграничением. Но механизм привязан к Spring Boot и Java. В TypeScript, PHP, Go или другом Java-проекте форма проверки будет другой, а вопрос остаётся тем же: какой consumer получил право использовать какой смысл.'),
p('Важная оговорка: не следует называть любой public тип API. Public может требоваться сериализатору, тесту или коду внутри модуля. Архитектурный API определяется не модификатором, а обещанием потребителям и правилом поддержки. Это обещание может быть очень узким: один результат, одно событие, один интерфейс. Чем шире surface, тем больше обязательство при изменении. Поэтому для новой связи сначала формулируют сценарий и минимальные данные, а лишь потом выбирают форму вызова.'),
h2('Граф обязан быть направленным и объяснимым'),
p('Список разрешённых направлений образует ориентированный граф. Он нужен не ради математического слова, а ради двух проверок. Первая: consumer не обходит owner через internal. Вторая: граф не получает цикл, в котором две области вынуждены знать детали друг друга. В fixed модели разрешены только три стрелки: `checkout → catalog.api`, `checkout → payments.api`, `payments → notifications.api`. Такая схема намеренно исключает обратное направление. Если добавить `payments → checkout.api`, модель увидит и неразрешённую стрелку, и цикл в переданном memory graph.'),
p('Найденный цикл — не приговор и не автоматическое указание создать микросервис. Он обозначает вопрос, который карта не ответила: кто владеет процессом, где заканчивается инвариант, есть ли у обмена направление во времени или нужен общий контракт. Иногда правильным ответом будет orchestration в одном модуле. Иногда — публичное событие. Иногда — перенос небольшой операции. Неправильным ответом будет добавить исключение без срока, потому что через него consumer получает постоянное право на чужую внутренность.'),
h2('Проверяем только то, что действительно смоделировали'),
p('Fixture в этом пакете не парсит imports. Он принимает fixed JS objects с полями `from`, `to`, `surface`, а затем сверяет их с fixed списком модулей и разрешённых маршрутов. Он специально отвергает лишнее поле `path`: путь к исходнику выглядел бы как начало скана, но fixture не умеет и не пытается читать исходники. Так отрицательная ветка проверяет более полезное свойство: модель не выдаёт synthetic запись за найденный файл.'),
code(fixtureCommand),
p('В корректной ветке три учебные связи разрешены; в ветках ошибок модель возвращает точную классификацию: неизвестный модуль, non-public surface, forbidden direction, duplicate reference, self-dependency или cycle. Она также возвращает `releaseAuthority=not-granted`, `projectScan=not-performed` и `realCiResult=not-claimed`. Это не декоративные флаги. Они закрывают распространённую ошибку документации: считать PASS на маленьком примере результатом архитектурной проверки реального процесса.'),
h2('Маршрут: от import к решению о границе'),
ol([
'<strong>Зафиксируйте конкретный вызов.</strong> Назовите consumer, owner и нужный результат; не начинайте с массовой перестройки namespaces.',
'<strong>Проверьте поверхность.</strong> Если consumer просит internal тип, выясните, это недостающий API или чужая ответственность, которую не надо переносить.',
'<strong>Проверьте направление.</strong> Сравните `from → to.surface` с картой. Любая новая стрелка требует сценария, owner и оценки обратного направления.',
'<strong>Проверьте цикл.</strong> Добавьте стрелку на схему до реализации. Если появилась петля, остановитесь на модели и выберите место orchestration.',
'<strong>Сделайте один обратимый перенос.</strong> Введите API или адаптер, переведите одного consumer-а, сохраните план удаления старого доступа и критерий проверки.',
'<strong>Только затем автоматизируйте.</strong> Выберите анализатор, язык и место запуска по возможностям реального проекта; не называйте fixture заменой этого шага.',
]),
h2('Где модель заканчивается'),
p('Граф не отражает данные, транзакции, задержку, права, версионирование события или стоимость преобразования. Зелёная стрелка может быть архитектурно разрешена и всё равно создать тяжёлый запрос. Красная стрелка может стать обоснованной после изменения ownership. У модели нет доступа к вашим файлам, dependency manager, build, тестам, CI, trace, observability или production. Нет и «идеального» числа модулей. Это не недостаток короткой карты; это её честная граница.'),
p('Следующий шаг — написать рядом с каждой разрешённой стрелкой один вопрос, который она обслуживает, и один способ удалить её при смене решения. Если такого вопроса нет, связь преждевременна. Если невозможно назвать removal path, API слишком рано объявлен стабильным. С этого момента папки снова становятся полезными: они отражают уже принятое правило, а не пытаются заменить его.'),
h2('Историческая граница февраля 2024'),
p('Февраль 2024 позволяет использовать Java SE 17 как нормативную границу module/package и Spring Modulith 1.1.0 как доступный на тот момент пример explicit dependencies; ArchUnit 1.1.0 уже существовал как инструмент структурных правил. Материал не переносит поздние runtime-опции и не делает вид, что любой монолит собран на Java. Уровень M7 здесь — спокойная проверяемая формулировка зависимости и отказ от ложного охвата.'),
]);
const field = revision({
slug: 'editorial-2024-02-field-modular-monolith',
title: 'Полевой разбор границ: три импорта и одна карта модульного монолита',
categories: ['Архитектура', 'Инженерные практики'],
cover: '/assets/editorial/2024/modular-monolith-2024-boundary-test-loop.svg',
excerpt: 'Учебный разбор без выдуманного production-опыта: как классифицировать допустимый API-вызов, протечку во внутренность и новую обратную зависимость, затем выбрать обратимое действие и не выдать fixture за CI.',
readingMinutes: 12,
}, [
p('Проблема полевого разбора обычно выглядит невинно: в pull request есть три маленьких импорта. Первый берёт карточку товара, второй — приватный formatter, третий просит платёжный модуль вызвать checkout обратно. Каждый отдельно можно объяснить дедлайном. Вместе они меняют карту: одна связь расширяет API, другая привязывает consumer к детали, третья замыкает направление. Цена ошибки — не «нарушение чистоты». Следующее изменение оплаты или каталога начинает требовать координации нескольких областей, а ревью вынуждено вспоминать неявные договорённости вместо того, чтобы проверить правило.'),
p('Ниже не история о реальном репозитории и не результат CI. Это synthetic кейс с фиксированными именами `catalog`, `checkout`, `payments`, `notifications`. Он нужен, чтобы показать порядок разбора перед тем, как открыть настоящий код. Кейс не использует production-трассы, файлы, git, network, HTTP или чужие метрики. Его сила не в реалистичных цифрах, а в том, что каждый вывод привязан к тройке `source → target.surface` и может быть отвергнут, если данных для него нет.'),
h2('Три входа в review'),
p('Первый synthetic импорт: `checkout → catalog.api`. Он попадает в разрешённую матрицу. Проверка не заканчивается словом «зелёный»: reviewer уточняет, что checkout действительно просит опубликованную каталожную возможность, а не переносит туда вычисление заказа. Действие — оставить связь и зафиксировать owner API. Второй: `checkout → catalog.internal`. Он может решать ровно ту же ближайшую задачу, но нарушает поверхность. Проверка показывает, что target не равен `catalog.api`. Действие — не делать internal публичным по умолчанию; сначала выбрать, нужен ли новый узкий метод или логика должна остаться у каталога.'),
p('Третий synthetic импорт: `payments → checkout.api`. Его surface формально публична, но направления нет в карте. Если одновременно `checkout → payments.api` уже существует, появляется цикл. Симптом — не ошибка компиляции в учебной модели, а невозможность объяснить, кто владеет процессом между оплатой и checkout. Причина — новая обратная связь добавлена как техническая деталь. Проверка — нарисовать оба ребра и рассмотреть владение состоянием. Действие — вынести orchestration в одну сторону или спроектировать явный event contract; не добавлять взаимную зависимость с обещанием «потом разберёмся». '),
table('Ledger учебного review', ['Ссылка', 'Симптом', 'Проверка модели', 'Предлагаемое действие', 'Что остаётся неизвестным'], [
['checkout → catalog.api', 'новый межмодульный вызов', 'surface и direction есть в fixed карте', 'оставить как candidate API contract', 'содержимое реального API, нагрузка и права'],
['checkout → catalog.internal', 'consumer тянет implementation detail', 'surface не совпадает с опубликованным catalog.api', 'вернуть вызов владельцу либо спроектировать узкий API', 'почему internal пока технически доступен'],
['payments → checkout.api', 'обратная зависимость к уже используемому consumer', 'direction не разрешён; вместе с checkout → payments образует synthetic cycle', 'назначить orchestration или event contract до кода', 'какой вариант соответствует реальному домену'],
['catalog → payments.api', 'боковой обход без сценария', 'public surface есть, direction отсутствует', 'отклонить до появления объяснимой потребности', 'нужен ли другой владелец процесса'],
['любой → unknown.api', 'карта не знает получателя', 'unknown-module', 'обновить модель только после отдельного review', 'существует ли такой компонент в кодовой базе'],
]),
figure('/assets/editorial/2024/modular-monolith-2024-boundary-test-loop.svg', 'Петля boundary review: fixed synthetic input проходит проверку формы, public surface, разрешённого направления и цикла, затем выдаёт только proposal на review карты. Отдельной подписью указано: нет чтения файлов, CI, сети, trace или production.', 'Схема показывает порядок учебной проверки. Она не является pipeline, отчётом CI или следом фактического импорта.'),
h2('Не путать API с разрешением на любой сценарий'),
p('Самая неудобная часть review — допустимый API-вызов может быть неверным по сути. `checkout → catalog.api` проходит boundary rule, но API всё ещё может отдавать слишком много данных, скрывать медленную операцию или использовать чужой инвариант. Архитектурная проверка отвечает на узкий вопрос: разрешено ли этому consumer обращаться к этой заявленной поверхности. Она не отвечает на продуктовый вопрос, корректность данных или performance. Это разделение экономит время: rule не раздувают до имитации полного design review, а критические свойства проверяют отдельными доказательствами.'),
p('Обратная ошибка — считать, что возникший internal import обязательно доказывает плохой модуль. Иногда consumer обнаружил настоящую недостающую возможность. Но это повод спросить владельца, не повод объявить formatter стабильным контрактом. Хорошее действие сохраняет выбор обратимым: ввести временный adapter с именем потребности, перевести один consumer, затем решить судьбу API при известном сценарии. Плохое действие — экспортировать весь internal namespace или добавить shared package без owner и срока пересмотра.'),
h2('Fixed fixture как защита от самообмана'),
p('Функция `inspectSyntheticModuleBoundaries()` намеренно принимает только versioned fixed карту и список reference records в памяти. Она отвергает неизвестный `repositoryUrl`, изменённый module map и поле `path` в ссылке. Эти случаи не «плохие данные реального проекта»; это охрана границы примера. Если fixture принял бы URL или путь, читатель мог бы решить, что он смотрит на исходники. Вместо этого функция отказывается от такого входа и оставляет `filesystem=not-read`, `network=not-used`, `projectScan=not-performed`.'),
code(fixtureCommand),
p('Набор assertions покрывает и обычную ветку, и отрицательные случаи: non-synthetic input, лишнее network-like поле, другой map version, подмена API, internal surface, forbidden direction, unknown module, duplicate link, malformed record, self-dependency и directed cycle. Вход обязан содержать точный набор полей и плотный список ссылок: пустой слот не должен пройти потому, что `every()` его пропустил. Перед восстановлением snapshot снова сверяется с versioned fixed картой; report с `accepted=true`, но неполным snapshot отклоняется. Восстановление возвращает копию договора, не делает rollback исходников, не меняет CI и не управляет релизом. Такой fixture пригоден для сопровождения текста: он проверяет, что автор не противоречит собственной карте. Он не заменяет source analysis.'),
h2('Маршрут разбора в настоящем review'),
ol([
'<strong>Остановите спор на точной ссылке.</strong> Выпишите consumer, owner, surface и цель вызова; «модули связаны» не является проверяемым описанием.',
'<strong>Отделите факт от модели.</strong> Реальный import, если он обнаружен разрешённым инструментом, храните как evidence отдельно. Synthetic fixture не приклеивайте к нему как доказательство.',
'<strong>Сверьте public surface.</strong> Если вызывается internal, выберите: локальная операция у owner-а, узкий API или изменение ownership. Не экспортируйте детали автоматически.',
'<strong>Сверьте направление и цикл.</strong> Добавьте новую стрелку в карту до merge. Обратная связь требует объяснить orchestration, а не только новый интерфейс.',
'<strong>Выберите наименьшее обратимое действие.</strong> Переведите одного consumer-а, сохраните путь назад и критерий удаления временного адаптера.',
'<strong>Запишите решение.</strong> В decision record оставьте владельца, scenario, API, allowed direction, исключения, дату пересмотра и evidence, которое действительно было получено.',
]),
h2('Граница проверки и ответственность reviewer-а'),
p('Reviewer не обязан предсказать всю будущую архитектуру. Его обязанность — не подписывать неясную связь как «просто import». Если аргумент строится на реальной трассе, сборке, file scan или production-эффекте, нужно проверить их в соответствующей разрешённой системе и назвать источник. Нельзя заменить этот труд скриншотом диаграммы или PASS fixture. В обратную сторону тоже важно: отсутствие файлового скана в учебном пакете не доказывает отсутствия нарушения. Это значит ровно то, что написано — такой scan не выполнялся.'),
p('Решение об исключении должно быть коротким и обратимым. Записать, для какого сценария оно нужно, кто отвечает за обе стороны, что временно доступно и когда карта будет пересмотрена. Если эти поля невозможно заполнить, исключение ещё не готово. Удобнее перенести небольшую операцию или добавить явно именованный адаптер, чем навсегда открыть внутренности. Прагматичность здесь — не в жёсткости правила, а в стоимости следующего изменения.'),
h2('Ограничения и следующий шаг'),
p('Synthetic кейс не доказывает архитектуру, чистоту кода, реальный список imports, результаты тестов, CI, трассы, performance, безопасность или delivery. Он не читает project files, package manager, environment, network, HTTP, trace, clock или production. Ссылки на Java SE 17, Spring Modulith и ArchUnit дают исторический и инструментальный контекст: они не назначают boundaries вашего домена. Реальная проверка должна пользоваться точечным анализом в пределах полномочий команды и оставлять evidence рядом с решением.'),
p('Следующий шаг — сделать один boundary review не как обсуждение названий папок, а как запись из пяти колонок: ссылка, scenario, public surface, direction, removal plan. После этого можно выбрать реальный анализатор и сформулировать его rule на языке проекта. Если rule не умеет отличить `api` от `internal`, сначала улучшайте карту. Инструмент должен проверять уже понятную политику, а не изобретать её по import-ам.'),
h2('Историческая граница февраля 2024'),
p('На феврале 2024 доступны Java SE 17, Spring Modulith 1.1.0 и ArchUnit 1.1.0. Этого достаточно, чтобы говорить о явных поверхностях и структурной проверке, но недостаточно для заявлений о runtime behaviour несуществующей системы. Автор M7 в этом разборе не выдает synthetic sample за production-кейс: он показывает цену ссылки, границу модели и следующий воспроизводимый вопрос для review.'),
]);
export const revisions = [practice, mechanism, field].map(({ proseLength, ...item }) => item);
function verifyFixture() {
const report = runModularMonolithFixture();
const failed = Object.entries(report.assertions).filter(([, value]) => value !== true).map(([key]) => key);
if (failed.length) {
process.stderr.write('FAIL fixture: ' + failed.join(', ') + '\n');
process.exitCode = 1;
return;
}
const count = Object.keys(report.assertions).length;
process.stdout.write('PASS fixture: ' + count + '/' + count + ' assertions\n');
}
if (process.argv.includes('--verify-fixture')) verifyFixture();
if (process.argv.includes('--print-revisions')) process.stdout.write(JSON.stringify(revisions) + '\n');