Logo
Overview

Модульный монолит: архитектура, примеры и подводные камни

April 18, 2026
11 min read

Модульный монолит — это не компромисс.

Если вы когда-нибудь слышали фразу «давайте начнем с монолита, а потом перейдём на микросервисы», то знайте — в 90% случаев «потом» не наступает никогда. Монолит обрастает зависимостями, как днище корабля ракушками, и через два года вы сидите перед кодовой базой, которая держится на честном слове и паре разработчиков, которые «знают, как тут всё устроено».

Модульный монолит — это попытка сделать иначе. Не «потом перепишем», а «сразу спроектируем так, чтобы не пришлось переделывать».

Что такое модульный монолит и зачем он нужен

Модульный монолит — это архитектурный подход, при котором приложение остаётся единым развёртываемым артефактом (один процесс, один деплой), но внутри делится на изолированные модули с чёткими границами и контрактами взаимодействия.

Звучит как «монолит, но аккуратный»? Почти. Разница в том, что в обычном монолите любой класс может дёрнуть любой другой класс — и к третьему году разработки вы получаете спагетти-код, который невозможно развязать. В модульном монолите модули общаются только через определённые интерфейсы, а прямые зависимости между внутренностями модулей запрещены.

Модульный монолит — это квартира с несущими стенами. Комнаты разделены, но под одной крышей. Вы не можете снести стену между кухней и спальней, потому что «так удобнее».

Чем это отличается от обычного монолита

В классическом монолите границы между частями системы существуют только в головах разработчиков (и то не у всех). Код организован по техническим слоям — контроллеры, сервисы, репозитории — но бизнес-логика размазана тонким слоем по всем этим слоям.

В модульном монолите код организован по бизнес-доменам. Есть модуль «Заказы», модуль «Платежи», модуль «Клиенты» — и каждый из них содержит свои контроллеры, сервисы и репозитории внутри себя.

100%
graph LR
  subgraph Классический монолит
      direction TB
      C["Controllers"]
      S["Services"]
      R["Repositories"]
      C --> S
      S --> R
      S -.->|"всё зависит от всего"| S
  end

  subgraph Модульный монолит
      direction TB
      subgraph Заказы
          C1["OrderController"]
          S1["OrderService"]
          R1["OrderRepo"]
          C1 --> S1 --> R1
      end
      subgraph Платежи
          C2["PaymentController"]
          S2["PaymentService"]
          R2["PaymentRepo"]
          C2 --> S2 --> R2
      end
      subgraph Клиенты
          C3["ClientController"]
          S3["ClientService"]
          R3["ClientRepo"]
          C3 --> S3 --> R3
      end
      S1 -->|"публичный API"| S2
      S2 -->|"публичный API"| S3
  end

  style C fill:#e0e0e0,stroke:#999,color:#333
  style S fill:#e0e0e0,stroke:#999,color:#333
  style R fill:#e0e0e0,stroke:#999,color:#333
  style C1 fill:#4a90d9,stroke:#2c5f8a,color:#fff
  style S1 fill:#4a90d9,stroke:#2c5f8a,color:#fff
  style R1 fill:#7b68ee,stroke:#5a4db2,color:#fff
  style C2 fill:#50c878,stroke:#3a9a5c,color:#fff
  style S2 fill:#50c878,stroke:#3a9a5c,color:#fff
  style R2 fill:#7b68ee,stroke:#5a4db2,color:#fff
  style C3 fill:#f0a500,stroke:#c88400,color:#fff
  style S3 fill:#f0a500,stroke:#c88400,color:#fff
  style R3 fill:#7b68ee,stroke:#5a4db2,color:#fff

На диаграмме слева — классический монолит, где всё свалено в технические слои и сервисы могут зависеть друг от друга хаотично. Справа — модульный монолит: каждый бизнес-домен живёт в своём модуле, а модули общаются только через публичные 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 — это граница, внутри которой определённые термины имеют однозначное значение. Например, «Клиент» в контексте модуля заказов — это тот, кто размещает заказ. А «Клиент» в контексте модуля поддержки — это тот, кто пишет тикеты. Это может быть один и тот же человек, но модели данных, поведение и правила — разные.

100%
graph TD
  subgraph "Bounded Context: Заказы"
      O_Client["Клиент<br/><i>имя, адрес доставки,<br/>история заказов</i>"]
      O_Order["Заказ"]
      O_Product["Товар<br/><i>кол-во, цена в заказе</i>"]
      O_Client --> O_Order
      O_Order --> O_Product
  end

  subgraph "Bounded Context: Каталог"
      C_Product["Товар<br/><i>описание, фото,<br/>характеристики, рейтинг</i>"]
      C_Category["Категория"]
      C_Review["Отзыв"]
      C_Category --> C_Product
      C_Product --> C_Review
  end

  subgraph "Bounded Context: Оплата"
      P_Client["Клиент<br/><i>платёжные данные,<br/>баланс, лимиты</i>"]
      P_Payment["Платёж"]
      P_Invoice["Счёт"]
      P_Client --> P_Payment
      P_Payment --> P_Invoice
  end

  O_Order -->|"ID товара"| C_Product
  O_Order -->|"запрос на списание"| P_Payment

  style O_Client fill:#4a90d9,stroke:#2c5f8a,color:#fff
  style O_Order fill:#4a90d9,stroke:#2c5f8a,color:#fff
  style O_Product fill:#4a90d9,stroke:#2c5f8a,color:#fff
  style C_Product fill:#50c878,stroke:#3a9a5c,color:#fff
  style C_Category fill:#50c878,stroke:#3a9a5c,color:#fff
  style C_Review fill:#50c878,stroke:#3a9a5c,color:#fff
  style P_Client fill:#f0a500,stroke:#c88400,color:#fff
  style P_Payment fill:#f0a500,stroke:#c88400,color:#fff
  style P_Invoice fill:#f0a500,stroke:#c88400,color:#fff

Обратите внимание: «Клиент» существует в двух контекстах с совершенно разными атрибутами. «Товар» тоже — в контексте заказа нас интересует только количество и цена, а в каталоге важны описание, фото и рейтинг. Это и есть суть 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))
// Модуль платежей подписан на событие
@EventListener
fun onOrderPlaced(event: OrderPlacedEvent) {
processPayment(event.orderId, event.amount)
}

3. Фасад (Mediator)

Центральный компонент, который координирует взаимодействие между модулями. Полезен для сложных сценариев, где задействовано несколько модулей.

100%
sequenceDiagram
  participant User as Пользователь
  participant OS as OrderService
  participant EB as EventBus
  participant PS as PaymentService
  participant NS as NotificationService
  participant WS as WarehouseService

  User->>OS: Создать заказ
  OS->>OS: Валидация заказа
  OS->>EB: OrderPlacedEvent
  EB-->>PS: Обработать платёж
  PS->>PS: Списание средств
  PS->>EB: PaymentCompletedEvent
  EB-->>WS: Зарезервировать товар
  EB-->>NS: Отправить уведомление
  WS->>EB: StockReservedEvent
  EB-->>NS: Товар зарезервирован
  NS-->>User: Email: заказ принят

На этой диаграмме видно, как 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 минут добавить прямую зависимость между внутренностями модулей — у вас не модульный монолит. У вас монолит с красивой файловой структурой.

Путь эволюции: от модульного монолита к микросервисам

Одно из главных преимуществ модульного монолита — возможность поэтапной миграции на микросервисы, когда (и если) это станет необходимо. Если границы модулей выдержаны, выделение модуля в отдельный сервис выглядит так:

100%
stateDiagram-v2
  [*] --> Монолит: Старт проекта

  Монолит --> МодульныйМонолит: Выделяем модули,<br/>определяем границы

  state МодульныйМонолит {
      [*] --> Стабильно
      Стабильно --> Рост: Нагрузка растёт,<br/>команда растёт
      Рост --> Анализ: Определяем<br/>узкое место
      Анализ --> ВыделениеСервиса: Модуль с чёткими<br/>границами
      ВыделениеСервиса --> Гибрид: Один модуль<br/>стал сервисом
      Гибрид --> Стабильно: Повторяем<br/>при необходимости
  }

  МодульныйМонолит --> Микросервисы: Все модули<br/>выделены

  Микросервисы --> [*]

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

Этот подход называют Strangler Fig Pattern (паттерн удушающей лианы). Новый сервис постепенно забирает на себя функциональность модуля, пока модуль не станет пустым и его можно будет удалить.

Практический пример: e-commerce платформа

Давайте спроектируем упрощённую e-commerce платформу как модульный монолит. Вот схема взаимодействия модулей:

100%
graph TD
  subgraph "API Gateway"
      GW["REST API<br/>единая точка входа"]
  end

  subgraph "Модуль: Каталог"
      CAT_API["CatalogApi"]
      CAT_SVC["CatalogService"]
      CAT_DB[("catalog_*<br/>таблицы")]
      CAT_API --> CAT_SVC --> CAT_DB
  end

  subgraph "Модуль: Корзина"
      CART_API["CartApi"]
      CART_SVC["CartService"]
      CART_DB[("cart_*<br/>таблицы")]
      CART_API --> CART_SVC --> CART_DB
  end

  subgraph "Модуль: Заказы"
      ORD_API["OrderApi"]
      ORD_SVC["OrderService"]
      ORD_DB[("order_*<br/>таблицы")]
      ORD_API --> ORD_SVC --> ORD_DB
  end

  subgraph "Модуль: Оплата"
      PAY_API["PaymentApi"]
      PAY_SVC["PaymentService"]
      PAY_DB[("payment_*<br/>таблицы")]
      PAY_API --> PAY_SVC --> PAY_DB
  end

  subgraph "Модуль: Уведомления"
      NOT_API["NotificationApi"]
      NOT_SVC["NotificationService"]
      NOT_DB[("notification_*<br/>таблицы")]
      NOT_API --> NOT_SVC --> NOT_DB
  end

  subgraph Shared
      EVT["EventBus"]
      AUTH["AuthModule"]
  end

  GW --> CAT_API
  GW --> CART_API
  GW --> ORD_API
  GW --> PAY_API

  CART_SVC -->|"получить цену"| CAT_API
  ORD_SVC -->|"создать платёж"| PAY_API
  ORD_SVC -->|"EventBus"| EVT
  EVT -->|"OrderCreated"| NOT_SVC
  EVT -->|"PaymentCompleted"| NOT_SVC
  PAY_SVC -->|"PaymentCompleted"| EVT

  style GW fill:#4a90d9,stroke:#2c5f8a,color:#fff
  style CAT_API fill:#50c878,stroke:#3a9a5c,color:#fff
  style CAT_SVC fill:#50c878,stroke:#3a9a5c,color:#fff
  style CAT_DB fill:#7b68ee,stroke:#5a4db2,color:#fff
  style CART_API fill:#f0a500,stroke:#c88400,color:#fff
  style CART_SVC fill:#f0a500,stroke:#c88400,color:#fff
  style CART_DB fill:#7b68ee,stroke:#5a4db2,color:#fff
  style ORD_API fill:#4a90d9,stroke:#2c5f8a,color:#fff
  style ORD_SVC fill:#4a90d9,stroke:#2c5f8a,color:#fff
  style ORD_DB fill:#7b68ee,stroke:#5a4db2,color:#fff
  style PAY_API fill:#50c878,stroke:#3a9a5c,color:#fff
  style PAY_SVC fill:#50c878,stroke:#3a9a5c,color:#fff
  style PAY_DB fill:#7b68ee,stroke:#5a4db2,color:#fff
  style NOT_API fill:#e0e0e0,stroke:#999,color:#333
  style NOT_SVC fill:#e0e0e0,stroke:#999,color:#333
  style NOT_DB fill:#7b68ee,stroke:#5a4db2,color:#fff
  style EVT fill:#f0a500,stroke:#c88400,color:#fff
  style AUTH fill:#e0e0e0,stroke:#999,color:#333

Обратите внимание на несколько важных моментов:

  1. Каждый модуль владеет своими таблицами — префиксы catalog_*, cart_*, order_* и т.д. Одна база данных, но логическое разделение. При необходимости можно выделить в отдельные схемы.

  2. Синхронные вызовы (сплошные стрелки) — для операций, где нужен немедленный ответ. Корзина спрашивает каталог о текущей цене товара — это должно произойти прямо сейчас.

  3. Асинхронные события (через EventBus) — для операций, которые не блокируют основной поток. Уведомления отправляются асинхронно — пользователю не нужно ждать, пока email дойдёт.

  4. Shared-модули — EventBus и AuthModule являются общей инфраструктурой. Это допустимо, потому что они не содержат бизнес-логики.

Чеклист: у вас точно модульный монолит?

Перед тем как гордо заявить на собеседовании «мы используем модульный монолит», пройдитесь по этому списку:

  • Модули выделены по бизнес-доменам, а не по техническим слоям
  • Каждый модуль имеет публичный API (интерфейс), через который с ним взаимодействуют другие модули
  • Прямой доступ к внутренностям чужого модуля запрещён (и это проверяется автоматически)
  • Каждый модуль владеет своими данными — нет JOIN’ов между таблицами разных модулей
  • Можно выделить любой модуль в отдельный сервис за 1–2 спринта
  • Есть документация по контрактам между модулями
  • Тесты модуля не зависят от внутренностей других модулей

Если у вас 5 из 7 — вы на правильном пути. Если 3 и меньше — у вас монолит с папками (и это не серебряная пуля, но хотя бы честно).

Заключение

Модульный монолит — это не «недомикросервисы» и не «монолит для бедных». Это зрелый архитектурный подход, который решает реальную проблему: как писать большие системы, не утонув в операционной сложности распределённых систем.

Ключевая мысль: архитектура — это не то, что вы рисуете на диаграммах. Это то, что реально происходит в коде. Можно нарисовать красивые модули, но если разработчики лезут в чужие внутренности — у вас монолит. Можно не рисовать ничего, но если границы соблюдаются — у вас модульная архитектура.

Начните с малого: выделите 2–3 модуля по бизнес-доменам, определите их API, запретите прямые зависимости. Через полгода вы скажете себе спасибо — или вашим разработчикам скажут спасибо те, кто придёт после них.

PS: Если ваш тимлид говорит «у нас модульный монолит», а разработчики делают import из любого пакета без ограничений — дайте ему ссылку почитать про ArchUnit.