{ "index": 140, "slug": "editorial-2024-02-mechanism-modular-monolith", "title": "Модульный монолит: как удержать границы до распила на сервисы", "excerpt": "Папки по доменам не защищают код от скрытых связей. Разбираем публичную поверхность модуля, карту разрешённых направлений, циклы и проверку, которую можно встроить в review.", "contentHtml": "
Ниже — учебный сценарий, а не отчёт о конкретной production-системе. В монолите код оформления заказа берёт внутренний formatter каталога, потому что нужный API ещё не готов. Затем платежи вызывают helper оформления заказа, а общий пакет получает третий набор исключений. Сборка проходит, а папки по-прежнему выглядят как отдельные домены.
\nСимптом появляется позже: изменение внутреннего типа каталога требует искать потребителей в заказах и платежах. Ревьюер не может ответить, какой вызов разрешён, а какой временно терпят ради срока. Цена ошибки — скрытый контракт: он расширяет область изменения, усложняет откат и делает возможное выделение сервиса дороже.
\nПрактический тезис такой: модульный монолит держится не на глубине каталогов, а на явном договоре. Для каждой межмодульной связи нужно назвать потребителя, владельца смысла и поверхность доступа: consumer → owner.publicApi. Отдельно фиксируют разрешённые направления. Эта статья показывает модель и небольшой проверяемый fixture; он не заменяет анализатор импортов, тесты данных, нагрузку или security-проверку.
Модуль владеет смыслом операции, связанными с ним данными и публичным входом. Публичный вход не равен каждому символу с модификатором public: это обещание для другого модуля. Им может быть команда, запрос, событие, порт или функция. Вход должен выражать потребность потребителя, а не раскрывать таблицу, репозиторий или внутренний formatter.
Возьмём четыре условных модуля: catalog, checkout, payments и notifications. Разрешены только checkout → catalog.api, checkout → payments.api и payments → notifications.api. Это форма архитектурной политики, а не утверждение о структуре какого-либо проекта.
| Вопрос | Пример ответа | Что защищаем |
|---|---|---|
| Кто вызывает? | checkout | Сценарий потребителя и его ответственность |
| Кто владеет смыслом? | catalog | Право менять контракт и правила данных |
| Через что вызывают? | catalog.api | Запрет на чтение внутренней реализации |
| Разрешено ли направление? | checkout → catalog | Контроль обратных связей и циклов |
Запись «checkout зависит от catalog» слишком широка: она допускает запрос карточки, чтение репозитория и вызов приватного форматтера. Запись checkout → catalog.api задаёт проверяемый объект. Если потребителю нужен новый смысл, владелец решает, добавить ли узкую операцию, событие или оставить работу внутри своего модуля.
Сначала составляют карту модулей: имя, владелец, публичная поверхность, внутренние пакеты и разрешённые исходящие связи. Затем фактические импорты или вызовы сопоставляют с этой картой. Проверка каждой ссылки должна ответить на четыре вопроса: существуют ли оба модуля, совпадает ли поверхность с опубликованной, разрешено ли направление и не образует ли оно цикл.
\nНаправление нужно хранить отдельно от физического пути. В одном стеке внутренний пакет частично закрывает компилятор, в другом остаются соглашение и архитектурный тест. Видимость помогает, но не отвечает на вопрос владения. Карта нужна именно для этого: она делает исключение обсуждаемым, а не случайным импортом.
\nnode --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)})));\"\nСохраните команду во временный терминал без изменений и выполните её через Node.js 18 или новее. Ожидаемый результат для трёх строк: allowed, затем forbidden-direction, затем non-public-surface. В fixture специально нет чтения репозитория: он проверяет только заранее заданные записи. Поэтому зелёный результат доказывает корректность policy-функции для этого входа, но не отсутствие незаконных импортов в приложении.
Если карта разрешает checkout → payments, а затем добавляет payments → checkout, граф становится циклическим. Это не означает, что завтра нужно выделить микросервис. Это сигнал уточнить владельца процесса, границу инварианта и способ обмена. Часто операция переносится к владельцу, а результат публикуется событием; иногда нужен узкий порт без обратного вызова.
Событие само по себе не уничтожает связь: остаются схема сообщения, политика повторов, порядок, идемпотентность и владелец данных. Если эти условия не названы, цикл просто переехал из импортов в сообщения. То же относится к общему пакету: тип, которым пользуются все, может стать не API, а обходом границы.
\n| Симптом | Вероятная причина | Проверка | Действие |
|---|---|---|---|
Импортируется catalog.internal | Публичный вход не выражает нужную операцию либо внутренняя деталь удобнее | Сверить фактический импорт с картой API | Вернуть операцию владельцу или добавить узкий контракт |
| Появилась обратная стрелка | Не определён владелец процесса | Построить граф и найти цикл | Выбрать orchestration, событие или перенос операции |
Все модули импортируют common | Общий пакет скрывает владение типами | Назвать владельца каждого типа и его изменений | Разделить контракты или вернуть тип владельцу |
| API повторяет таблицу владельца | Публичная поверхность раскрывает хранение | Проверить, можно ли сменить storage без consumer | Сузить результат до операции, DTO или события |
| Архитектурный тест зелёный, а импорт неизвестен | Проверена только модель в памяти | Сопоставить источник фактических ссылок с policy | Не называть fixture аудитом; подключить реальный анализ |
В Java Platform Module System модуль объявляет requires для зависимостей и exports для пакетов, доступных извне. Это физический механизм языка, но он не заменяет решение о владельце операции. В Spring Modulith логические модули выводятся из структуры пакетов; для них можно проверять отсутствие циклов, доступ только через API-пакеты и явно разрешённые зависимости.
var modules = ApplicationModules.of(Application.class);\nmodules.verify();\nЭтот Java-фрагмент воспроизводим только в проекте, где подключён Spring Modulith и существует класс приложения Application; он не является самостоятельной командой для Node-проекта. В проекте на другом языке понадобится соответствующий анализатор импортов или архитектурный тест. Общий критерий один: проверка должна видеть фактические зависимости, а не только красивую схему.
from → to.surface дополните запрещённой обратной связью и владельцем исключения.Граф зависимостей отвечает за структуру, но не за транзакции, задержку, права доступа, размер payload, версионирование событий, миграции схемы и качество данных. Разрешённая стрелка может вести к медленной операции, а временно запрещённая — стать допустимой после смены владельца. Поэтому архитектурный PASS не заменяет нагрузочный, security, контрактный и интеграционный тест.
\nНе переносите правило catalog.internal в универсальную истину для любого фреймворка. В Java package-private, JPMS exports и Spring Modulith дают разные уровни защиты; в JavaScript или PHP часть границ может остаться договором, статическим анализом и review. Открытый модуль в Spring Modulith также меняет правила доступа и может быть переходным решением для legacy-кода, а не целевым состоянием.
Отрицательный путь должен быть наблюдаемым: неизвестный модуль, пустая поверхность, дубликат связи и цикл возвращают понятную ошибку. Если тест пропускает произвольный путь к файлу или проверяет только наличие слов api, он защищает соглашение о названии, но не архитектурную границу.
Граница готова, если для каждого межмодульного вызова можно показать сценарий потребителя, владельца смысла, точную публичную поверхность и разрешённое направление. Автоматическая проверка проходит по фактическим ссылкам и отдельно сообщает об internal-протечке, неизвестном модуле и цикле. Учебный fixture из статьи проверяет только форму policy и обязан так себя называть.
\nЕсли хотя бы один ответ отсутствует, не расширяйте API и не начинайте распил. Сначала уточните владельца или верните операцию локально. Ценность модульного монолита именно в этом: внутренность можно менять независимо от потребителей, а границу — проверять до дорогостоящего распределения системы.
\nrequires, exports и доступа к пакетам. Ограничение: применимо к JPMS и не описывает правила произвольного монолита.verify() проверяет модель, которую библиотека смогла построить.