Модульный монолит — это не компромисс.
Если вы когда-нибудь слышали фразу «давайте начнем с монолита, а потом перейдём на микросервисы», то знайте — в 90% случаев «потом» не наступает никогда. Монолит обрастает зависимостями, как днище корабля ракушками, и через два года вы сидите перед кодовой базой, которая держится на честном слове и паре разработчиков, которые «знают, как тут всё устроено».
Модульный монолит — это попытка сделать иначе. Не «потом перепишем», а «сразу спроектируем так, чтобы не пришлось переделывать».
Что такое модульный монолит и зачем он нужен
Модульный монолит — это архитектурный подход, при котором приложение остаётся единым развёртываемым артефактом (один процесс, один деплой), но внутри делится на изолированные модули с чёткими границами и контрактами взаимодействия.
Звучит как «монолит, но аккуратный»? Почти. Разница в том, что в обычном монолите любой класс может дёрнуть любой другой класс — и к третьему году разработки вы получаете спагетти-код, который невозможно развязать. В модульном монолите модули общаются только через определённые интерфейсы, а прямые зависимости между внутренностями модулей запрещены.
Модульный монолит — это квартира с несущими стенами. Комнаты разделены, но под одной крышей. Вы не можете снести стену между кухней и спальней, потому что «так удобнее».
Чем это отличается от обычного монолита
В классическом монолите границы между частями системы существуют только в головах разработчиков (и то не у всех). Код организован по техническим слоям — контроллеры, сервисы, репозитории — но бизнес-логика размазана тонким слоем по всем этим слоям.
В модульном монолите код организован по бизнес-доменам. Есть модуль «Заказы», модуль «Платежи», модуль «Клиенты» — и каждый из них содержит свои контроллеры, сервисы и репозитории внутри себя.
На диаграмме слева — классический монолит, где всё свалено в технические слои и сервисы могут зависеть друг от друга хаотично. Справа — модульный монолит: каждый бизнес-домен живёт в своём модуле, а модули общаются только через публичные API.
Анатомия модуля: что внутри
Каждый модуль в модульном монолите — это мини-приложение со своей структурой. Хороший модуль содержит:
- Публичный API — набор интерфейсов и DTO, через которые другие модули могут взаимодействовать с этим модулем
- Внутренняя реализация — бизнес-логика, доменные модели, репозитории, которые скрыты от внешнего мира
- Собственная схема данных — в идеале каждый модуль владеет своими таблицами в базе данных
Вот как это выглядит на уровне файловой структуры (пример на Java/Kotlin):
src/├── modules/│ ├── order/│ │ ├── api/ ← публичные интерфейсы и DTO│ │ │ ├── OrderApi.kt│ │ │ └── OrderDto.kt│ │ ├── domain/ ← доменная модель│ │ │ ├── Order.kt│ │ │ └── OrderItem.kt│ │ ├── service/ ← бизнес-логика│ │ │ └── OrderService.kt│ │ └── repository/ ← работа с БД│ │ └── OrderRepository.kt│ ├── payment/│ │ ├── api/│ │ ├── domain/│ │ ├── service/│ │ └── repository/│ └── client/│ ├── api/│ ├── domain/│ ├── service/│ └── repository/└── shared/ ← общие утилиты (логирование, авторизация)Ключевое правило: модуль order может импортировать из payment/api/, но никогда из payment/domain/ или payment/repository/. Нарушение этого правила — первый шаг к превращению модульного монолита обратно в комок пластилина.
Bounded Contexts из DDD: фундамент модульного монолита
Если вы читали про Domain-Driven Design, то понятие Bounded Context вам знакомо. Если нет — это самое время разобраться, потому что модульный монолит без bounded contexts — это просто папки с красивыми названиями.
Bounded Context — это граница, внутри которой определённые термины имеют однозначное значение. Например, «Клиент» в контексте модуля заказов — это тот, кто размещает заказ. А «Клиент» в контексте модуля поддержки — это тот, кто пишет тикеты. Это может быть один и тот же человек, но модели данных, поведение и правила — разные.
Обратите внимание: «Клиент» существует в двух контекстах с совершенно разными атрибутами. «Товар» тоже — в контексте заказа нас интересует только количество и цена, а в каталоге важны описание, фото и рейтинг. Это и есть суть Bounded Context — одно и то же слово, разные модели.
Между контекстами данные передаются через идентификаторы или события, но не через общие модели. Модуль заказов не хранит полное описание товара — он хранит только productId и запрашивает нужные данные у модуля каталога через его публичный API.
Как модули общаются друг с другом
В микросервисах всё понятно — HTTP, gRPC, очереди сообщений. А как общаются модули внутри одного процесса?
Есть три основных подхода:
1. Прямой вызов через интерфейс
Самый простой способ. Модуль заказов вызывает метод PaymentApi.processPayment() напрямую. Быстро, типобезопасно, но создаёт синхронную зависимость.
// Модуль заказов вызывает API модуля платежейclass OrderService( private val paymentApi: PaymentApi // интерфейс из payment/api/) { fun placeOrder(order: Order) { // ... валидация заказа paymentApi.processPayment(order.id, order.totalAmount) }}2. Внутренние события (In-Process Events)
Модуль публикует событие, а подписчики из других модулей реагируют на него. Это снижает связанность, потому что модуль заказов не знает, кто именно обрабатывает событие.
// Модуль заказов публикует событиеeventBus.publish(OrderPlacedEvent(orderId = order.id, amount = order.totalAmount))
// Модуль платежей подписан на событие@EventListenerfun onOrderPlaced(event: OrderPlacedEvent) { processPayment(event.orderId, event.amount)}3. Фасад (Mediator)
Центральный компонент, который координирует взаимодействие между модулями. Полезен для сложных сценариев, где задействовано несколько модулей.
На этой диаграмме видно, как EventBus развязывает модули. OrderService не знает о существовании NotificationService или WarehouseService — он просто публикует событие, и каждый заинтересованный модуль реагирует самостоятельно. Это тот же принцип, что и в микросервисах с очередями сообщений, только без сетевых задержек и сериализации.
Модульный монолит vs Микросервисы: честное сравнение
Я уже писал подробное сравнение монолита и микросервисов, но тогда модульный монолит был скорее «третьим вариантом». Давайте сравним его с микросервисами напрямую — без маркетинговых лозунгов.
| Критерий | Модульный монолит | Микросервисы |
|---|---|---|
| Развёртывание | Один артефакт, один деплой | Десятки артефактов, оркестрация (K8s) |
| Сетевые вызовы | Вызовы в памяти (~наносекунды) | HTTP/gRPC через сеть (~миллисекунды) |
| Транзакции | ACID в рамках одной БД | Saga, eventual consistency |
| Масштабирование | Только целиком | Каждый сервис отдельно |
| Отладка | Один стек-трейс | Распределённая трассировка (Jaeger, Zipkin) |
| Команда | 3–20 человек | 20+ человек, несколько команд |
| Стоимость инфраструктуры | Низкая — один сервер / контейнер | Высокая — кластер, service mesh, мониторинг |
| Порог входа | Средний | Высокий |
| Независимость деплоя | Нет — деплоится всё вместе | Да — каждый сервис независимо |
Когда выбирать модульный монолит
- Команда до 15–20 человек
- Проект на стадии MVP или активного роста
- Нет выделенной DevOps команды с опытом K8s
- Бизнес-домен ещё не стабилизировался (границы могут меняться)
- Важна скорость разработки и простота отладки
Когда всё-таки нужны микросервисы
- Разные части системы требуют разного масштабирования (например, сервис нотификаций обрабатывает в 100 раз больше запросов, чем сервис отчётов)
- Команды распределены и работают в разных часовых поясах
- Нужна полиглотная архитектура (часть на Java, часть на Python, часть на Go)
- У вас уже есть инфраструктура и экспертиза в распределённых системах
Типичные ошибки при проектировании модульного монолита
За время работы с разными командами я насмотрелся на одни и те же грабли. Вот топ-5:
1. Модули по техническим слоям, а не по бизнес-доменам
❌ Неправильно:modules/├── controllers/├── services/└── repositories/
✅ Правильно:modules/├── order/├── payment/└── catalog/Если ваши «модули» называются controllers, services, repositories — это не модульный монолит, это обычный монолит с папками.
2. Общая доменная модель между модулями
Когда модуль заказов и модуль платежей используют один и тот же класс Client — это прямой путь к проблемам. Стоит одному модулю добавить поле, и второй ломается. Каждый модуль должен иметь собственное представление о клиенте.
3. Прямой доступ к таблицам чужого модуля
«Да зачем мне вызывать API, если я могу просто сделать JOIN?» — подставьте имя любимого разработчика. Прямой JOIN нарушает изоляцию, и при попытке выделить модуль в отдельный сервис придётся распутывать десятки SQL-запросов.
4. Отсутствие enforced boundaries
Договорённости на словах не работают. Нужны инструменты:
- ArchUnit (Java) — проверяет архитектурные правила в тестах
- Eslint boundaries (TypeScript) — запрещает импорты между модулями
- Module system (Kotlin/Java) —
module-info.javaограничивает видимость пакетов
5. «У нас модульный монолит» (но на самом деле нет)
Если любой разработчик может за 5 минут добавить прямую зависимость между внутренностями модулей — у вас не модульный монолит. У вас монолит с красивой файловой структурой.
Путь эволюции: от модульного монолита к микросервисам
Одно из главных преимуществ модульного монолита — возможность поэтапной миграции на микросервисы, когда (и если) это станет необходимо. Если границы модулей выдержаны, выделение модуля в отдельный сервис выглядит так:
Ключевое слово — поэтапно. Вы не переписываете всю систему за раз (это классический провал). Вы берёте один модуль, который мешает больше всего — например, модуль обработки изображений, который сжирает CPU — и выделяете его в отдельный сервис. Остальная система продолжает работать как модульный монолит.
Этот подход называют Strangler Fig Pattern (паттерн удушающей лианы). Новый сервис постепенно забирает на себя функциональность модуля, пока модуль не станет пустым и его можно будет удалить.
Практический пример: e-commerce платформа
Давайте спроектируем упрощённую e-commerce платформу как модульный монолит. Вот схема взаимодействия модулей:
Обратите внимание на несколько важных моментов:
-
Каждый модуль владеет своими таблицами — префиксы
catalog_*,cart_*,order_*и т.д. Одна база данных, но логическое разделение. При необходимости можно выделить в отдельные схемы. -
Синхронные вызовы (сплошные стрелки) — для операций, где нужен немедленный ответ. Корзина спрашивает каталог о текущей цене товара — это должно произойти прямо сейчас.
-
Асинхронные события (через EventBus) — для операций, которые не блокируют основной поток. Уведомления отправляются асинхронно — пользователю не нужно ждать, пока email дойдёт.
-
Shared-модули — EventBus и AuthModule являются общей инфраструктурой. Это допустимо, потому что они не содержат бизнес-логики.
Чеклист: у вас точно модульный монолит?
Перед тем как гордо заявить на собеседовании «мы используем модульный монолит», пройдитесь по этому списку:
- Модули выделены по бизнес-доменам, а не по техническим слоям
- Каждый модуль имеет публичный API (интерфейс), через который с ним взаимодействуют другие модули
- Прямой доступ к внутренностям чужого модуля запрещён (и это проверяется автоматически)
- Каждый модуль владеет своими данными — нет JOIN’ов между таблицами разных модулей
- Можно выделить любой модуль в отдельный сервис за 1–2 спринта
- Есть документация по контрактам между модулями
- Тесты модуля не зависят от внутренностей других модулей
Если у вас 5 из 7 — вы на правильном пути. Если 3 и меньше — у вас монолит с папками (и это не серебряная пуля, но хотя бы честно).
Заключение
Модульный монолит — это не «недомикросервисы» и не «монолит для бедных». Это зрелый архитектурный подход, который решает реальную проблему: как писать большие системы, не утонув в операционной сложности распределённых систем.
Ключевая мысль: архитектура — это не то, что вы рисуете на диаграммах. Это то, что реально происходит в коде. Можно нарисовать красивые модули, но если разработчики лезут в чужие внутренности — у вас монолит. Можно не рисовать ничего, но если границы соблюдаются — у вас модульная архитектура.
Начните с малого: выделите 2–3 модуля по бизнес-доменам, определите их API, запретите прямые зависимости. Через полгода вы скажете себе спасибо — или вашим разработчикам скажут спасибо те, кто придёт после них.
PS: Если ваш тимлид говорит «у нас модульный монолит», а разработчики делают import из любого пакета без ограничений — дайте ему ссылку почитать про ArchUnit.