{ "index": 140, "slug": "editorial-2024-02-mechanism-modular-monolith", "title": "Модульный монолит: как удержать границы до распила на сервисы", "excerpt": "Папки по доменам не защищают код от скрытых связей. Разбираем публичную поверхность модуля, карту разрешённых направлений, циклы и проверку, которую можно встроить в review.", "contentHtml": "

Ниже — учебный сценарий, а не отчёт о конкретной production-системе. В монолите код оформления заказа берёт внутренний formatter каталога, потому что нужный API ещё не готов. Затем платежи вызывают helper оформления заказа, а общий пакет получает третий набор исключений. Сборка проходит, а папки по-прежнему выглядят как отдельные домены.

\n

Симптом появляется позже: изменение внутреннего типа каталога требует искать потребителей в заказах и платежах. Ревьюер не может ответить, какой вызов разрешён, а какой временно терпят ради срока. Цена ошибки — скрытый контракт: он расширяет область изменения, усложняет откат и делает возможное выделение сервиса дороже.

\n

Практический тезис такой: модульный монолит держится не на глубине каталогов, а на явном договоре. Для каждой межмодульной связи нужно назвать потребителя, владельца смысла и поверхность доступа: consumer → owner.publicApi. Отдельно фиксируют разрешённые направления. Эта статья показывает модель и небольшой проверяемый fixture; он не заменяет анализатор импортов, тесты данных, нагрузку или security-проверку.

\n

Что именно считается границей

\n

Модуль владеет смыслом операции, связанными с ним данными и публичным входом. Публичный вход не равен каждому символу с модификатором public: это обещание для другого модуля. Им может быть команда, запрос, событие, порт или функция. Вход должен выражать потребность потребителя, а не раскрывать таблицу, репозиторий или внутренний formatter.

\n

Возьмём четыре условных модуля: catalog, checkout, payments и notifications. Разрешены только checkout → catalog.api, checkout → payments.api и payments → notifications.api. Это форма архитектурной политики, а не утверждение о структуре какого-либо проекта.

\n
Минимальная запись границы
ВопросПример ответаЧто защищаем
Кто вызывает?checkoutСценарий потребителя и его ответственность
Кто владеет смыслом?catalogПраво менять контракт и правила данных
Через что вызывают?catalog.apiЗапрет на чтение внутренней реализации
Разрешено ли направление?checkout → catalogКонтроль обратных связей и циклов
\n

Запись «checkout зависит от catalog» слишком широка: она допускает запрос карточки, чтение репозитория и вызов приватного форматтера. Запись checkout → catalog.api задаёт проверяемый объект. Если потребителю нужен новый смысл, владелец решает, добавить ли узкую операцию, событие или оставить работу внутри своего модуля.

\n
Схема учебной политики модульного монолита: checkout обращается к catalog.api и payments.api, payments обращается к notifications.api, доступ к внутренним поверхностям запрещён
Схема показывает заданные направления и границу public API. Она не построена по исходникам и не подтверждает состояние production-кода.
\n

Механизм: публичная поверхность и направленный граф

\n

Сначала составляют карту модулей: имя, владелец, публичная поверхность, внутренние пакеты и разрешённые исходящие связи. Затем фактические импорты или вызовы сопоставляют с этой картой. Проверка каждой ссылки должна ответить на четыре вопроса: существуют ли оба модуля, совпадает ли поверхность с опубликованной, разрешено ли направление и не образует ли оно цикл.

\n

Направление нужно хранить отдельно от физического пути. В одном стеке внутренний пакет частично закрывает компилятор, в другом остаются соглашение и архитектурный тест. Видимость помогает, но не отвечает на вопрос владения. Карта нужна именно для этого: она делает исключение обсуждаемым, а не случайным импортом.

\n
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)})));\"
\n

Сохраните команду во временный терминал без изменений и выполните её через Node.js 18 или новее. Ожидаемый результат для трёх строк: allowed, затем forbidden-direction, затем non-public-surface. В fixture специально нет чтения репозитория: он проверяет только заранее заданные записи. Поэтому зелёный результат доказывает корректность policy-функции для этого входа, но не отсутствие незаконных импортов в приложении.

\n

Как обнаруживать циклы и обходы

\n

Если карта разрешает checkout → payments, а затем добавляет payments → checkout, граф становится циклическим. Это не означает, что завтра нужно выделить микросервис. Это сигнал уточнить владельца процесса, границу инварианта и способ обмена. Часто операция переносится к владельцу, а результат публикуется событием; иногда нужен узкий порт без обратного вызова.

\n

Событие само по себе не уничтожает связь: остаются схема сообщения, политика повторов, порядок, идемпотентность и владелец данных. Если эти условия не названы, цикл просто переехал из импортов в сообщения. То же относится к общему пакету: тип, которым пользуются все, может стать не API, а обходом границы.

\n
Симптом → причина → проверка → действие
СимптомВероятная причинаПроверкаДействие
Импортируется catalog.internalПубличный вход не выражает нужную операцию либо внутренняя деталь удобнееСверить фактический импорт с картой APIВернуть операцию владельцу или добавить узкий контракт
Появилась обратная стрелкаНе определён владелец процессаПостроить граф и найти циклВыбрать orchestration, событие или перенос операции
Все модули импортируют commonОбщий пакет скрывает владение типамиНазвать владельца каждого типа и его измененийРазделить контракты или вернуть тип владельцу
API повторяет таблицу владельцаПубличная поверхность раскрывает хранениеПроверить, можно ли сменить storage без consumerСузить результат до операции, DTO или события
Архитектурный тест зелёный, а импорт неизвестенПроверена только модель в памятиСопоставить источник фактических ссылок с policyНе называть fixture аудитом; подключить реальный анализ
\n

Как закрепить правило в конкретном стеке

\n

В Java Platform Module System модуль объявляет requires для зависимостей и exports для пакетов, доступных извне. Это физический механизм языка, но он не заменяет решение о владельце операции. В Spring Modulith логические модули выводятся из структуры пакетов; для них можно проверять отсутствие циклов, доступ только через API-пакеты и явно разрешённые зависимости.

\n
var modules = ApplicationModules.of(Application.class);\nmodules.verify();
\n

Этот Java-фрагмент воспроизводим только в проекте, где подключён Spring Modulith и существует класс приложения Application; он не является самостоятельной командой для Node-проекта. В проекте на другом языке понадобится соответствующий анализатор импортов или архитектурный тест. Общий критерий один: проверка должна видеть фактические зависимости, а не только красивую схему.

\n

Порядок внедрения без большой переделки

\n
  1. Выберите один болезненный стык. Возьмите изменение, которое регулярно цепляет чужую внутренность. Не перестраивайте весь монолит до первого измеримого результата.
  2. Опишите потребность. Назовите consumer, ожидаемый результат, владельца смысла и данные, которые должны остаться внутри.
  3. Сузьте поверхность. Оставьте команду, запрос, событие или порт. Не публикуйте namespace только потому, что он доступен компилятору.
  4. Запишите разрешённое направление. Формат from → to.surface дополните запрещённой обратной связью и владельцем исключения.
  5. Снимите baseline. Зафиксируйте список фактических импортов и хотя бы один отрицательный случай: internal-протечку, неизвестный модуль или цикл.
  6. Переведите один вызов. Сначала добавьте API и тест, затем уберите старый импорт. Оставьте обратимый путь до подтверждения потребителей.
  7. Включите проверку в review или CI. Документ без автоматического отказа быстро становится устным соглашением. Сообщение об ошибке должно назвать consumer, owner, surface и допустимый маршрут.
\n

Ограничения и отрицательный путь

\n

Граф зависимостей отвечает за структуру, но не за транзакции, задержку, права доступа, размер payload, версионирование событий, миграции схемы и качество данных. Разрешённая стрелка может вести к медленной операции, а временно запрещённая — стать допустимой после смены владельца. Поэтому архитектурный PASS не заменяет нагрузочный, security, контрактный и интеграционный тест.

\n

Не переносите правило catalog.internal в универсальную истину для любого фреймворка. В Java package-private, JPMS exports и Spring Modulith дают разные уровни защиты; в JavaScript или PHP часть границ может остаться договором, статическим анализом и review. Открытый модуль в Spring Modulith также меняет правила доступа и может быть переходным решением для legacy-кода, а не целевым состоянием.

\n

Отрицательный путь должен быть наблюдаемым: неизвестный модуль, пустая поверхность, дубликат связи и цикл возвращают понятную ошибку. Если тест пропускает произвольный путь к файлу или проверяет только наличие слов api, он защищает соглашение о названии, но не архитектурную границу.

\n

Критерий готовности

\n

Граница готова, если для каждого межмодульного вызова можно показать сценарий потребителя, владельца смысла, точную публичную поверхность и разрешённое направление. Автоматическая проверка проходит по фактическим ссылкам и отдельно сообщает об internal-протечке, неизвестном модуле и цикле. Учебный fixture из статьи проверяет только форму policy и обязан так себя называть.

\n

Если хотя бы один ответ отсутствует, не расширяйте API и не начинайте распил. Сначала уточните владельца или верните операцию локально. Ценность модульного монолита именно в этом: внутренность можно менять независимо от потребителей, а границу — проверять до дорогостоящего распределения системы.

\n

Проверяемые источники

\n" }