Files

8 lines
20 KiB
JSON
Raw Permalink 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>Ниже — учебный сценарий, а не отчёт о конкретной production-системе. В монолите код оформления заказа берёт внутренний formatter каталога, потому что нужный API ещё не готов. Затем платежи вызывают helper оформления заказа, а общий пакет получает третий набор исключений. Сборка проходит, а папки по-прежнему выглядят как отдельные домены.</p>\n<p>Симптом появляется позже: изменение внутреннего типа каталога требует искать потребителей в заказах и платежах. Ревьюер не может ответить, какой вызов разрешён, а какой временно терпят ради срока. Цена ошибки — скрытый контракт: он расширяет область изменения, усложняет откат и делает возможное выделение сервиса дороже.</p>\n<p>Практический тезис такой: модульный монолит держится не на глубине каталогов, а на явном договоре. Для каждой межмодульной связи нужно назвать потребителя, владельца смысла и поверхность доступа: <code>consumer → owner.publicApi</code>. Отдельно фиксируют разрешённые направления. Эта статья показывает модель и небольшой проверяемый fixture; он не заменяет анализатор импортов, тесты данных, нагрузку или security-проверку.</p>\n<h2>Что именно считается границей</h2>\n<p>Модуль владеет смыслом операции, связанными с ним данными и публичным входом. Публичный вход не равен каждому символу с модификатором <code>public</code>: это обещание для другого модуля. Им может быть команда, запрос, событие, порт или функция. Вход должен выражать потребность потребителя, а не раскрывать таблицу, репозиторий или внутренний formatter.</p>\n<p>Возьмём четыре условных модуля: <code>catalog</code>, <code>checkout</code>, <code>payments</code> и <code>notifications</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>Запрет на чтение внутренней реализации</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, доступ к внутренним поверхностям запрещён' loading='lazy' /><figcaption>Схема показывает заданные направления и границу public API. Она не построена по исходникам и не подтверждает состояние production-кода.</figcaption></figure>\n<h2>Механизм: публичная поверхность и направленный граф</h2>\n<p>Сначала составляют карту модулей: имя, владелец, публичная поверхность, внутренние пакеты и разрешённые исходящие связи. Затем фактические импорты или вызовы сопоставляют с этой картой. Проверка каждой ссылки должна ответить на четыре вопроса: существуют ли оба модуля, совпадает ли поверхность с опубликованной, разрешено ли направление и не образует ли оно цикл.</p>\n<p>Направление нужно хранить отдельно от физического пути. В одном стеке внутренний пакет частично закрывает компилятор, в другом остаются соглашение и архитектурный тест. Видимость помогает, но не отвечает на вопрос владения. Карта нужна именно для этого: она делает исключение обсуждаемым, а не случайным импортом.</p>\n<pre><code>node --input-type=module -e \"const allowed=new Set(['checkout&gt;catalog:catalog.api','checkout&gt;payments:payments.api','payments&gt;notifications:notifications.api']); const links=[{from:'checkout',to:'catalog',surface:'catalog.api'},{from:'catalog',to:'checkout',surface:'checkout.api'},{from:'checkout',to:'catalog',surface:'catalog.internal'}]; const check=({from,to,surface}) =&gt; surface !== to + '.api' ? 'non-public-surface' : allowed.has(from + '&gt;' + to + ':' + surface) ? 'allowed' : 'forbidden-direction'; console.table(links.map(link =&gt; ({...link,result:check(link)})));\"</code></pre>\n<p>Сохраните команду во временный терминал без изменений и выполните её через Node.js 18 или новее. Ожидаемый результат для трёх строк: <code>allowed</code>, затем <code>forbidden-direction</code>, затем <code>non-public-surface</code>. В fixture специально нет чтения репозитория: он проверяет только заранее заданные записи. Поэтому зелёный результат доказывает корректность policy-функции для этого входа, но не отсутствие незаконных импортов в приложении.</p>\n<h2>Как обнаруживать циклы и обходы</h2>\n<p>Если карта разрешает <code>checkout → payments</code>, а затем добавляет <code>payments → checkout</code>, граф становится циклическим. Это не означает, что завтра нужно выделить микросервис. Это сигнал уточнить владельца процесса, границу инварианта и способ обмена. Часто операция переносится к владельцу, а результат публикуется событием; иногда нужен узкий порт без обратного вызова.</p>\n<p>Событие само по себе не уничтожает связь: остаются схема сообщения, политика повторов, порядок, идемпотентность и владелец данных. Если эти условия не названы, цикл просто переехал из импортов в сообщения. То же относится к общему пакету: тип, которым пользуются все, может стать не API, а обходом границы.</p>\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>Публичный вход не выражает нужную операцию либо внутренняя деталь удобнее</td><td>Сверить фактический импорт с картой 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>Проверить, можно ли сменить storage без consumer</td><td>Сузить результат до операции, DTO или события</td></tr><tr><td>Архитектурный тест зелёный, а импорт неизвестен</td><td>Проверена только модель в памяти</td><td>Сопоставить источник фактических ссылок с policy</td><td>Не называть fixture аудитом; подключить реальный анализ</td></tr></tbody></table>\n<h2>Как закрепить правило в конкретном стеке</h2>\n<p>В Java Platform Module System модуль объявляет <code>requires</code> для зависимостей и <code>exports</code> для пакетов, доступных извне. Это физический механизм языка, но он не заменяет решение о владельце операции. В Spring Modulith логические модули выводятся из структуры пакетов; для них можно проверять отсутствие циклов, доступ только через API-пакеты и явно разрешённые зависимости.</p>\n<pre><code>var modules = ApplicationModules.of(Application.class);\nmodules.verify();</code></pre>\n<p>Этот Java-фрагмент воспроизводим только в проекте, где подключён Spring Modulith и существует класс приложения <code>Application</code>; он не является самостоятельной командой для Node-проекта. В проекте на другом языке понадобится соответствующий анализатор импортов или архитектурный тест. Общий критерий один: проверка должна видеть фактические зависимости, а не только красивую схему.</p>\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>Снимите baseline.</strong> Зафиксируйте список фактических импортов и хотя бы один отрицательный случай: internal-протечку, неизвестный модуль или цикл.</li><li><strong>Переведите один вызов.</strong> Сначала добавьте API и тест, затем уберите старый импорт. Оставьте обратимый путь до подтверждения потребителей.</li><li><strong>Включите проверку в review или CI.</strong> Документ без автоматического отказа быстро становится устным соглашением. Сообщение об ошибке должно назвать consumer, owner, surface и допустимый маршрут.</li></ol>\n<h2>Ограничения и отрицательный путь</h2>\n<p>Граф зависимостей отвечает за структуру, но не за транзакции, задержку, права доступа, размер payload, версионирование событий, миграции схемы и качество данных. Разрешённая стрелка может вести к медленной операции, а временно запрещённая — стать допустимой после смены владельца. Поэтому архитектурный PASS не заменяет нагрузочный, security, контрактный и интеграционный тест.</p>\n<p>Не переносите правило <code>catalog.internal</code> в универсальную истину для любого фреймворка. В Java package-private, JPMS exports и Spring Modulith дают разные уровни защиты; в JavaScript или PHP часть границ может остаться договором, статическим анализом и review. Открытый модуль в Spring Modulith также меняет правила доступа и может быть переходным решением для legacy-кода, а не целевым состоянием.</p>\n<p>Отрицательный путь должен быть наблюдаемым: неизвестный модуль, пустая поверхность, дубликат связи и цикл возвращают понятную ошибку. Если тест пропускает произвольный путь к файлу или проверяет только наличие слов <code>api</code>, он защищает соглашение о названии, но не архитектурную границу.</p>\n<h2>Критерий готовности</h2>\n<p>Граница готова, если для каждого межмодульного вызова можно показать сценарий потребителя, владельца смысла, точную публичную поверхность и разрешённое направление. Автоматическая проверка проходит по фактическим ссылкам и отдельно сообщает об internal-протечке, неизвестном модуле и цикле. Учебный fixture из статьи проверяет только форму policy и обязан так себя называть.</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> — нормативное описание модулей, <code>requires</code>, <code>exports</code> и доступа к пакетам. Ограничение: применимо к JPMS и не описывает правила произвольного монолита.</li><li><a href='https://docs.spring.io/spring-modulith/reference/fundamentals.html' target='_blank' rel='noopener noreferrer'>Spring Modulith: Fundamentals</a> — официальное описание API модуля, внутренних пакетов, named interfaces и allowed dependencies. Ограничение: относится к Spring Boot/Spring Modulith.</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-only доступа и разрешённых межмодульных зависимостей. Ограничение: вызов <code>verify()</code> проверяет модель, которую библиотека смогла построить.</li><li><a href='https://www.archunit.org/userguide/html/000_Index.html' target='_blank' rel='noopener noreferrer'>ArchUnit User Guide</a> — официальный guide для анализа bytecode и архитектурных правил пакетов, слоёв и циклов. Ограничение: результат зависит от набора class-файлов и правил импорта.</li></ul>"
}