Files
progcode/editorial/agent-rewrites/140.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
17 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": 140,
"slug": "editorial-2024-02-mechanism-modular-monolith",
"title": "Модульный монолит: как удержать границы до распила на сервисы",
"excerpt": "Папки по доменам не защищают код от скрытых связей. Разбираем публичную поверхность модуля, разрешённые направления, цикл зависимостей и проверку, которую можно встроить в обычный review.",
"contentHtml": "<p>В монолите проблема часто начинается с маленького импорта. Код оформления заказа берёт внутренний formatter каталога, потому что нужный API ещё не готов. Потом платежи вызывают helper оформления заказа, а общий пакет получает третий набор исключений. Сборка проходит. Папки по-прежнему выглядят как отдельные домены.</p>\n<p>Симптом появляется позже: изменение внутреннего типа каталога требует искать потребителей в заказах и платежах. Ревьюер не может ответить, какой вызов разрешён, а какой только терпят ради срока. Цена ошибки — скрытый контракт. Он увеличивает область каждого изменения, усложняет откат и делает будущий перенос модуля дороже.</p>\n<p>Тезис простой: модульный монолит держится не на глубине каталогов, а на явном договоре. Для каждой связи нужно назвать потребителя, владельца и поверхность доступа: <code>consumer → owner.publicApi</code>. Отдельно нужно перечислить разрешённые направления. Тогда правило можно обсуждать по конкретному вызову, а не по впечатлению от дерева файлов.</p>\n<h2>Что именно считается границей</h2>\n<p>Модуль владеет смыслом операции, своими данными и публичным входом. Публичный вход не равен каждому символу с модификатором <code>public</code>. Это обещание для другого модуля. Им может быть команда, запрос, событие, порт или функция. Вход должен выражать потребность, а не раскрывать внутреннее хранение.</p>\n<p>В учебной модели есть четыре модуля: <code>catalog</code>, <code>checkout</code>, <code>payments</code> и <code>notifications</code>. У каждого есть поверхность <code>*.api</code> и внутренняя часть <code>*.internal</code>. Разрешены только три связи: <code>checkout → catalog.api</code>, <code>checkout → payments.api</code> и <code>payments → notifications.api</code>. Это пример формы правила, а не описание реальной системы.</p>\n<table><caption>Граница читается по четырём вопросам</caption><thead><tr><th>Вопрос</th><th>Пример ответа</th><th>Зачем он нужен</th></tr></thead><tbody><tr><td>Кто вызывает?</td><td><code>checkout</code></td><td>Фиксирует потребителя и его сценарий</td></tr><tr><td>Кто владеет смыслом?</td><td><code>catalog</code></td><td>Назначает ответственность за изменение контракта</td></tr><tr><td>Через что вызывают?</td><td><code>catalog.api</code></td><td>Не даёт подменить API внутренним типом</td></tr><tr><td>Разрешено ли направление?</td><td><code>checkout → catalog</code></td><td>Останавливает случайные обратные связи</td></tr></tbody></table>\n<p>Одна стрелка без поверхности слишком широка. Запись «checkout зависит от catalog» допускает и запрос карточки, и чтение репозитория, и вызов приватного форматтера. Запись <code>checkout → catalog.api</code> задаёт проверяемый объект. Если потребителю нужен новый смысл, владелец решает, добавлять ли узкий метод, событие или оставить операцию внутри своего модуля.</p>\n<figure><img src='/assets/editorial/2024/modular-monolith-2024-allowed-directions.svg' alt='Схема разрешённых направлений модульного монолита: checkout вызывает catalog.api и payments.api, payments вызывает notifications.api, обращение к catalog.internal запрещено' loading='lazy' /><figcaption>Учебная схема показывает направление и поверхность связи. Она не является результатом сканирования исходников и не описывает production-систему.</figcaption></figure>\n<h2>Механизм: поверхность плюс направленный граф</h2>\n<p>Сначала команда описывает карту модулей. Для каждого модуля она записывает имя, публичную поверхность, внутренние пакеты и владельца. Затем добавляет разрешённые рёбра. Проверка каждой ссылки отвечает на четыре вопроса: существует ли источник, существует ли получатель, совпадает ли поверхность с опубликованной и есть ли такое направление в карте.</p>\n<p>Направление нужно хранить отдельно от физического пути. В одном языке internal-пакет можно закрыть средствами компилятора, в другом останется только соглашение и архитектурный тест. Оба слоя полезны. Видимость защищает от части ошибочных обращений, а карта объясняет, почему разрешён сам маршрут.</p>\n<pre><code>const allowed = new Set(['checkout&gt;catalog:catalog.api', 'checkout&gt;payments:payments.api', 'payments&gt;notifications:notifications.api']); function check(link) { if (link.surface !== link.to + '.api') return 'non-public surface'; return allowed.has(link.from + '&gt;' + link.to + ':' + link.surface) ? 'allowed' : 'forbidden direction'; }</code></pre>\n<p>Код выше — учебный пример проверки заранее описанной карты. Он не читает репозиторий, не строит граф импортов и не доказывает отсутствие нарушений в приложении. В настоящем проекте анализатор должен получить фактические ссылки из подходящего инструмента языка, сопоставить их с картой и сохранить результат проверки. Если такого анализа пока нет, честный результат — «карта описана, фактические импорты не проверены».</p>\n<p>Цикл проверяют на том же графе. Если карта разрешает <code>checkout → payments</code>, а затем добавляет <code>payments → checkout</code>, две области начинают знать друг о друге. Цикл не означает, что нужно немедленно выделить микросервис. Он означает, что не назван владелец процесса. Сначала уточняют orchestration, границу инварианта и направление обмена. Иногда помогает событие. Иногда — перенос операции к владельцу. Иногда — узкий контракт без обратного вызова.</p>\n<h2>Симптом → причина → проверка → действие</h2>\n<table><caption>Практическая диагностика границы</caption><thead><tr><th>Симптом</th><th>Причина</th><th>Проверка</th><th>Действие</th></tr></thead><tbody><tr><td>Потребитель импортирует <code>catalog.internal</code></td><td>API не выражает нужную операцию или деталь показалась удобнее</td><td>Сверить surface вызова со списком API и назвать сценарий</td><td>Вернуть операцию владельцу либо добавить узкий контракт</td></tr><tr><td>Появилась обратная стрелка</td><td>Не определён владелец процесса или смешаны ответственности</td><td>Построить граф и найти цикл</td><td>Выбрать orchestration, событие или перенос операции</td></tr><tr><td>Все импортируют <code>common</code></td><td>Общий пакет стал обходом границы</td><td>Проверить, кто владеет каждым типом и кто меняет его</td><td>Разделить контракты или вернуть код владельцу</td></tr><tr><td>API повторяет таблицы владельца</td><td>Публичная поверхность раскрывает реализацию</td><td>Проверить, может ли владелец изменить хранение без consumer</td><td>Сузить данные до операции, результата или события</td></tr><tr><td>Тест зелёный, но импорт неизвестен</td><td>Проверена только модель, а не исходный код</td><td>Проверить источник фактических ссылок и дату evidence</td><td>Не выдавать модель за аудит; добавить реальный анализ</td></tr></tbody></table>\n<h2>Порядок внедрения</h2>\n<ol><li><strong>Выберите один болезненный стык.</strong> Возьмите изменение, которое регулярно цепляет чужую внутренность. Не начинайте с перестройки всего монолита.</li><li><strong>Запишите потребность.</strong> Назовите consumer, ожидаемый результат и модуль-владелец. Если результат нельзя описать без внутреннего класса, граница ещё не сформулирована.</li><li><strong>Опишите поверхность.</strong> Оставьте минимальный вход: команду, запрос, событие или порт. Не публикуйте namespace целиком.</li><li><strong>Добавьте направление.</strong> Запишите <code>from → to.surface</code> и отдельно укажите запрещённую обратную связь. У каждого исключения должен быть владелец и дата пересмотра.</li><li><strong>Проверьте существующие ссылки.</strong> Используйте анализатор языка, правила сборки или архитектурный тест, который видит реальные импорты. Учебная карта сама по себе этого не делает.</li><li><strong>Переведите один вызов.</strong> Оставьте обратимый путь, проверьте отсутствие старого потребителя и только потом удаляйте внутренний доступ.</li><li><strong>Закрепите правило.</strong> Добавьте проверку в место, где она запускается вместе с изменением кода. Документ без проверки быстро становится устным соглашением.</li></ol>\n<h2>Ограничения и отрицательный путь</h2>\n<p>Граф границ не отвечает за транзакции, задержку, права доступа, размер payload, версионирование событий и качество данных. Разрешённая стрелка может вести к медленной операции. Запрещённая стрелка может стать оправданной после смены владельца. Поэтому зелёный статус архитектурной проверки не заменяет нагрузочный, security или интеграционный тест.</p>\n<p>Отрицательный путь нужно сохранять рядом с правилом. Вызов <code>catalog.internal</code> должен завершаться понятным отказом, а не молча проходить через исключение. Неизвестный модуль, дубликат связи и цикл тоже должны иметь отдельные сообщения. Если проверка пропускает пустую поверхность или принимает произвольный путь к файлу, она защищает только видимость, но не границу.</p>\n<p>Java Platform Module System даёт физический пример: именованный модуль объявляет экспортируемые пакеты и зависимости. Spring Modulith показывает похожую идею для Java/Spring: API модуля отделяется от внутренних пакетов и разрешённых зависимостей. Эти механизмы нельзя перенести в любой стек без изменений. Их полезный общий принцип уже достаточен: доступ должен быть назван, ограничен и проверяем.</p>\n<h2>Критерий готовности</h2>\n<p>Граница готова, если для каждого межмодульного вызова команда может показать четыре записи: сценарий потребителя, владельца смысла, точную публичную поверхность и разрешённое направление. Реальная проверка должна пройти по фактическим ссылкам и отдельно показать отрицательные случаи: internal-протечку, неизвестный модуль и цикл. Учебная модель может проверить только формулировку правила и обязана так себя называть.</p>\n<p>Если один из четырёх ответов отсутствует, не расширяйте API и не выделяйте сервис. Сначала уточните владельца или оставьте код локальным. Модульный монолит приносит пользу именно в этот момент: команда получает ясную границу и может менять внутренность без скрытых потребителей.</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> — официальное описание пакетов, модулей, экспортов и зависимостей.</li><li><a href='https://docs.spring.io/spring-modulith/reference/fundamentals.html' target='_blank' rel='noopener noreferrer'>Spring Modulith: Fundamentals</a> — официальная документация о module API, внутренних пакетах и разрешённых зависимостях.</li><li><a href='https://www.archunit.org/userguide/html/000_Index.html' target='_blank' rel='noopener noreferrer'>ArchUnit User Guide</a> — официальный guide для выражения архитектурных правил в тестах Java.</li></ul>"
}