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

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

\n

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

\n

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

\n

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

\n

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

\n

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

\n
Граница читается по четырём вопросам
ВопросПример ответаЗачем он нужен
Кто вызывает?checkoutФиксирует потребителя и его сценарий
Кто владеет смыслом?catalogНазначает ответственность за изменение контракта
Через что вызывают?catalog.apiНе даёт подменить API внутренним типом
Разрешено ли направление?checkout → catalogОстанавливает случайные обратные связи
\n

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

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

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

\n

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

\n

Направление нужно хранить отдельно от физического пути. В одном языке internal-пакет можно закрыть средствами компилятора, в другом останется только соглашение и архитектурный тест. Оба слоя полезны. Видимость защищает от части ошибочных обращений, а карта объясняет, почему разрешён сам маршрут.

\n
const allowed = new Set(['checkout>catalog:catalog.api', 'checkout>payments:payments.api', 'payments>notifications:notifications.api']); function check(link) { if (link.surface !== link.to + '.api') return 'non-public surface'; return allowed.has(link.from + '>' + link.to + ':' + link.surface) ? 'allowed' : 'forbidden direction'; }
\n

Код выше — учебный пример проверки заранее описанной карты. Он не читает репозиторий, не строит граф импортов и не доказывает отсутствие нарушений в приложении. В настоящем проекте анализатор должен получить фактические ссылки из подходящего инструмента языка, сопоставить их с картой и сохранить результат проверки. Если такого анализа пока нет, честный результат — «карта описана, фактические импорты не проверены».

\n

Цикл проверяют на том же графе. Если карта разрешает checkout → payments, а затем добавляет payments → checkout, две области начинают знать друг о друге. Цикл не означает, что нужно немедленно выделить микросервис. Он означает, что не назван владелец процесса. Сначала уточняют orchestration, границу инварианта и направление обмена. Иногда помогает событие. Иногда — перенос операции к владельцу. Иногда — узкий контракт без обратного вызова.

\n

Симптом → причина → проверка → действие

\n
Практическая диагностика границы
СимптомПричинаПроверкаДействие
Потребитель импортирует catalog.internalAPI не выражает нужную операцию или деталь показалась удобнееСверить surface вызова со списком API и назвать сценарийВернуть операцию владельцу либо добавить узкий контракт
Появилась обратная стрелкаНе определён владелец процесса или смешаны ответственностиПостроить граф и найти циклВыбрать orchestration, событие или перенос операции
Все импортируют commonОбщий пакет стал обходом границыПроверить, кто владеет каждым типом и кто меняет егоРазделить контракты или вернуть код владельцу
API повторяет таблицы владельцаПубличная поверхность раскрывает реализациюПроверить, может ли владелец изменить хранение без consumerСузить данные до операции, результата или события
Тест зелёный, но импорт неизвестенПроверена только модель, а не исходный кодПроверить источник фактических ссылок и дату evidenceНе выдавать модель за аудит; добавить реальный анализ
\n

Порядок внедрения

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

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

\n

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

\n

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

\n

Java Platform Module System даёт физический пример: именованный модуль объявляет экспортируемые пакеты и зависимости. Spring Modulith показывает похожую идею для Java/Spring: API модуля отделяется от внутренних пакетов и разрешённых зависимостей. Эти механизмы нельзя перенести в любой стек без изменений. Их полезный общий принцип уже достаточен: доступ должен быть назван, ограничен и проверяем.

\n

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

\n

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

\n

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

\n

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

\n" }