Москва: проверенные б/у ноутбуки с гарантией и проверкой перед оплатой
+7 906 722-94-45

Главная → Статьи → Модульный монолит: границы модулей и зависимости

Модульный монолит: границы модулей, которые держатся

Модульный монолит: публичный API модуля, правила зависимостей, отдельные схемы БД, контроль импортов и признаки, что пора разделять.

Прямой ответ

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

Три обязательных элемента:

  1. Публичный интерфейс модуля. Другие модули обращаются только к нему. Внутренние классы и функции недоступны извне.
  2. Правила зависимостей. Направление обращений задано явно; циклы запрещены.
  3. Контроль в 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. Измеряйте связность. Число импортов между модулями и частота совместных изменений в коммитах показывают, где граница проведена неверно.

Рекомендации:

  1. Начинайте с одного развёртываемого приложения. Разделение проще выполнить позже из модулей с чистыми границами, чем выделять их из переплетённого кода.
  2. Ведите правила в репозитории. Конфигурация анализатора импортов — часть кодовой базы и проходит то же ревью.
  3. Разделяйте схемы с первого дня. Перенос таблиц между схемами позже обходится дороже, чем изначальное разделение.
  4. Следите за долгом. Размытие границ — это технический долг, который нужно учитывать явно; о работе с ним — в статье про технический долг.
  5. Проверяйте структуру импортов в CI. Локальная проверка забывается, проверка в пайплайне — нет; о раскладке проекта — в статье про структуру проекта.

Практический сценарий: выделение модуля в сервис заняло неделю вместо квартала

Контекст: из приложения потребовалось выделить расчёт скидок в отдельный сервис, чтобы масштабировать его независимо от остальной нагрузки.

  1. Исходное состояние. Модуль скидок существовал как пакет, но его внутренние функции импортировались из семи мест в других модулях, а две таблицы были связаны внешними ключами с таблицами заказов.
  2. Подготовка. (а) Ввели публичный интерфейс модуля и перенесли все обращения на него; (б) добавили контракт в import-linter, запрещающий прямой импорт внутренних пакетов; (в) убрали внешние ключи между схемами, заменив их ссылкой по идентификатору; (г) заменили прямые вызовы публикацией событий внутри процесса.
  3. Проверка. Через месяц после подготовки число прямых обращений к модулю сократилось до нуля; это подтверждалось анализатором импортов, а не обзором кода.
  4. Выделение. Модуль перенесли в отдельный сервис: публичный интерфейс превратился в HTTP-контракт, внутренняя подписка на события — в потребление из очереди, схема — в отдельную базу.
  5. Результат. Перенос занял шесть рабочих дней. Основной риск — поиск скрытых зависимостей — был снят заранее, поскольку зависимости стали явными и контролируемыми.

Вывод: ценность модульного монолита проявляется не в момент написания кода, а в момент изменения границ. Явные интерфейсы и контроль импортов превращают архитектурное решение из намерения в проверяемое свойство.

FAQ

Чем модульный монолит отличается от обычного? Границы заданы явно и контролируются инструментами. В обычном монолите разделение существует в названиях пакетов, но любой код может импортировать что угодно.

Нужны ли отдельные схемы в одной базе? Желательно. Общая схема делает последующее разделение дорогим, поскольку связи между таблицами приходится разрывать уже в работающей системе.

Когда пора выделять модуль в сервис? Когда появляется независимая потребность в масштабировании, отдельном цикле releases или изоляции сбоев. Разделение ради архитектуры бессмысленно.

Можно ли применять подход в небольшом проекте? Да, но достаточно двух-трёх модулей и простых правил. Избыточное дробление небольшого кода усложняет чтение без пользы.

Источники: import-linter docs, Martin Fowler (MonolithFirst), Python docs — ссылки в шапке.