{ "index": 141, "slug": "editorial-2024-02-practice-modular-monolith", "title": "Модульный монолит: как сделать границы зависимостей проверяемыми", "excerpt": "Папки не защищают модуль от чужих импортов. Разбираем публичную поверхность, разрешённые направления, цикл зависимостей и короткую проверку, которую можно встроить в тесты.", "contentHtml": "
Симптом обычно выглядит безобидно: разработчик в модуле checkout добавляет импорт из catalog/internal, потому что нужный helper уже готов. Сборка проходит. Через несколько недель изменение внутреннего parser-а каталога требует искать потребителей в оплате и заказах. Команда больше не знает, какой код можно менять локально. Цена ошибки — скрытые регрессии, длинный review и рефакторинг, который нельзя выполнить по частям.
Папка с названием домена не создаёт границу. Она помогает найти файлы, но не определяет право на импорт. Граница появляется только тогда, когда команда явно задаёт публичную поверхность модуля, владельца этой поверхности и допустимые направления зависимостей. После этого правило можно проверить на коде и отдельно проверить отрицательный путь: внутренний импорт и цикл должны ломать проверку.
\nУ модуля есть две стороны. Первая — то, что он публикует: команда, запрос, тип или событие с понятным смыслом. Вторая — то, от чего он зависит. Если описана только первая сторона, API быстро превращается в транзит к чужим деталям. Если описана только вторая, команда видит список импортов, но не понимает, какие вызовы считаются устойчивыми.
\nДля каждой связи полезно хранить тройку source → target.surface. Например, checkout → catalog.api означает, что checkout использует именно опубликованную поверхность каталога. Запись checkout → catalog слишком широка: она не отличает API от repository, внутреннего mapper-а и класса, который случайно объявили public.
Учебный пример ниже не описывает реальный продукт и не сообщает о результатах в production. В нём четыре модуля: catalog владеет товарами, checkout собирает заказ, payments проводит оплату, notifications отправляет уведомления. Разрешены только три стрелки: checkout к API каталога, checkout к API платежей и payments к API уведомлений.
| Откуда | Куда | Решение | Что это защищает |
|---|---|---|---|
| checkout | catalog.api | разрешено | заказ получает товар через контракт каталога |
| checkout | payments.api | разрешено | заказ не знает внутреннюю реализацию оплаты |
| payments | notifications.api | разрешено | уведомление вызывается через отдельную поверхность |
| любой модуль | чужой *.internal | запрещено | детали реализации остаются у владельца |
| catalog | payments.api | запрещено в этой модели | новая стрелка требует сценария и владельца |
Публичная поверхность должна выражать потребность потребителя, а не повторять внутреннюю структуру владельца. Если checkout нужен итоговый товар для расчёта цены, ему не нужен ProductEntity, JPA repository и набор внутренних преобразователей. Ему нужен узкий порт, например CatalogReader. Владелец может заменить хранение и parser, пока сохраняет смысл этого порта.
/* Учебный пример. Это контракт модуля catalog, а не готовая production-модель. */\nexport type ProductQuote = {\n sku: string;\n price: number;\n currency: string;\n};\n\nexport interface CatalogReader {\n quote(sku: string): Promise<ProductQuote>;\n}\n\n// checkout импортирует только public surface:\nimport type { CatalogReader } from '../catalog/public';\n\n// Такой импорт нарушает границу:\nimport { ProductParser } from '../catalog/internal/ProductParser';\nСамо слово public не решает архитектурную задачу. В обычном монолите разработчик часто может технически импортировать любой доступный символ. Поэтому правило состоит из двух уровней. Язык и модульная система задают физическую видимость, а архитектурный тест задаёт смысловое разрешение. Нельзя подменять одно другим.
Направления должны образовывать ориентированный ацикличный граф. Цикл checkout → payments → checkout не всегда означает, что предметная модель неверна. Он означает, что текущий порядок владения не объяснён. Пока цикл существует, изменение одного модуля требует держать в голове другой, а изолированный тест и поэтапная миграция становятся дороже.
Разорвать цикл можно несколькими способами. Сначала назовите операцию и её владельца. Если payments сообщает checkout о результате, событие может идти в одну сторону. Если оба модуля используют одинаковое правило, возможно, нужен небольшой тип без поведения. Если один модуль просит внутреннюю деталь другого, сначала спроектируйте порт по потребности. Пакет common не является решением сам по себе: без владельца он превращается в новую общую свалку.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Изменение внутреннего класса требует искать чужие вызовы | Потребитель импортирует деталь вместо контракта | Выписать from, to и surface для импорта | Сформировать узкий API и перевести один вызов |
| Два модуля ссылаются друг на друга | Не назван владелец операции или сообщения | Построить граф прямых зависимостей и найти цикл | Выбрать владельца, событие или односторонний adapter |
Все новые вызовы идут через shared | Временный helper получил неограниченную роль | Проверить владельца, потребителей и срок исключения | Оставить тип локальным либо вернуть поведение владельцу |
| Тест границ зелёный, но API отдаёт слишком много | Структурное правило приняли за проверку бизнес-контракта | Сопоставить данные API с конкретным сценарием потребителя | Уточнить DTO, права, инварианты и отдельные тесты |
| Новая стрелка добавлена ради прохождения сборки | Правило не требует обоснования связи | Спросить сценарий, владельца, альтернативу и цену связи | Оформить исключение с датой пересмотра или не добавлять импорт |
Минимальная проверка отвечает на четыре вопроса: существует ли названный модуль, существует ли его поверхность, разрешено ли направление и нет ли цикла. Отдельно проверяется запрет на internal. Если тест проверяет только разрешённые примеры, его можно случайно сломать так, что он начнёт принимать любой импорт.
// Учебный псевдокод проверки политики.\nconst allowed = new Set([\n 'checkout->catalog:catalog.api',\n 'checkout->payments:payments.api',\n 'payments->notifications:notifications.api',\n]);\n\nfunction check(reference) {\n if (reference.surface.endsWith('.internal')) return 'reject: internal';\n const key = `${reference.from}->${reference.to}:${reference.surface}`;\n return allowed.has(key) ? 'accept' : 'reject: direction';\n}\n\ncheck({ from: 'checkout', to: 'catalog', surface: 'catalog.api' });\n// accept\n\ncheck({ from: 'checkout', to: 'catalog', surface: 'catalog.internal' });\n// reject: internal\nЭтот фрагмент проверяет только заранее переданную политику. Он не читает файлы, не строит AST, не сканирует package graph и не доказывает отсутствие нарушений в конкретном репозитории. Для реального проекта нужен инструмент, который видит фактические зависимости исходного кода, а затем тот же инструмент должен быть подключён к обычной проверке проекта. Учебный псевдокод помогает проверить форму правила, но не заменяет такой анализ.
\nCatalogReader, а не CatalogInternals. Перечислите, что остаётся закрытым.source → target.surface. Запрещённые направления запишите явно.Граф зависимостей не отвечает за качество API. Разрешённый вызов может быть медленным, возвращать лишние данные или нарушать бизнес-инвариант. Он также не решает транзакции, права доступа, владение таблицами, доставку событий и совместимость схем. Эти свойства требуют отдельных контрактов и тестов.
\nФизическая модульность зависит от стека. Java Platform Module System умеет ограничивать экспорт пакетов, но многие приложения живут в обычном classpath. Spring Modulith предлагает проверку application modules, API-пакетов, циклов и явно разрешённых зависимостей, но это решение для Spring-стека. В TypeScript или другом языке понадобится другой анализатор. Переносить аннотации без переноса семантики бесполезно.
\nНе всякая связь должна исчезнуть. Две области могут честно зависеть от общего справочного типа или от события. Важно назвать форму связи и её владельца. Если новая стрелка появляется только потому, что импорт проще, это сигнал остановиться. Если она нужна предметному сценарию, она должна попасть в карту и пройти тот же отрицательный путь.
\nУчасток готов, когда команда может показать карту с владельцами и поверхностями, а проверка даёт четыре наблюдаемых результата: разрешённая связь проходит; импорт чужого internal отвергается; неизвестная стрелка отвергается; цикл получает отдельную ошибку. После перевода одного реального вызова потребитель больше не импортирует детали владельца. Если хотя бы один результат нельзя воспроизвести на коде, граница пока остаётся договорённостью на словах.