Кладовочка

О разном, чтобы было под рукой онлайн

Монолит или микросервисы? Спектр архитектурных решений: от Big Ball of Mud до модульного монолита

Архитектура — это не бинарный выбор

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

flowchart LR A["Big Ball of Mud
Грязный монолит"] --> 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)

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

flowchart TB subgraph Presentation["Presentation Layer"] C["Controllers / REST API"] end subgraph Business["Business Logic Layer"] S["Services"] end subgraph Data["Data Access Layer"] R["Repositories / DAO"] end subgraph DB["Database"] T["Tables"] end Presentation --> Business --> Data --> DB

В чём проблема: OrderService вызывает PaymentRepository напрямую, потому что «так быстрее». UserController дёргает ProductRepository — а почему бы и нет, импорт же доступен. Через год у вас слоёный монолит, в котором каждый сервис знает про каждый репозиторий, а границы слоёв существуют только на бумаге.

Слоёный монолит — хорошая отправная точка, если дисциплинированно соблюдать правило: сервис слоя N общается только со слоем N+1. Но на практике без инструментов принуждения (ArchUnit, compiler checks) границы неизбежно размываются.

Модульный монолит (Modular Monolith) ★

Это — золотой стандарт. Если вы запомните из этой статьи что-то одно — запомните это.

Модульный монолит — единый деплой-артефакт с жёсткими внутренними границами. Модули соответствуют Bounded Contexts из Domain-Driven Design: Заказы, Платежи, Доставка, Склад. Каждый модуль — автономный мир со своим API.

C4Container title Модульный монолит — внутренняя структура Container_Boundary(monolith, "Модульный монолит") { Container(orders, "Модуль Заказы", "In-process", "Управление заказами: создание, статусы, история") Container(payments, "Модуль Платежи", "In-process", "Обработка платежей, refunds, интеграция с платёжными шлюзами") Container(shipping, "Модуль Доставка", "In-process", "Расчёт стоимости, трекинг, интеграция с курьерскими службами") Container(inventory, "Модуль Склад", "In-process", "Остатки, резервирование, пополнение") ContainerDb(db_orders, "Схема orders", "PostgreSQL", "Заказы и позиции заказов") ContainerDb(db_payments, "Схема payments", "PostgreSQL", "Транзакции и refunds") ContainerDb(db_shipping, "Схема shipping", "PostgreSQL", "Отправления и трекинг") ContainerDb(db_inventory, "Схема inventory", "PostgreSQL", "SKU, остатки, резервирования") } Rel(orders, payments, "Вызывает PaymentFacade", "in-process") Rel(orders, shipping, "Вызывает ShippingFacade", "in-process") Rel(orders, inventory, "Вызывает InventoryFacade", "in-process") Rel(payments, db_payments, "Читает/пишет", "JDBC") Rel(orders, db_orders, "Читает/пишет", "JDBC") Rel(shipping, db_shipping, "Читает/пишет", "JDBC") Rel(inventory, db_inventory, "Читает/пишет", "JDBC") UpdateLayoutConfig($C4Shape, "false")

Ключевые принципы модульного монолита:

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


Когда оставаться на монолите: дерево принятия решений

Не каждая система должна «дорасти» до микросервисов. Для многих — монолит (особенно модульный) является конечной, а не промежуточной архитектурой.

flowchart TD Q1{"Команда < 10-15 разработчиков?"} Q2{"Нагрузка < 1M запросов/день?"} Q3{"Критичны ACID-транзакции?"} Q4{"Зрелая DevOps-практика?"} Q5{"Домен стабилен?"} Q6{"Разные требования к масштабированию
разных компонентов?"} 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» (Форд, Парсонс, Куа). Определение:

Архитектурный квант — минимальная независимо развёртываемая единица системы с высокой функциональной связностью и синхронной связанностью.

Проще: квант — это то, что вы деплоите как единое целое и что не может быть разделено без нарушения функциональности. Всё внутри кванта коммуницирует синхронно и обязано деплоиться вместе.

flowchart TB subgraph Monolith["Монолит — 1 квант"] direction LR M1[Модуль A] M2[Модуль B] M3[Модуль C] M1 <--> M2 M2 <--> M3 end subgraph Micro["Микросервисы — N квантов"] direction LR S1[Сервис A] S2[Сервис B] S3[Сервис C] end Monolith -->|"Сплит когда кванты определены"| Micro

Монолит = 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.


Связанные статьи на сайте