Модульный монолит
Модульный монолит устанавливает жёсткие швы между функциями внутри одного деплоя — пакеты по фиче, а не по слою — зарабатывая право на разделение позже.
B2B биллинговая платформа начиналась как единое Rails-приложение, организованное по слоям: app/models/, app/controllers/, app/services/. Через восемнадцать месяцев в команде было 60 разработчиков, а деплой занимал 40 минут. Каждое изменение биллинговой логики требовало правок в models/, services/, и иногда controllers/. Никто не мог сказать, какие service-классы безопасно вызывать из биллинга, не заглянув в три директории. Технический лид предложил перейти на микросервисы. Руководство одобрило. Через шесть месяцев команда получила три сервиса, деплоящихся синхронно, разделяющих одну базу данных и ещё более сложных в изменении. Что пошло не так? Они разделили ком грязи и получили распределённый ком грязи. Нужная им модульная структура — это внутренняя дисциплина, а не сетевая граница.
Package-by-layer против package-by-feature
Классический скаффолд Rails/Spring/Django организует код по технической роли: models/, controllers/, services/, repositories/. Это package-by-layer. На старте проекта выглядит чисто. При масштабировании становится обузой.
Package-by-layer объединяет каждую модель из каждого домена. OrderService и InvoiceService и CustomerService — все живут в services/. Чтобы понять домен заказов, приходится читать четыре директории. Добавление фичи в заказы разбрасывает изменения по тем же четырём директориям. Каждое доменное изменение затрагивает каждый слой. Зацепление невидимо: ничто не мешает InvoiceService напрямую вызвать OrderRepository.
Package-by-feature (или package-by-domain) группирует всё для одной бизнес-возможности вместе. Модуль ordering/ содержит свои модели, свои сервисы, свои репозитории, свою API-поверхность. Модуль billing/ — соседний со своими внутренностями. Кросс-модульный доступ явный и контролируемый.
Принудительное соблюдение границ модуля
Модульный монолит заслуживает своё название именно за принудительное соблюдение. Раскладывание файлов по поддиректориям — не граница модуля. Граница должна принудительно соблюдаться так, чтобы нарушение было ошибкой сборки, а не комментарием в code review.
Механизмы по языку/экосистеме:
- Java-модули (JPMS):
module-info.javaобъявляет, что экспортируется и что требуется. Попытка доступа к неэкспортируемому пакету не компилируется. - TypeScript с
@typescript-eslint/no-restricted-imports: правила ESLint запрещают кросс-модульные импорты, кроме как через re-экспорты index. Нарушения ломают lint-гейт. - NestJS-модули:
@Module({ exports: [...] })явно определяет поверхность модуля. Injectable-сервисы не вexportsневидимы снаружи модуля. - Go-пакеты с тулингом (
golang.org/x/tools/go/analysis): кастомные анализаторы могут assertить, что ни один пакет вinternal/ordering/не импортируется напрямую изinternal/billing/. - ArchUnit (Java) /
ts-arch(TypeScript): архитектурные тесты, assertящие правила зависимостей. Тест-сьют падает, еслиbillingимпортирует непубличный класс изordering.
Ключевое: механизм должен быть автоматизированным. Если принудительное соблюдение границы требует ревью людьми, оно разрушится под давлением дедлайнов. Modular monolith — это обязательство перед автоматизацией.
▸Why this works
Почему граница должна быть внутри одного деплоя, а не сетевой границей? Потому что сетевая граница добавляет задержку, распределённые режимы отказа и накладные расходы на сериализацию для каждого кросс-модульного вызова. Внутренняя граница модуля не стоит ничего в рантайме — это ограничение на этапе компиляции или тестирования. Форму границы можно дёшево менять. Если потом модуль извлекается в сервис, граница уже определена и протестирована — извлечение становится механической операцией, а не архитектурной переработкой.
Подход MonolithFirst по Фаулеру
Аргумент Мартина Фаулера «MonolithFirst» — не предпочтение монолитов. Это утверждение об информации: в начале проекта вы не знаете, где правильные границы сервисов. Доменные границы обнаруживаются через использование, а не проектируются заранее.
Паттерн отказа, который Фаулер наблюдал повторно: команды начинают с микросервисов, потому что «знают, что нужно масштабироваться», декомпозируют преждевременно по неправильным швам, и затем сталкиваются с двумя проблемами одновременно — строительством продукта и рефакторингом границ сервисов. Цена неправильной границы микросервиса высока: разделённые данные, API-версионирование, распределённые транзакции и две команды, которые должны согласовывать каждое изменение схемы.
Modular monolith — промежуточная позиция: обязаться на чистую внутреннюю структуру сейчас, отложить решение о распределении до понимания домена. Когда придёт время делить, деление происходит по швам, подтверждённым реальным использованием, а не предположенным заранее.
Архитектор команды предлагает: «Нужно разделиться на микросервисы сейчас, пока кодовая база мала. Потом, когда кода будет больше, делить будет сложнее». Старший инженер не согласен. В чём конкретный изъян рассуждений архитектора?
Что делает границу модуля хорошей
Не каждая поддиректория — это модуль. Хорошая граница модуля определяется:
Явный публичный API. Модуль выставляет поверхность — набор типов, функций или интерфейсов — и скрывает внутренности. Вызывающие зависят от публичного API, а не от классов реализации. При изменении внутренней реализации меняется только код самого модуля.
Одна бизнес-возможность. Модуль должен соответствовать одному bounded context (урок 06) или одной бизнес-возможности. ordering/ управляет жизненным циклом заказа. billing/ управляет инвойсингом и оплатой. inventory/ управляет запасами. Модули со смешанными возможностями — ordering-and-billing/ — сигнализируют о неправильном шве.
Независимый тест-сьют. Модуль может быть протестирован без запуска других модулей. Если тестирование billing требует импорта живого кода из ordering, зависимость неправильная.
Низкое афферентное зацепление на внутренностях. Внутренние классы модуля не импортируются ничем снаружи модуля. Весь внешний доступ идёт через публичный API. Если внутренний класс Order импортируется напрямую BillingService, граница уже нарушена.
В кодовой базе биллинговой платформы есть модуль `ordering/` и модуль `billing/`. Ревью кода обнаруживает: BillingService импортирует OrderRepository из `ordering/repositories/OrderRepository` напрямую, минуя публичную поверхность `ordering/api`. Инженер аргументирует: «Это только чтение — мы не модифицируем данные заказов, граница не нарушена». В чём структурная проблема такого рассуждения?
Когда делить: зарабатывание права
Modular monolith — не постоянная цель. Это состояние, в котором нужно быть перед разделением. Разделение зарабатывается, а не даруется.
Признаки того, что право на извлечение модуля в сервис заработано:
- Публичный API модуля стабилен и использовался несколькими вызывающими без изменения контракта.
- У модуля есть собственное хранилище данных (даже внутри общей БД его таблицы принадлежат исключительно ему).
- Тест-сьют модуля независим — может запускаться изолированно.
- Частота деплоя модуля явно отличается от других (нужно релизить несколько раз в неделю, другие — раз в месяц).
- Команда, владеющая модулем, достаточно велика, что накладные расходы на коммуникацию через границу модуля теперь дороже, чем граница деплоя.
Если эти условия не выполнены, разделение будет преждевременным и граница сервиса окажется неправильной. Самая дорогая ошибка в микросервисах — не слишком долгое оставание монолитом, а слишком раннее разделение по неправильным швам.
Модуль инвентаря биллинговой платформы работает в продакшне шесть месяцев. Команда рассматривает его извлечение в сервис. Инженер проверяет модуль и находит: (a) InventoryService.getAvailableStock() импортируется OrderService и BillingService через публичный API. (b) Таблицы базы данных модуля инвентаря напрямую запрашиваются BillingService через общее ORM-соединение. (c) Тест-сьют инвентаря требует запущенного OrderService для некоторых граничных случаев. Какие из этих находок должны блокировать извлечение и почему?
- 01В чём ключевое различие между package-by-layer и package-by-feature и почему это важно для границ модуля?
- 02Что означает 'принудительное соблюдение границ модуля' на практике и почему оно должно быть автоматизировано?
- 03Каковы признаки того, что модуль 'заработал право' на извлечение в микросервис?
Провальная миграция на микросервисы на биллинговой платформе разбила ком грязи и получила распределённый ком грязи. Недостающим ингредиентом была не сетевая граница — а внутренняя дисциплина. Modular monolith обеспечивает эту дисциплину: жёсткие швы между бизнес-возможностями, принудительно соблюдаемые инструментарием сборки, организованные по фиче, а не по слою.
Паттерн MonolithFirst — не ностальгия по монолиту. Это утверждение об информации: вы не знаете правильных границ сервисов, пока домен не понят через продакшн-использование. Преждевременное разделение фиксирует неправильные границы по высокой цене. Разделение после периода дисциплины modular monolith означает деление по швам, подтверждённым реальными вызывающими, стабильными API и независимыми тест-сьютами.
Граница модуля внутри деплоя не стоит ничего в рантайме — это ограничение на этапе компиляции или тестирования. Её форму можно дёшево менять. Став сетевой границей, её изменение требует API-версионирования, миграции данных и межкомандной координации. Modular monolith — фаза дешёвых экспериментов, чтобы сетевая граница, когда придёт, была размещена правильно.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.