8 lines
20 KiB
JSON
8 lines
20 KiB
JSON
{
|
||
"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>catalog:catalog.api','checkout>payments:payments.api','payments>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}) => surface !== to + '.api' ? 'non-public-surface' : allowed.has(from + '>' + to + ':' + surface) ? 'allowed' : 'forbidden-direction'; console.table(links.map(link => ({...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>"
|
||
}
|