Монолит или микросервисы? Спектр архитектурных решений: от Big Ball of Mud до модульного монолита
Архитектура — это не бинарный выбор
Индустрия любит спорить: «монолит — зло, микросервисы решают всё» против «микросервисы — переусложнение, монолит наше всё». Это ложная дихотомия. Архитектура не бинарна, это спектр. Между «грязным комком грязи» и распределённой системой из 200 сервисов лежат десятки промежуточных состояний — и большинство успешных систем находятся именно там.
Грязный монолит"] --> B["Layered Monolith
Слоёный монолит"] B --> C["Modular Monolith
Модульный монолит ★"] C --> D["Coarse SOA
Крупно-сервисная SOA"] D --> E["Microservices
Микросервисы"]
Ключевая мысль: каждый шаг вправо — осознанный компромисс. Вы платите сложностью за изоляцию, независимым масштабированием за eventual consistency, скоростью команд за распределёнными транзакциями. Нет универсально правильного ответа — есть правильный ответ для вашего контекста.
И главный секрет индустрии: большинство компаний, которые «перешли на микросервисы», на самом деле построили распределённый монолит — худшее из обоих миров. Сложность микросервисов с жёсткой связанностью монолита.
Спектр архитектурных решений
Big Ball of Mud (грязный монолит)
Это не архитектура. Это отсутствие архитектуры. Код, который вырос organically без какого-либо плана: спагетти-зависимости, отсутствие слоёв, бизнес-логика в контроллерах, SQL-запросы во view, глобальное состояние везде.
Как опознать:
- Чтобы поправить «цену товара», правите 14 файлов в разных папках
- Класс
UserServiceна 3000 строк — и это «сервисный слой» - Изменение в одном модуле ломает три других — и никто не понимает почему
- Нет тестов, потому что «потом напишем»
Как появляется: стартап спешит к product-market fit, технический долг копится, рефакторинг откладывается. Проблема не в том, что это монолит. Проблема в том, что это грязный монолит — отсутствие внутренних границ.
Слоёный монолит (Layered Monolith)
Классика: презентационный слой → бизнес-логика → слой данных. Это шаг вперёд от хаоса, но с фундаментальной проблемой: границы проходят по техническим слоям, а не по доменным.
В чём проблема: OrderService вызывает PaymentRepository напрямую, потому что «так быстрее». UserController дёргает ProductRepository — а почему бы и нет, импорт же доступен. Через год у вас слоёный монолит, в котором каждый сервис знает про каждый репозиторий, а границы слоёв существуют только на бумаге.
Слоёный монолит — хорошая отправная точка, если дисциплинированно соблюдать правило: сервис слоя N общается только со слоем N+1. Но на практике без инструментов принуждения (ArchUnit, compiler checks) границы неизбежно размываются.
Модульный монолит (Modular Monolith) ★
Это — золотой стандарт. Если вы запомните из этой статьи что-то одно — запомните это.
Модульный монолит — единый деплой-артефакт с жёсткими внутренними границами. Модули соответствуют Bounded Contexts из Domain-Driven Design: Заказы, Платежи, Доставка, Склад. Каждый модуль — автономный мир со своим API.
Ключевые принципы модульного монолита:
1. Domain Module API — каждый модуль выставляет фасад (interface + DTO), скрывающий внутренности. Модуль «Заказы» не знает, как модуль «Платежи» хранит транзакции. Он знает только PaymentFacade.charge(orderId, amount).
2. Коммуникация через API + асинхронные события — синхронные вызовы через фасады для операций, требующих немедленного ответа. Асинхронные события (in-process event bus) для остального: «заказ создан» → модуль лояльности начисляет баллы, модуль аналитики пишет метрику.
3. Разделение данных — общая БД, но разные схемы (schemas) или префиксы таблиц. Модуль «Заказы» читает только orders.*, модуль «Платежи» — только payments.*. Никаких прямых JOIN'ов через модули. Если модулю B нужны данные модуля A — только через API модуля A.
4. Принуждение границ — слоёный монолит полагается на дисциплину и соглашения. Модульный монолит полагается на инструменты:
- Java: ArchUnit — тесты, проверяющие зависимости между пакетами
- .NET: NetArchTest — аналог ArchUnit для .NET
- Компилятор: build modules (Gradle multi-project, Maven modules), где API модуля — отдельный JAR без транзитивных зависимостей
Пример ArchUnit-теста:
@AnalyzeClasses(packages = "com.example")
public class ArchitectureTest {
@ArchTest
static final ArchRule no_data_access_from_controllers =
classes().that().resideInAPackage("..controller..")
.should().onlyAccessClassesThat()
.resideInAnyPackage("..service..", "..dto..", "java..");
@ArchTest
static final ArchRule orders_independent_of_shipping =
slices().matching("com.example.(*)..")
.should().notDependOnEachOther()
.ignoreDependency(ordersSlice, shippingSlice); // разрешённые зависимости
}
Паттерны Криса Ричардсона (microservices.io) — три ключевых паттерна, формализующих модульный монолит:
| Паттерн | Суть |
|---|---|
| Modular Monolith | Код разделён на модули по bounded context, деплой единый |
| Domain Module API | Каждый модуль выставляет публичный API-фасад, скрывая реализацию |
| Domain API Build Module | API модуля — отдельный build-артефакт без транзитивных зависимостей. Модуль B зависит от orders-api.jar, а не от orders-impl.jar |
Почему модульный монолит — золотой стандарт: он даёт 80% преимуществ микросервисов (изоляция команд, понятные границы, возможность сплита в будущем) при 20% их сложности. Нет сетевых задержек, нет распределённых транзакций, нет проблем с дебаггингом распределённых систем. Это архитектура, готовая к сплиту, но не требующая его прямо сейчас.
Majestic Modular Monolith
Аксель Фонтейн (создатель Flyway) сформулировал концепцию Majestic Modular Monolith — «третий путь»:
Микросервисы — не цель. Цель — архитектура, которая позволяет командам двигаться быстро. Модульный монолит с дисциплиной даёт это без цены распределённой системы.
Ключевой посыл: модульный монолит — не «переходный этап» к микросервисам, а конечная архитектура. Если у вас 50 разработчиков, единый деплой и чёткие границы модулей — вы в отличной форме. Не надо «дозревать» до микросервисов ради моды.
Фонтейн подчёркивает: majesic монолит требует архитектурной дисциплины. Это не «ну мы типа соблюдаем границы». Это формальные проверки в CI/CD: любой PR, нарушающий границы модулей, падает на стадии сборки. Без этого модульный монолит за 3-6 месяцев деградирует в слоёный, а затем в грязный.
Coarse SOA (сервис-ориентированная архитектура)
Переходный этап: из монолита выделили 3-4 крупных сервиса. Не микросервисы — сервисы. Каждый всё ещё содержит несколько bounded context'ов внутри. Сетевые вызовы присутствуют, но их мало — сервисы крупные и самодостаточные.
Типичный путь: модульный монолит → выделили модуль «Поиск» в отдельный сервис (потому что Elasticsearch требует других ресурсов) → выделили «Аналитику» (потому что batch-нагрузка мешает онлайн-обработке). Остальное — всё ещё в монолите.
Coarse SOA — прагматичная остановка. Большинству компаний не нужны 50 микросервисов. 3-5 сервисов решают реальные проблемы масштабирования, не создавая ад распределённых систем.
Микросервисы
Когда это правильный выбор:
- Команды действительно автономны — разные команды владеют разными сервисами, релизные циклы независимы
- Разное масштабирование — сервис поиска потребляет CPU, сервис загрузки файлов — I/O, масштабируются по-разному
- Разные технологические требования — сервис видеообработки на C++, всё остальное на Python
- Organisational scaling — 100+ разработчиков, один монолит становится bottleneck'ом деплоя
Микросервисы — не цель, а инструмент. Они решают проблемы масштаба организации, а не проблемы кода. Если ваша проблема — «грязный код» в монолите, микросервисы не помогут: вы получите грязный код, размазанный по сети, с сетевыми задержками в придачу.
Monolith First: почему не надо стартовать с микросервисов
Мартин Фаулер в статье MonolithFirst сформулировал правило: начинайте с монолита, даже если планируете микросервисы в будущем. Аргументы:
YAGNI: вы не знаете границы
В первые 6-12 месяцев жизни продукта вы не знаете, где пройдут границы bounded context'ов. Вы их открываете — через эксперименты, фичи, откаты. В монолите передвинуть границу между «Заказами» и «Платежами» — это рефакторинг в IDE. В микросервисах — переписывание контрактов, миграция данных, координация релизов трёх команд.
BoundedContext Discovery — дорого в микросервисах
Сэм Ньюман («Monolith to Microservices») называет это MicroservicePremium — наценкой, которую вы платите за каждый неверно проведённый сервисный шов:
- В монолите: переименовать поле
amountвtotalPrice— глобальный search-and-replace, 5 минут - В микросервисах: изменить поле в сервисе A → обновить API-контракт → убедиться, что сервисы B, C, D обработают новое поле → координировать деплой 4 сервисов → молиться, чтобы ничего не сломалось
Чем раньше вы проводите сервисные швы, тем дороже ошибки. Поэтому: сначала монолит, открытие границ, стабилизация домена — и только потом сплит.
Sacrificial Architecture
Ещё одна концепция от Фаулера: первая версия вашей архитектуры — жертвенная. Вы её выбросите. Это нормально. Монолит позволяет выбросить дёшево — это один репозиторий. Выбросить 15 микросервисов, каждый со своим CI/CD, мониторингом и командой — политически и технически намного сложнее.
«Almost all the successful microservice stories have started with a monolith that got too big and was broken up» — Martin Fowler
Когда оставаться на монолите: дерево принятия решений
Не каждая система должна «дорасти» до микросервисов. Для многих — монолит (особенно модульный) является конечной, а не промежуточной архитектурой.
разных компонентов?"} R1["Модульный монолит ★
Остановитесь здесь"] R2["Микросервисы
Осознанный выбор"] Q1 -- "Да" --> R1 Q1 -- "Нет" --> Q2 Q2 -- "Да" --> R1 Q2 -- "Нет" --> Q3 Q3 -- "Да" --> R1 Q3 -- "Нет" --> Q4 Q4 -- "Нет" --> R1 Q4 -- "Да" --> Q5 Q5 -- "Нет" --> R1 Q5 -- "Да" --> Q6 Q6 -- "Нет" --> R1 Q6 -- "Да" --> R2
Как читать это дерево: если вы ответили «Да» хотя бы на один из первых пяти вопросов — монолит (предпочтительно модульный) — ваш правильный выбор. Микросервисы оправданы только когда у вас одновременно: большая команда, высокая нагрузка, отсутствие жёстких требований к транзакционной целостности, зрелый DevOps и стабильный домен.
Сравнительная таблица
| Критерий | Монолит | Модульный монолит | Микросервисы |
|---|---|---|---|
| Развёртывание | 1 артефакт | 1 артефакт | N артефактов |
| Масштабирование | Всё или ничего | Всё или ничего | Независимо |
| Данные | Одна БД, ACID | Одна БД, ACID (partitioned) | БД на сервис, eventual consistency |
| Коммуникация | In-process | In-process + события | Сеть (REST/gRPC/сообщения) |
| Отказоустойчивость | Полный отказ | Полный отказ | Частичная изоляция |
| Команды | 1 команда | Несколько, коорд. на деплой | Автономные команды |
| Сложность | Низкая | Средняя | Высокая |
| Скорость разработки | Высокая (старт) | Высокая | Низкая (старт), средняя (зрелость) |
| Тестируемость | E2E тесты простые | Модульные + E2E | Контрактное тестирование необходимо |
| Стоимость инфраструктуры | Низкая | Низкая | Высокая (оркестрация, service mesh, мониторинг) |
Как правильно строить модульный монолит
Пять практик, которые превращают слоёный монолит в модульный:
1. Разбивай по Bounded Contexts (DDD), а не по техническим слоям
Плохо: пакеты controllers/, services/, repositories/ — каждый содержит классы со всех доменов. Зависимости неочевидны.
Хорошо: пакеты orders/, payments/, shipping/ — внутри каждого свои контроллеры, сервисы и репозитории. Зависимости явны: orders зависит от payments, но не наоборот.
com.example/
├── orders/
│ ├── OrdersController.java
│ ├── OrderService.java
│ └── OrderRepository.java
├── payments/
│ ├── PaymentFacade.java ← Публичное API модуля
│ ├── PaymentServiceImpl.java ← Внутренняя реализация
│ └── PaymentRepository.java
└── shared/
├── events/
└── kernel/ ← Shared Kernel (DDD)
2. Enforce module boundaries через тесты
Правила должны проверяться в CI/CD. Пример для ArchUnit:
// Модуль orders НЕ должен зависеть от модуля shipping напрямую
@ArchTest
static final ArchRule modules_respect_boundaries = layeredArchitecture()
.consideringOnlyDependenciesInLayers()
.layer("Orders").definedBy("com.example.orders..")
.layer("Payments").definedBy("com.example.payments..")
.layer("Shipping").definedBy("com.example.shipping..")
.whereLayer("Orders").mayOnlyAccessLayers("Payments", "Shared")
.whereLayer("Payments").mayNotAccessAnyLayer();
Результат: разработчик физически не может смержить PR, нарушающий границы модулей. Архитектура принуждается автоматически.
3. Выставляй фасадные API модулей
Каждый модуль определяет интерфейс-фасад. Внешние модули зависят только от интерфейса:
// payments/api/PaymentFacade.java — в отдельном build-модуле
public interface PaymentFacade {
PaymentResult charge(OrderId orderId, Money amount);
RefundResult refund(PaymentId paymentId);
}
// payments/internal/PaymentServiceImpl.java — реализация
class PaymentServiceImpl implements PaymentFacade {
// внутренняя логика, скрытая от других модулей
}
Модуль orders зависит только от payments-api.jar, а не от payments-impl.jar. Сменить реализацию платёжного адаптера, не трогая модуль заказов? Без проблем.
4. Асинхронный messaging между модулями
Не всё должно быть синхронным вызовом через фасад. События разрывают временну́ю связанность:
// Модуль Orders публикует событие
eventBus.publish(new OrderCreated(orderId, customerId, totalAmount));
// Модуль Loyalty подписан — начисляет баллы
@Subscribe
public void onOrderCreated(OrderCreated event) {
loyaltyService.addPoints(event.customerId(), calculatePoints(event.totalAmount()));
}
// Модуль Analytics подписан — обновляет метрики
@Subscribe
public void onOrderCreated(OrderCreated event) {
metrics.increment("orders.created");
}
Для in-process messaging в монолите подходят: Guava EventBus, Spring Application Events, самописный легковесный EventBus (буквально 50 строк). Не нужен Kafka — вы в одном процессе.
5. Separate build modules для API
Структура build-модулей в Gradle:
orders/
├── orders-api/ ← Интерфейсы и DTO, НИКАКИХ зависимостей кроме shared
├── orders-impl/ ← Реализация, зависит от orders-api, payments-api, shared
payments/
├── payments-api/
├── payments-impl/
orders-impl зависит от payments-api, но не от payments-impl. Компилятор гарантирует: модуль заказов не может использовать внутренние классы модуля платежей. Это compile-time enforcement — жёстче, чем ArchUnit.
Архитектурный квант (Architectural Quantum)
Концепция из книги «Building Evolutionary Architectures» (Форд, Парсонс, Куа). Определение:
Архитектурный квант — минимальная независимо развёртываемая единица системы с высокой функциональной связностью и синхронной связанностью.
Проще: квант — это то, что вы деплоите как единое целое и что не может быть разделено без нарушения функциональности. Всё внутри кванта коммуницирует синхронно и обязано деплоиться вместе.
Монолит = 1 квант. Все компоненты разделяют судьбу: деплой, отказ, масштабирование.
Модульный монолит = 1 квант, но с узнаваемыми будущими квантами. Границы модулей — это «пунктирные линии» будущих сервисных швов. Когда модуль «Платежи» станет узким местом — вы уже знаете, где резать. Вся внутренняя коммуникация уже идёт через фасад, данные разделены по схемам.
Микросервисы = N квантов. Каждый сервис — отдельный квант со своей судьбой: свой деплой, своя БД, своя команда. Связанность между квантами асинхронная или синхронная через сеть.
Практический вывод: не стройте микросервисы, пока не можете перечислить свои архитектурные кванты. Если вы не знаете, где проходят швы — модульный монолит. Модульный монолит — это архитектура, которая документирует будущие кванты, не разделяя их физически раньше времени.
Что читать дальше
| Книга | Почему читать |
|---|---|
| Building Evolutionary Architectures (Ford, Parsons, Kua) | Концепция архитектурных квантов, fitness functions, evolutionary change. Фундамент для понимания, когда менять архитектуру |
| Monolith to Microservices (Sam Newman) | Практическое руководство по сплиту монолита: паттерны Strangler Fig, UI Composition, Branch by Abstraction, миграция данных |
| Fundamentals of Software Architecture (Richards, Ford) | Архитектурные характеристики (-ilities), архитектурное мышление, сравнение стилей. Первая книга, которую стоит прочитать про архитектуру |
| Software Architecture: The Hard Parts (Ford et al.) | Сложные решения: распределённые транзакции, саги, data ownership, когда нарушать «правила». Продолжение Fundamentals — для тех, кто уже столкнулся с болью распределённых систем |
| Domain-Driven Design (Eric Evans) | Bounded Contexts, Ubiquitous Language, Aggregates. DDD — это то, как вы находите границы модулей в монолите и сервисов в микросервисах |
| Clean Architecture (Robert Martin) | Принцип «зависимости указывают внутрь», инверсия зависимостей, архитектурные границы. Применяется на всех точках спектра |
Одна книга: если читать что-то одно — Fundamentals of Software Architecture. Две: добавить Monolith to Microservices. Три: добавить Building Evolutionary Architectures.
Связанные статьи на сайте
- Справочник архитектурных паттернов — полная таксономия: от монолита до AI-архитектур, Mermaid-диаграммы
- Event-Driven Architecture: когда события управляют системой — event sourcing, CQRS, Kafka vs RabbitMQ, когда EDA оправдана
- Архитектурные роли в ИТ-командах — кто принимает архитектурные решения и за что отвечает
- C4 Model: документируем архитектуру — визуализация архитектуры на 4 уровнях детализации
- ADR: архитектурные решения — как документировать архитектурные решения и почему это важно