Files
progcode/editorial/agent-rewrites/141.json
T
huncode 2d914b543f
Build and deploy / deploy (push) Failing after 15s
Publish rewritten technical article archive
2026-08-02 22:19:34 +03:00

8 lines
20 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"index": 141,
"slug": "editorial-2024-02-practice-modular-monolith",
"title": "Модульный монолит: как сделать границы зависимостей проверяемыми",
"excerpt": "Папки не защищают модуль от чужих импортов. Разбираем публичную поверхность, разрешённые направления, цикл зависимостей и короткую проверку, которую можно встроить в тесты.",
"contentHtml": "<p>Симптом обычно выглядит безобидно: разработчик в модуле <code>checkout</code> добавляет импорт из <code>catalog/internal</code>, потому что нужный helper уже готов. Сборка проходит. Через несколько недель изменение внутреннего parser-а каталога требует искать потребителей в оплате и заказах. Команда больше не знает, какой код можно менять локально. Цена ошибки — скрытые регрессии, длинный review и рефакторинг, который нельзя выполнить по частям.</p>\n<p>Папка с названием домена не создаёт границу. Она помогает найти файлы, но не определяет право на импорт. Граница появляется только тогда, когда команда явно задаёт публичную поверхность модуля, владельца этой поверхности и допустимые направления зависимостей. После этого правило можно проверить на коде и отдельно проверить отрицательный путь: внутренний импорт и цикл должны ломать проверку.</p>\n<h2>Тезис: модуль — это контракт, а не каталог</h2>\n<p>У модуля есть две стороны. Первая — то, что он публикует: команда, запрос, тип или событие с понятным смыслом. Вторая — то, от чего он зависит. Если описана только первая сторона, API быстро превращается в транзит к чужим деталям. Если описана только вторая, команда видит список импортов, но не понимает, какие вызовы считаются устойчивыми.</p>\n<p>Для каждой связи полезно хранить тройку <code>source → target.surface</code>. Например, <code>checkout → catalog.api</code> означает, что checkout использует именно опубликованную поверхность каталога. Запись <code>checkout → catalog</code> слишком широка: она не отличает API от repository, внутреннего mapper-а и класса, который случайно объявили <code>public</code>.</p>\n<p>Учебный пример ниже не описывает реальный продукт и не сообщает о результатах в production. В нём четыре модуля: <code>catalog</code> владеет товарами, <code>checkout</code> собирает заказ, <code>payments</code> проводит оплату, <code>notifications</code> отправляет уведомления. Разрешены только три стрелки: checkout к API каталога, checkout к API платежей и payments к API уведомлений.</p>\n<div class=\"table-scroll\"><table><caption>Разрешённые связи в учебной модели</caption><thead><tr><th scope=\"col\">Откуда</th><th scope=\"col\">Куда</th><th scope=\"col\">Решение</th><th scope=\"col\">Что это защищает</th></tr></thead><tbody><tr><td>checkout</td><td>catalog.api</td><td>разрешено</td><td>заказ получает товар через контракт каталога</td></tr><tr><td>checkout</td><td>payments.api</td><td>разрешено</td><td>заказ не знает внутреннюю реализацию оплаты</td></tr><tr><td>payments</td><td>notifications.api</td><td>разрешено</td><td>уведомление вызывается через отдельную поверхность</td></tr><tr><td>любой модуль</td><td>чужой <code>*.internal</code></td><td>запрещено</td><td>детали реализации остаются у владельца</td></tr><tr><td>catalog</td><td>payments.api</td><td>запрещено в этой модели</td><td>новая стрелка требует сценария и владельца</td></tr></tbody></table></div>\n<figure><img src=\"/assets/editorial/2024/modular-monolith-2024-dependency-matrix.svg\" alt=\"Матрица зависимостей учебного модульного монолита с разрешёнными направлениями между catalog, checkout, payments и notifications\" loading=\"lazy\" /><figcaption>Учебная матрица показывает направление связи и поверхность API. Она не получена сканированием репозитория и не доказывает устройство production-системы.</figcaption></figure>\n<h2>Механизм границы</h2>\n<p>Публичная поверхность должна выражать потребность потребителя, а не повторять внутреннюю структуру владельца. Если checkout нужен итоговый товар для расчёта цены, ему не нужен <code>ProductEntity</code>, JPA repository и набор внутренних преобразователей. Ему нужен узкий порт, например <code>CatalogReader</code>. Владелец может заменить хранение и parser, пока сохраняет смысл этого порта.</p>\n<pre><code>/* Учебный пример. Это контракт модуля catalog, а не готовая production-модель. */\nexport type ProductQuote = {\n sku: string;\n price: number;\n currency: string;\n};\n\nexport interface CatalogReader {\n quote(sku: string): Promise&lt;ProductQuote&gt;;\n}\n\n// checkout импортирует только public surface:\nimport type { CatalogReader } from '../catalog/public';\n\n// Такой импорт нарушает границу:\nimport { ProductParser } from '../catalog/internal/ProductParser';</code></pre>\n<p>Само слово <code>public</code> не решает архитектурную задачу. В обычном монолите разработчик часто может технически импортировать любой доступный символ. Поэтому правило состоит из двух уровней. Язык и модульная система задают физическую видимость, а архитектурный тест задаёт смысловое разрешение. Нельзя подменять одно другим.</p>\n<p>Направления должны образовывать ориентированный ацикличный граф. Цикл <code>checkout → payments → checkout</code> не всегда означает, что предметная модель неверна. Он означает, что текущий порядок владения не объяснён. Пока цикл существует, изменение одного модуля требует держать в голове другой, а изолированный тест и поэтапная миграция становятся дороже.</p>\n<p>Разорвать цикл можно несколькими способами. Сначала назовите операцию и её владельца. Если payments сообщает checkout о результате, событие может идти в одну сторону. Если оба модуля используют одинаковое правило, возможно, нужен небольшой тип без поведения. Если один модуль просит внутреннюю деталь другого, сначала спроектируйте порт по потребности. Пакет <code>common</code> не является решением сам по себе: без владельца он превращается в новую общую свалку.</p>\n<h2>Симптом → причина → проверка → действие</h2>\n<div class=\"table-scroll\"><table><caption>Диагностика нарушенной модульной границы</caption><thead><tr><th scope=\"col\">Симптом</th><th scope=\"col\">Причина</th><th scope=\"col\">Проверка</th><th scope=\"col\">Действие</th></tr></thead><tbody><tr><td>Изменение внутреннего класса требует искать чужие вызовы</td><td>Потребитель импортирует деталь вместо контракта</td><td>Выписать <code>from</code>, <code>to</code> и <code>surface</code> для импорта</td><td>Сформировать узкий API и перевести один вызов</td></tr><tr><td>Два модуля ссылаются друг на друга</td><td>Не назван владелец операции или сообщения</td><td>Построить граф прямых зависимостей и найти цикл</td><td>Выбрать владельца, событие или односторонний adapter</td></tr><tr><td>Все новые вызовы идут через <code>shared</code></td><td>Временный helper получил неограниченную роль</td><td>Проверить владельца, потребителей и срок исключения</td><td>Оставить тип локальным либо вернуть поведение владельцу</td></tr><tr><td>Тест границ зелёный, но API отдаёт слишком много</td><td>Структурное правило приняли за проверку бизнес-контракта</td><td>Сопоставить данные API с конкретным сценарием потребителя</td><td>Уточнить DTO, права, инварианты и отдельные тесты</td></tr><tr><td>Новая стрелка добавлена ради прохождения сборки</td><td>Правило не требует обоснования связи</td><td>Спросить сценарий, владельца, альтернативу и цену связи</td><td>Оформить исключение с датой пересмотра или не добавлять импорт</td></tr></tbody></table></div>\n<h2>Проверка должна ловить отрицательный путь</h2>\n<p>Минимальная проверка отвечает на четыре вопроса: существует ли названный модуль, существует ли его поверхность, разрешено ли направление и нет ли цикла. Отдельно проверяется запрет на <code>internal</code>. Если тест проверяет только разрешённые примеры, его можно случайно сломать так, что он начнёт принимать любой импорт.</p>\n<pre><code>// Учебный псевдокод проверки политики.\nconst allowed = new Set([\n 'checkout-&gt;catalog:catalog.api',\n 'checkout-&gt;payments:payments.api',\n 'payments-&gt;notifications:notifications.api',\n]);\n\nfunction check(reference) {\n if (reference.surface.endsWith('.internal')) return 'reject: internal';\n const key = `${reference.from}-&gt;${reference.to}:${reference.surface}`;\n return allowed.has(key) ? 'accept' : 'reject: direction';\n}\n\ncheck({ from: 'checkout', to: 'catalog', surface: 'catalog.api' });\n// accept\n\ncheck({ from: 'checkout', to: 'catalog', surface: 'catalog.internal' });\n// reject: internal</code></pre>\n<p>Этот фрагмент проверяет только заранее переданную политику. Он не читает файлы, не строит AST, не сканирует package graph и не доказывает отсутствие нарушений в конкретном репозитории. Для реального проекта нужен инструмент, который видит фактические зависимости исходного кода, а затем тот же инструмент должен быть подключён к обычной проверке проекта. Учебный псевдокод помогает проверить форму правила, но не заменяет такой анализ.</p>\n<h2>Порядок внедрения</h2>\n<ol><li><strong>Выберите один болезненный стык.</strong> Возьмите участок, где изменение часто затрагивает чужой internal-код или где уже виден цикл. Не начинайте с переименования всего монолита.</li><li><strong>Назовите владельца.</strong> Запишите, какой модуль отвечает за данные, инварианты и смысл операции. Потребитель не становится владельцем только потому, что первым вызвал функцию.</li><li><strong>Опишите поверхность.</strong> Дайте API имя по потребности: <code>CatalogReader</code>, а не <code>CatalogInternals</code>. Перечислите, что остаётся закрытым.</li><li><strong>Зафиксируйте тройку связи.</strong> Для каждого межмодульного вызова укажите <code>source → target.surface</code>. Запрещённые направления запишите явно.</li><li><strong>Добавьте положительный и отрицательный тест.</strong> Разрешённая связь должна проходить. Internal-импорт, неизвестная поверхность и цикл должны получать понятный отказ.</li><li><strong>Переведите один вызов.</strong> Сначала замените один импорт на API. После этого проверьте, что старый internal-символ больше не нужен потребителю.</li><li><strong>Оформите исключение отдельно.</strong> Если временный обход неизбежен, укажите сценарий, владельца, срок пересмотра и способ удаления. Комментарий без проверки не создаёт границу.</li></ol>\n<h2>Ограничения</h2>\n<p>Граф зависимостей не отвечает за качество API. Разрешённый вызов может быть медленным, возвращать лишние данные или нарушать бизнес-инвариант. Он также не решает транзакции, права доступа, владение таблицами, доставку событий и совместимость схем. Эти свойства требуют отдельных контрактов и тестов.</p>\n<p>Физическая модульность зависит от стека. Java Platform Module System умеет ограничивать экспорт пакетов, но многие приложения живут в обычном classpath. Spring Modulith предлагает проверку application modules, API-пакетов, циклов и явно разрешённых зависимостей, но это решение для Spring-стека. В TypeScript или другом языке понадобится другой анализатор. Переносить аннотации без переноса семантики бесполезно.</p>\n<p>Не всякая связь должна исчезнуть. Две области могут честно зависеть от общего справочного типа или от события. Важно назвать форму связи и её владельца. Если новая стрелка появляется только потому, что импорт проще, это сигнал остановиться. Если она нужна предметному сценарию, она должна попасть в карту и пройти тот же отрицательный путь.</p>\n<h2>Критерий готовности</h2>\n<p>Участок готов, когда команда может показать карту с владельцами и поверхностями, а проверка даёт четыре наблюдаемых результата: разрешённая связь проходит; импорт чужого <code>internal</code> отвергается; неизвестная стрелка отвергается; цикл получает отдельную ошибку. После перевода одного реального вызова потребитель больше не импортирует детали владельца. Если хотя бы один результат нельзя воспроизвести на коде, граница пока остаётся договорённостью на словах.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href=\"https://docs.oracle.com/javase/specs/jls/se17/html/jls-7.html\" target=\"_blank\" rel=\"noopener noreferrer\">Java Language Specification, Java SE 17, глава 7: Packages and Modules</a> — официальная спецификация Java о пакетах, модулях, экспортируемых пакетах и зависимостях.</li><li><a href=\"https://docs.spring.io/spring-modulith/reference/verification.html\" target=\"_blank\" rel=\"noopener noreferrer\">Spring Modulith: Verifying Application Module Structure</a> — официальная документация о проверке циклов, доступе через API-пакеты и явно разрешённых зависимостях.</li><li><a href=\"https://www.archunit.org/userguide/html/000_Index.html\" target=\"_blank\" rel=\"noopener noreferrer\">ArchUnit User Guide</a> — официальное руководство по архитектурным правилам для Java-кода; пример проверки в статье остаётся учебным и не утверждает запуск этого инструмента.</li></ul>"
}