8 lines
20 KiB
JSON
8 lines
20 KiB
JSON
{
|
||
"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<ProductQuote>;\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->catalog:catalog.api',\n 'checkout->payments:payments.api',\n 'payments->notifications:notifications.api',\n]);\n\nfunction check(reference) {\n if (reference.surface.endsWith('.internal')) return 'reject: internal';\n const key = `${reference.from}->${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>"
|
||
}
|