Прямой ответ
Модульный монолит — это одно развёртываемое приложение с явно заданными границами между частями. От обычного монолита он отличается не размером, а тем, что границы модулей заданы и контролируются инструментами, а не только соглашением команды.
Три обязательных элемента:
- Публичный интерфейс модуля. Другие модули обращаются только к нему. Внутренние классы и функции недоступны извне.
- Правила зависимостей. Направление обращений задано явно; циклы запрещены.
- Контроль в CI. Нарушение правила ломает сборку, а не обсуждается в ревью.
src/
orders/
__init__.py # публичный API модуля
public.py # функции и типы, доступные извне
internal/
service.py # недоступно другим модулям
repository.py
migrations/ # только схема orders
payments/
__init__.py
public.py
internal/
Контроль импортов задаётся декларативно. Для Python подходит import-linter:
[importlinter]
root_package = src
[importlinter:contract:orders-layers]
name = Orders не зависит от Payments напрямую
type = forbidden
source_modules =
src.orders
forbidden_modules =
src.payments
[importlinter:contract:internal-hidden]
name = Внутренние пакеты закрыты
type = forbidden
source_modules =
src.payments
forbidden_modules =
src.orders.internal
src.billing.internal
Проверка выполняется в CI одной командой, и нарушение видно до слияния. Без такого контроля границы размываются за несколько месяцев: прямой импорт внутренней функции кажется безобидным, но делает последующее разделение невозможным.
Вторая часть — данные. Модули работают с отдельными схемами в одной базе:
CREATE SCHEMA orders;
CREATE SCHEMA payments;
-- модуль orders не имеет прав на схему payments
REVOKE ALL ON ALL TABLES IN SCHEMA payments FROM orders_app;
Это сохраняет преимущества монолита (одна транзакция, простой JOIN при необходимости) и одновременно готовит данные к возможному разделению: если таблицы модуля не переплетены с чужими, вынос в отдельную базу выполняется переносом схемы.
Практика: как удерживать границы
1. Проектируйте модуль вокруг сценария, а не вокруг таблицы. Модуль «Заказы» отвечает за жизненный цикл заказа, а не за таблицу orders. Группировка по сущностям приводит к тому, что один сценарий затрагивает пять модулей.
2. Публичный интерфейс — минимальный. Каждый лишний экспортированный символ становится контрактом, который придётся поддерживать. Начните с двух-трёх функций и расширяйте по необходимости.
3. Запретите прямые обращения к чужим таблицам. Это самое частое нарушение. Проверка — поиск имён чужих схем в SQL модуля.
4. Обмен через события внутри процесса. Модуль публикует событие, заинтересованные подписываются. Это заменяет прямые вызовы и соответствует тому, как будет устроен обмен после разделения.
5. Фиксируйте архитектурные решения. Причина, по которой модули разделены именно так, должна быть записана — см. статью про документацию решений.
6. Не разделяйте преждевременно. Микросервисы добавляют сеть, распределённые транзакции и сложность развёртывания. О паттернах и антипаттернах — в статье про микросервисы.
7. Измеряйте связность. Число импортов между модулями и частота совместных изменений в коммитах показывают, где граница проведена неверно.
Рекомендации:
- Начинайте с одного развёртываемого приложения. Разделение проще выполнить позже из модулей с чистыми границами, чем выделять их из переплетённого кода.
- Ведите правила в репозитории. Конфигурация анализатора импортов — часть кодовой базы и проходит то же ревью.
- Разделяйте схемы с первого дня. Перенос таблиц между схемами позже обходится дороже, чем изначальное разделение.
- Следите за долгом. Размытие границ — это технический долг, который нужно учитывать явно; о работе с ним — в статье про технический долг.
- Проверяйте структуру импортов в CI. Локальная проверка забывается, проверка в пайплайне — нет; о раскладке проекта — в статье про структуру проекта.
Практический сценарий: выделение модуля в сервис заняло неделю вместо квартала
Контекст: из приложения потребовалось выделить расчёт скидок в отдельный сервис, чтобы масштабировать его независимо от остальной нагрузки.
- Исходное состояние. Модуль скидок существовал как пакет, но его внутренние функции импортировались из семи мест в других модулях, а две таблицы были связаны внешними ключами с таблицами заказов.
- Подготовка. (а) Ввели публичный интерфейс модуля и перенесли все обращения на него; (б) добавили контракт в import-linter, запрещающий прямой импорт внутренних пакетов; (в) убрали внешние ключи между схемами, заменив их ссылкой по идентификатору; (г) заменили прямые вызовы публикацией событий внутри процесса.
- Проверка. Через месяц после подготовки число прямых обращений к модулю сократилось до нуля; это подтверждалось анализатором импортов, а не обзором кода.
- Выделение. Модуль перенесли в отдельный сервис: публичный интерфейс превратился в HTTP-контракт, внутренняя подписка на события — в потребление из очереди, схема — в отдельную базу.
- Результат. Перенос занял шесть рабочих дней. Основной риск — поиск скрытых зависимостей — был снят заранее, поскольку зависимости стали явными и контролируемыми.
Вывод: ценность модульного монолита проявляется не в момент написания кода, а в момент изменения границ. Явные интерфейсы и контроль импортов превращают архитектурное решение из намерения в проверяемое свойство.
FAQ
Чем модульный монолит отличается от обычного? Границы заданы явно и контролируются инструментами. В обычном монолите разделение существует в названиях пакетов, но любой код может импортировать что угодно.
Нужны ли отдельные схемы в одной базе? Желательно. Общая схема делает последующее разделение дорогим, поскольку связи между таблицами приходится разрывать уже в работающей системе.
Когда пора выделять модуль в сервис? Когда появляется независимая потребность в масштабировании, отдельном цикле releases или изоляции сбоев. Разделение ради архитектуры бессмысленно.
Можно ли применять подход в небольшом проекте? Да, но достаточно двух-трёх модулей и простых правил. Избыточное дробление небольшого кода усложняет чтение без пользы.
Источники: import-linter docs, Martin Fowler (MonolithFirst), Python docs — ссылки в шапке.