{ "index": 140, "slug": "editorial-2024-02-mechanism-modular-monolith", "title": "Модульный монолит: как удержать границы до распила на сервисы", "excerpt": "Папки по доменам не защищают код от скрытых связей. Разбираем публичную поверхность модуля, разрешённые направления, цикл зависимостей и проверку, которую можно встроить в обычный review.", "contentHtml": "
В монолите проблема часто начинается с маленького импорта. Код оформления заказа берёт внутренний formatter каталога, потому что нужный API ещё не готов. Потом платежи вызывают helper оформления заказа, а общий пакет получает третий набор исключений. Сборка проходит. Папки по-прежнему выглядят как отдельные домены.
\nСимптом появляется позже: изменение внутреннего типа каталога требует искать потребителей в заказах и платежах. Ревьюер не может ответить, какой вызов разрешён, а какой только терпят ради срока. Цена ошибки — скрытый контракт. Он увеличивает область каждого изменения, усложняет откат и делает будущий перенос модуля дороже.
\nТезис простой: модульный монолит держится не на глубине каталогов, а на явном договоре. Для каждой связи нужно назвать потребителя, владельца и поверхность доступа: consumer → owner.publicApi. Отдельно нужно перечислить разрешённые направления. Тогда правило можно обсуждать по конкретному вызову, а не по впечатлению от дерева файлов.
Модуль владеет смыслом операции, своими данными и публичным входом. Публичный вход не равен каждому символу с модификатором public. Это обещание для другого модуля. Им может быть команда, запрос, событие, порт или функция. Вход должен выражать потребность, а не раскрывать внутреннее хранение.
В учебной модели есть четыре модуля: catalog, checkout, payments и notifications. У каждого есть поверхность *.api и внутренняя часть *.internal. Разрешены только три связи: checkout → catalog.api, checkout → payments.api и payments → notifications.api. Это пример формы правила, а не описание реальной системы.
| Вопрос | Пример ответа | Зачем он нужен |
|---|---|---|
| Кто вызывает? | checkout | Фиксирует потребителя и его сценарий |
| Кто владеет смыслом? | catalog | Назначает ответственность за изменение контракта |
| Через что вызывают? | catalog.api | Не даёт подменить API внутренним типом |
| Разрешено ли направление? | checkout → catalog | Останавливает случайные обратные связи |
Одна стрелка без поверхности слишком широка. Запись «checkout зависит от catalog» допускает и запрос карточки, и чтение репозитория, и вызов приватного форматтера. Запись checkout → catalog.api задаёт проверяемый объект. Если потребителю нужен новый смысл, владелец решает, добавлять ли узкий метод, событие или оставить операцию внутри своего модуля.
Сначала команда описывает карту модулей. Для каждого модуля она записывает имя, публичную поверхность, внутренние пакеты и владельца. Затем добавляет разрешённые рёбра. Проверка каждой ссылки отвечает на четыре вопроса: существует ли источник, существует ли получатель, совпадает ли поверхность с опубликованной и есть ли такое направление в карте.
\nНаправление нужно хранить отдельно от физического пути. В одном языке internal-пакет можно закрыть средствами компилятора, в другом останется только соглашение и архитектурный тест. Оба слоя полезны. Видимость защищает от части ошибочных обращений, а карта объясняет, почему разрешён сам маршрут.
\nconst 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, границу инварианта и направление обмена. Иногда помогает событие. Иногда — перенос операции к владельцу. Иногда — узкий контракт без обратного вызова.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
Потребитель импортирует catalog.internal | API не выражает нужную операцию или деталь показалась удобнее | Сверить surface вызова со списком API и назвать сценарий | Вернуть операцию владельцу либо добавить узкий контракт |
| Появилась обратная стрелка | Не определён владелец процесса или смешаны ответственности | Построить граф и найти цикл | Выбрать orchestration, событие или перенос операции |
Все импортируют common | Общий пакет стал обходом границы | Проверить, кто владеет каждым типом и кто меняет его | Разделить контракты или вернуть код владельцу |
| API повторяет таблицы владельца | Публичная поверхность раскрывает реализацию | Проверить, может ли владелец изменить хранение без consumer | Сузить данные до операции, результата или события |
| Тест зелёный, но импорт неизвестен | Проверена только модель, а не исходный код | Проверить источник фактических ссылок и дату evidence | Не выдавать модель за аудит; добавить реальный анализ |
from → to.surface и отдельно укажите запрещённую обратную связь. У каждого исключения должен быть владелец и дата пересмотра.Граф границ не отвечает за транзакции, задержку, права доступа, размер payload, версионирование событий и качество данных. Разрешённая стрелка может вести к медленной операции. Запрещённая стрелка может стать оправданной после смены владельца. Поэтому зелёный статус архитектурной проверки не заменяет нагрузочный, security или интеграционный тест.
\nОтрицательный путь нужно сохранять рядом с правилом. Вызов catalog.internal должен завершаться понятным отказом, а не молча проходить через исключение. Неизвестный модуль, дубликат связи и цикл тоже должны иметь отдельные сообщения. Если проверка пропускает пустую поверхность или принимает произвольный путь к файлу, она защищает только видимость, но не границу.
Java Platform Module System даёт физический пример: именованный модуль объявляет экспортируемые пакеты и зависимости. Spring Modulith показывает похожую идею для Java/Spring: API модуля отделяется от внутренних пакетов и разрешённых зависимостей. Эти механизмы нельзя перенести в любой стек без изменений. Их полезный общий принцип уже достаточен: доступ должен быть назван, ограничен и проверяем.
\nГраница готова, если для каждого межмодульного вызова команда может показать четыре записи: сценарий потребителя, владельца смысла, точную публичную поверхность и разрешённое направление. Реальная проверка должна пройти по фактическим ссылкам и отдельно показать отрицательные случаи: internal-протечку, неизвестный модуль и цикл. Учебная модель может проверить только формулировку правила и обязана так себя называть.
\nЕсли один из четырёх ответов отсутствует, не расширяйте API и не выделяйте сервис. Сначала уточните владельца или оставьте код локальным. Модульный монолит приносит пользу именно в этот момент: команда получает ясную границу и может менять внутренность без скрытых потребителей.
\n