Монолит и модульный монолит: недооценённый выбор
Слово «монолит» за последнее десятилетие превратилось в диагноз. На собеседованиях его произносят с извиняющейся интонацией: «ну, у нас пока монолит, но мы, конечно, пилим на сервисы». В резюме пишут «опыт миграции с монолита на микросервисы» — как будто направление стрелки самоочевидно и обратного движения не бывает.
Между тем обратное движение бывает, и у очень заметных компаний. Amazon Prime Video в 2023 году публично описал переход своего сервиса мониторинга аудио/видео с распределённой архитектуры на serverless-компонентах обратно в единый процесс — и получил снижение стоимости инфраструктуры на 90%. Shopify до сих пор работает на одном из крупнейших Rails-монолитов в мире и вкладывается не в распил, а в модуляризацию. Stack Overflow годами обслуживал десятки миллионов пользователей на девяти веб-серверах и одном приложении.
Значит, вопрос не в том, «монолит или микросервисы», а в том, какую именно проблему решает разделение на отдельно развёртываемые единицы и готовы ли вы платить за это решение. Эта статья — про то, что монолит бывает разный, что подавляющая часть боли, которую приписывают монолиту, вызвана не единым процессом, а отсутствием внутренних границ, и что существует архитектура, забирающая почти все преимущества модульности, не платя налог распределённости: модульный монолит (он же modulith).
Если вы ещё не читали статью «Многоуровневая, гексагональная, луковичная и чистая архитектуры», стоит начать оттуда: здесь мы активно пользуемся понятиями «порт», «адаптер» и «направление зависимости».
1. Три разных вещи, которые называют одним словом
Первый источник путаницы — смешение трёх независимых осей. Разведём их явно.
Ось 1. Единица развёртывания. Монолит — это система, которая собирается в один артефакт (jar, бинарник, образ, набор файлов) и развёртывается целиком, за один шаг. Противоположность — распределённая система из нескольких независимо развёртываемых единиц.
Ось 2. Внутренняя структура кода. Код может быть модульным (явные компоненты с контрактами) или монолитным по структуре — «big ball of mud», где всё зовёт всё.
Ось 3. Модель данных. Одна схема, к которой все ходят напрямую, — или изолированные наборы таблиц с владельцем у каждого.
Эти оси ортогональны. Существует прекрасно структурированный монолит и существуют «распределённые big ball of mud» — микросервисы, которые ходят друг к другу в базу и разворачиваются только вместе (это состояние называют distributed monolith, и это худший из четырёх квадрантов: вы платите за распределённость, не получая независимости).
Главный тезис статьи читается прямо с этой картинки: движение вверх (модульность) и движение вправо (распределённость) — это разные проекты с разной ценой и разной отдачей. Почти всегда сначала нужно первое, и очень часто второе после первого уже не требуется.
2. Что на самом деле болит в «монолите»
Соберём типичные жалобы и честно спросим по каждой: это следствие единого процесса или отсутствия границ?
| Жалоба | Настоящая причина | Лечится распилом? |
|---|---|---|
| «Страшно менять код — не знаю, что сломается» | Отсутствие границ, неявные зависимости | Нет, лечится модульностью |
| «Релиз занимает три часа и требует шести согласований» | Процесс релиза, слабые тесты | Частично, но дешевле починить CI |
| «Тесты идут 40 минут» | Всё через реальную БД, нет разделения на юниты/интеграцию | Нет |
| «Команды блокируют друг друга» | Общие файлы, общий владелец релиза | Частично (см. ниже) |
| «Один медленный эндпоинт кладёт весь сервис» | Нет изоляции ресурсов (bulkhead) | Да, но есть in-process способы |
| «Не можем масштабировать поиск отдельно от чекаута» | Единый профиль ресурсов | Да, это честная причина |
| «Нужен Go для видеокодека, а приложение на Python» | Единый рантайм | Да, это честная причина |
Из семи пунктов распилом однозначно лечатся два с половиной. Остальные — внутренние проблемы, которые после распила никуда не денутся, а станут дороже: неявная зависимость между модулями превращается после разделения в неявную зависимость между сервисами, только теперь она проходит по сети, отказывает частично и не ловится компилятором.
Правило, которое стоит запомнить: распил не создаёт границы, он их материализует. Если границ не было, вы просто разложите беспорядок по разным машинам.
Цена распределённости, которую редко считают
Когда вызов метода превращается в сетевой вызов, вы получаете набор новых обязательных задач:
- Частичные отказы. Локальный вызов либо выполняется, либо бросает исключение. Удалённый может ещё и «зависнуть навсегда», «выполниться, но не ответить», «выполниться дважды». Появляются таймауты, ретраи, идемпотентность, circuit breaker — см. «Устойчивость».
- Отсутствие транзакций. ACID-транзакция через границу процесса недоступна; нужны saga, outbox и компенсации — см. «Saga и распределённые транзакции».
- Сериализация и версионирование контрактов. Каждый контракт становится публичным API с обратной совместимостью — см. «Стили API».
- Наблюдаемость. Один стектрейс превращается в распределённую трассировку, которую надо ещё собрать и научиться читать.
- Инфраструктура. Service discovery, конфигурация, секреты, сеть, мониторинг, оркестрация — умноженные на количество сервисов.
Полезная количественная интуиция. Локальный вызов метода — единицы наносекунд. Вызов по сети внутри дата-центра — сотни микросекунд, то есть примерно в 10⁴–10⁵ раз дороже. Обычно это неважно, но становится важным, когда одна пользовательская операция «размазана» по цепочке из десяти сервисов: латентность складывается, а доступность умножается. Десять сервисов с индивидуальной доступностью 99.9% в последовательной цепочке дают 0.999¹⁰ ≈ 99.0% — то есть с двух с половиной минут простоя в сутки вы переезжаете на четверть часа.
Смысл графика: у распределённой системы высокая базовая стоимость (её платят с первого дня, даже на трёх разработчиках) и пологий рост. У связного монолита низкий старт и квадратичный рост — потому что число потенциальных связей между n компонентами есть O(n²), и без ограничений граф зависимостей действительно стремится к полному. Модульный монолит — это попытка получить пологую кривую, не платя базовую цену: связи ограничены контрактами, поэтому рост ближе к O(n), а сеть в игру не вступает.
Точка пересечения — не абстракция: это конкретный момент в жизни продукта, и о нём стоит принимать явное решение с записью в ADR (см. «Архитектурные решения»).
3. Модульный монолит: определение и свойства
Модульный монолит — система, которая развёртывается как единый процесс, но внутри разделена на модули с явными публичными контрактами, собственными данными и запретом на обращение к внутренностям соседей, причём этот запрет проверяется автоматически.
Ключевые свойства:
- Единица развёртывания одна. Один артефакт, одна транзакция, один стектрейс, один процесс отладки.
- Модуль владеет своими данными. Никто не читает чужие таблицы напрямую — только через API модуля.
- Публичная поверхность модуля минимальна. Наружу торчит фасад и набор DTO; доменные сущности остаются внутри.
- Зависимости между модулями явные и ациклические. Циклы запрещены и ловятся сборкой.
- Правила проверяет машина, а не ревьюер. Договорённость, которую не проверяет CI, — это не архитектура, а пожелание.
Последний пункт — самый важный и чаще всего пропускаемый. Любая архитектурная граница, охраняемая только дисциплиной людей, разрушается за срок порядка одного квартала: приходит дедлайн, приходит новый разработчик, приходит «ну тут же всего один импорт».
Как выглядит структура на диске
src/
shared_kernel/ # только примитивы: Money, UserId, Result, часы
modules/
orders/
api/ # ПУБЛИЧНОЕ: фасад + DTO + события
__init__.py
contract.py
events.py
internal/ # ПРИВАТНОЕ: домен, БД, всё остальное
domain/
application/
infrastructure/
billing/
api/
internal/
shipping/
api/
internal/
platform/ # http-роутинг, DI, миграции, конфигурация
Каждый модуль внутри себя может быть устроен как угодно — слоями, гексагонально, транзакционным скриптом. Это его частное дело, и в этом главная выгода: выбор внутренней архитектуры становится локальным решением. Модуль reporting вправе быть тонким слоем над SQL, а модуль pricing — иметь богатую доменную модель.
4. Где проводить границы
Границы модулей — это самое трудное и самое ценное решение. Ошибка здесь не компенсируется никакой техникой.
Плохой критерий: технические слои. Модули controllers, services, repositories, models — это не модули, а слои, размазанные по всей системе. Любая бизнес-фича задевает все четыре, значит, границы не снижают стоимость изменения.
Хороший критерий: бизнес-способности и языковые швы. Модуль — это область, у которой свой язык, свой владелец и свой темп изменений. В DDD это bounded context: место, где слово «заказ» означает ровно одно. Для orders заказ — это корзина с позициями и статусом; для billing — сумма с валютой и налогом; для shipping — коробка с весом и адресом. Это три разных заказа, и попытка сделать одну «универсальную» сущность Order — самый надёжный способ получить big ball of mud.
Практические эвристики поиска швов:
- Соотнесённость изменений. Соберите историю коммитов и постройте матрицу: какие файлы меняются вместе. Файлы с высокой совместной изменяемостью должны жить в одном модуле. Это дёшево посчитать:
# Грубый, но рабочий сигнал о связности: какие каталоги чаще всего правят в одном коммите
git log --format='%H' --name-only --since='1 year ago' \
| awk '/^[0-9a-f]{40}$/{c=$0;next} NF{split($0,a,"/"); print c"\t"a[1]"/"a[2]}' \
| sort -u | cut -f2 | sort | uniq -c | sort -rn | head -30
- Организационный шов. Закон Конвея работает в обе стороны: структура системы стремится к структуре коммуникаций. Если две области кода принадлежат разным командам, между ними обязана быть граница — иначе она возникнет всё равно, но в виде конфликтов в merge request.
- Разный темп и разные требования. Каталог меняется еженедельно и терпит устаревание на минуту; расчёт налогов меняется раз в год, но обязан быть точным до копейки. Это разные модули.
- Данные, которые не хочется соединять. Если между двумя группами таблиц почти нет естественных JOIN — это шов.
оптимизируем скорость обучения] B -- "Да, язык устоялся" --> D{Сколько команд будет через год?} D -- "1–3" --> E[Модульный монолит
границы по bounded context] D -- "> 5, независимые релизы критичны" --> F{Есть ли платформа:
CI/CD, трассировка, дежурства?} F -- "Нет" --> G[Сначала модульный монолит
параллельно строим платформу] F -- "Да" --> H{Есть ли модуль с иным профилем:
нагрузка, рантайм, комплаенс?} H -- "Нет" --> E H -- "Да" --> I[Выделяем именно этот модуль
остальное остаётся монолитом] C --> J[Через 2–3 квартала пересматриваем] J --> D
Отдельно подчеркнём ветку C. На ранней стадии продукта границы вредны, если проведены наугад: неправильная граница дороже отсутствующей, потому что её приходится пересекать при каждом изменении. Мартин Фаулер сформулировал это как MonolithFirst: почти все успешные микросервисные системы, которые он наблюдал, начинались как монолит, а почти все системы, начатые сразу как микросервисы, столкнулись с серьёзными проблемами границ.
5. Реализация: контракт модуля
Перейдём к коду. Возьмём модуль billing, к которому обращается orders.
Публичный контракт — единственная точка входа. Он оперирует собственными простыми типами (DTO), а не доменными сущностями:
# modules/billing/api/contract.py — ПУБЛИЧНОЕ, часть контракта
from __future__ import annotations
from dataclasses import dataclass
from decimal import Decimal
from typing import Protocol
from uuid import UUID
@dataclass(frozen=True)
class InvoiceView:
"""DTO наружу. Никогда не отдаём наружу доменную сущность Invoice:
иначе внешний код начнёт зависеть от её внутренностей, и граница исчезнет."""
invoice_id: UUID
order_id: UUID
amount: Decimal
currency: str
status: str # "draft" | "issued" | "paid" | "void"
class BillingApi(Protocol):
"""Порт модуля billing. Специально узкий: чем меньше поверхность,
тем дешевле потом заменить реализацию на удалённый вызов."""
def issue_invoice(self, order_id: UUID, amount: Decimal, currency: str) -> InvoiceView:
...
def get_invoice(self, invoice_id: UUID) -> InvoiceView | None:
...
Реализация живёт во внутренностях и не экспортируется:
# modules/billing/internal/application/service.py — ПРИВАТНОЕ
from modules.billing.api.contract import BillingApi, InvoiceView
from modules.billing.api.events import InvoiceIssued
from modules.billing.internal.domain.invoice import Invoice
class BillingService(BillingApi):
def __init__(self, repo: InvoiceRepository, bus: EventBus) -> None:
self._repo = repo
self._bus = bus
def issue_invoice(self, order_id: UUID, amount: Decimal, currency: str) -> InvoiceView:
if amount <= 0:
# Инварианты домена проверяем внутри модуля — снаружи о них знать не обязаны
raise ValueError("сумма счёта должна быть положительной")
invoice = Invoice.issue(invoice_id=uuid4(), order_id=order_id,
amount=amount, currency=currency)
self._repo.add(invoice)
# Событие публикуем в ту же транзакцию, что и запись в БД.
# Это и есть главное преимущество монолита: атомарность бесплатна.
self._bus.publish(InvoiceIssued(
invoice_id=invoice.id, order_id=order_id,
amount=amount, currency=currency,
))
return self._to_view(invoice)
@staticmethod
def _to_view(invoice: Invoice) -> InvoiceView:
# Наружу — только DTO. Доменная сущность не покидает модуль.
return InvoiceView(invoice.id, invoice.order_id, invoice.amount,
invoice.currency, invoice.status.value)
Потребитель зависит только от протокола, а конкретная реализация подставляется композиционным корнем приложения:
# modules/orders/internal/application/checkout.py
from decimal import Decimal
from uuid import UUID
from modules.billing.api.contract import BillingApi # можно: это публичный api
# from modules.billing.internal... import ... # НЕЛЬЗЯ: линтер уронит сборку
class CheckoutService:
def __init__(self, billing: BillingApi, orders_repo: "OrderRepository") -> None:
self._billing = billing
self._orders = orders_repo
def checkout(self, order_id: UUID) -> None:
order = self._orders.get_or_fail(order_id)
order.confirm() # инварианты заказа — внутри orders
self._orders.save(order)
# Синхронный вызов соседа: в монолите он в той же транзакции,
# значит откат счёта при падении заказа происходит сам собой.
self._billing.issue_invoice(order.id, Decimal(order.total), order.currency)
Два способа связать модули
Синхронно через порт — когда результат нужен здесь и сейчас и вызывающий отвечает за исход операции. Создаёт зависимость по времени и направление в графе: orders → billing.
Асинхронно через событие — когда сосед просто должен узнать о факте. Публикующий не знает подписчиков, направление зависимости разворачивается: shipping зависит от контракта события billing, а не наоборот. Это способ разорвать цикл, если два модуля «хотят» ссылаться друг на друга.
Внутрипроцессная шина, готовая к будущему выносу за пределы процесса:
# platform/events.py
class EventBus:
"""События — неизменяемые и сериализуемые: так их можно будет без
переписывания отправить в брокер, когда модуль поедет в отдельный сервис."""
def __init__(self) -> None:
self._handlers: dict[type, list[Callable]] = defaultdict(list)
self._pending: list[DomainEvent] = []
def subscribe(self, event_type: type[T], handler: Callable[[T], None]) -> None:
self._handlers[event_type].append(handler)
def publish(self, event: DomainEvent) -> None:
# КЛЮЧЕВОЕ РЕШЕНИЕ: не вызываем обработчики сразу, копим до коммита.
# Иначе подписчик увидит данные, которых после отката не существует.
self._pending.append(event)
def dispatch_after_commit(self) -> None:
"""Вызывается ровно один раз из middleware/UoW после успешного commit."""
events, self._pending = self._pending, []
for event in events:
for handler in self._handlers[type(event)]:
try:
handler(event)
except Exception:
# Подписчик уронил себя, но не транзакцию издателя.
# В проде здесь — логирование и dead letter.
log.exception("обработчик %s упал", type(event).__name__)
Обратите внимание на dispatch_after_commit: это одна из немногих вещей, которую в монолите легко сделать неправильно. Синхронная доставка событий внутри транзакции превращает «слабую связь» в жёсткую — исключение подписчика откатывает работу издателя, и вы получаете все минусы связности при иллюзии её отсутствия.
в микросервисах здесь была бы сага O->>Bus: dispatch_after_commit() Bus->>S: on_invoice_issued(event) activate S S->>DB: INSERT INTO shipping.shipment (новая транзакция) deactivate S O-->>HTTP: 200 OK deactivate O
6. Границы, которые проверяет машина
Повторим: договорённость без автоматической проверки не работает. Ниже — реальные инструменты по экосистемам.
Python — import-linter (документация). Кладём в setup.cfg или .importlinter и запускаем в CI:
[importlinter]
root_package = src
include_external_packages = True
[importlinter:contract:1]
name = Модули не лезут во внутренности соседей
type = forbidden
source_modules =
src.modules.orders
src.modules.shipping
forbidden_modules =
src.modules.billing.internal
src.modules.orders.internal
[importlinter:contract:2]
name = Слои уложены сверху вниз (platform -> modules -> shared_kernel)
type = layers
layers =
src.platform
src.modules
src.shared_kernel
[importlinter:contract:3]
name = Модули независимы (циклы запрещены)
type = independence
modules =
src.modules.orders
src.modules.billing
src.modules.shipping
Контракт independence — самый жёсткий: он запрещает вообще любые импорты между перечисленными модулями. На практике его включают, когда всё межмодульное общение переведено на события; для синхронных вызовов используют forbidden с точечными исключениями на api-пакеты.
Java/Kotlin — ArchUnit (archunit.org) или Spring Modulith (docs):
@AnalyzeClasses(packages = "com.shop", importOptions = ImportOption.DoNotIncludeTests.class)
class ArchitectureTest {
// Внутренности модуля недоступны снаружи
@ArchTest
static final ArchRule internals_are_private = SlicesRuleDefinition.slices()
.matching("com.shop.modules.(*)..")
.should().notDependOnEachOther()
.ignoreDependency(
DescribedPredicate.alwaysTrue(),
JavaClass.Predicates.resideInAPackage("..api.."));
// Циклов между модулями быть не должно
@ArchTest
static final ArchRule no_cycles = SlicesRuleDefinition.slices()
.matching("com.shop.modules.(*)..")
.should().beFreeOfCycles();
}
Spring Modulith идёт дальше: он выводит структуру модулей из пакетов, проверяет её тестом ApplicationModules.of(App.class).verify(), генерирует документацию и — что особенно ценно — умеет заменять внутрипроцессные события на транзакционный outbox одной аннотацией, готовя приложение к выносу модуля.
Go — механизм internal, встроенный в компилятор. Это самый честный вариант: правило проверяется не линтером, а сборкой. Пакет по пути .../orders/internal/... импортируется только из поддерева orders:
// modules/billing/api/contract.go — публичный контракт модуля
package api
type InvoiceView struct {
InvoiceID uuid.UUID
OrderID uuid.UUID
AmountMinor int64 // деньги — только в минорных единицах, никаких float
Currency string
Status string
}
type Billing interface {
IssueInvoice(ctx context.Context, orderID uuid.UUID, amountMinor int64, cur string) (InvoiceView, error)
GetInvoice(ctx context.Context, invoiceID uuid.UUID) (InvoiceView, error)
}
// modules/orders/internal/app/checkout.go:
// import "shop/modules/billing/api" // ok
// import "shop/modules/billing/internal/domain" // build error:
// use of internal package shop/modules/billing/internal/domain not allowed
Дополнительно в Go границы фиксируют через depguard в golangci-lint. В .NET аналогичную роль играют NetArchTest и разделение на проекты с ограничением ProjectReference; в TypeScript — правило no-restricted-imports в ESLint плюс project references в tsconfig или зоны в Nx.
Данные: самая протекающая граница
Код изолировать проще всего. Настоящая утечка обычно происходит в базе: кто-то пишет JOIN billing.invoice ON ... из модуля orders, и через полгода эту связь невозможно разорвать.
Дисциплина, которая работает:
-- Каждому модулю — своя схема и свой пользователь БД.
CREATE SCHEMA orders AUTHORIZATION app_orders;
CREATE SCHEMA billing AUTHORIZATION app_billing;
CREATE SCHEMA shipping AUTHORIZATION app_shipping;
-- Права выдаём точечно: модуль видит только своё.
REVOKE ALL ON SCHEMA billing FROM app_orders;
GRANT USAGE ON SCHEMA billing TO app_billing;
-- Межмодульные ссылки — только по идентификатору, БЕЗ внешнего ключа.
CREATE TABLE billing.invoice (
id uuid PRIMARY KEY,
order_id uuid NOT NULL, -- ссылка на orders.order, но FK НЕТ намеренно:
-- FK через границу модуля = цемент, который потом не разбить
amount_minor bigint NOT NULL CHECK (amount_minor > 0),
currency char(3) NOT NULL,
status text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ON billing.invoice (order_id);
Если приложение работает от одного пользователя БД (частый случай), разделение всё равно полезно, а проверить его можно тестом, разбирающим SQL: собрать все запросы модуля и убедиться, что в них не встречаются чужие схемы. Дешёвый вариант — регулярка по миграциям и репозиториям в CI; надёжный — прокси-обёртка над соединением, которая выставляет SET search_path в схему модуля и роняет запрос с явным именем чужой схемы.
Что делать, когда нужен «JOIN» через границу — например, список заказов с суммами счетов? Три рабочих ответа:
- Композиция на уровне приложения:
ordersотдаёт страницу заказов, потом одним батч-вызовом запрашиваетbilling.get_invoices(order_ids). Стоимость — дополнительный запрос, зато граница цела. - Read-модель: отдельный модуль
reportingподписан на события и держит денормализованную таблицу. Это уже CQRS в миниатюре — см. «CQRS и Event Sourcing». - Явно разрешённый read-only view:
billingпубликует представлениеbilling.invoice_publicкак часть своего контракта. Это компромисс, но контролируемый: изменение view — изменение публичного API.
Чего делать не надо — «временно» сходить в чужую таблицу. Временное решение в базе данных живёт дольше компании.
7. Эксплуатация: где монолит выигрывает и где проседает
Выигрывает
- Отладка. Один стектрейс от HTTP-хендлера до SQL. Никакой корреляции трейсов между сервисами.
- Тесты. Сквозной сценарий — это обычный интеграционный тест в одном процессе, без docker-compose из десяти контейнеров.
- Рефакторинг через границы. Переименовать метод в контракте — атомарный коммит с проверкой компилятором. В микросервисах то же изменение — это версионирование API, две выкладки и период совместимости.
- Согласованность данных. Сильная согласованность бесплатна; распределённые системы платят за неё сагами и вечной борьбой с eventual consistency.
- Стоимость инфраструктуры. Три экземпляра приложения за балансировщиком против сорока подов, service mesh и брокера.
Проседает
- Селективное масштабирование. Масштабируется профиль ресурсов целиком. Смягчение: несколько развёртываний одного и того же артефакта с разными ролями —
web,worker,scheduler,search. Один код, один образ, разный набор включённых модулей и разные лимиты:
# Один и тот же образ, два развёртывания с разными ролями и лимитами.
# Модульность даёт селективное масштабирование БЕЗ распила на сервисы.
apiVersion: apps/v1
kind: Deployment
metadata: { name: shop-web }
spec:
replicas: 8
template:
spec:
containers:
- { name: app, image: "registry.internal/shop:1.42.0",
args: ["serve", "--role=web"],
env: [{ name: MODULES_ENABLED, value: "orders,billing,catalog" }],
resources: { requests: { cpu: "500m", memory: "512Mi" } } }
---
apiVersion: apps/v1
kind: Deployment
metadata: { name: shop-reporting }
spec:
replicas: 2
template:
spec:
containers: # ТОТ ЖЕ образ, другой профиль ресурсов
- { name: app, image: "registry.internal/shop:1.42.0",
args: ["serve", "--role=reporting"],
env: [{ name: MODULES_ENABLED, value: "reporting" }],
resources: { requests: { cpu: "2", memory: "4Gi" } } }
- Изоляция отказов. Утечка памяти в одном модуле кладёт весь процесс. Смягчение: bulkhead внутри процесса — отдельные пулы соединений и семафоры на модуль:
class ModuleBulkhead:
"""Ограничивает число одновременных операций модуля:
медленный reporting не съест весь пул воркеров у checkout."""
def __init__(self, limit: int, timeout_s: float) -> None:
self._sem, self._timeout = asyncio.Semaphore(limit), timeout_s
@asynccontextmanager
async def acquire(self):
try:
await asyncio.wait_for(self._sem.acquire(), timeout=self._timeout)
except asyncio.TimeoutError:
raise ModuleOverloaded("модуль перегружен") # быстрый отказ лучше деградации
try:
yield
finally:
self._sem.release()
BULKHEADS = {
"checkout": ModuleBulkhead(limit=200, timeout_s=0.05),
"reporting": ModuleBulkhead(limit=8, timeout_s=0.20), # тяжёлый и не критичный
}
- Технологическая свобода. Один рантайм на всех. Это часто плюс (одна экспертиза, один тулинг), но если нужен ML на Python рядом с ядром на JVM — это честная причина для отдельного сервиса.
- Время сборки и тестов. Растёт с кодовой базой. Смягчение: модульность позволяет запускать тесты только затронутых модулей — по графу зависимостей из того же
import-linter/Nx/Bazel. Shopify и Gusto описывали именно этот путь. - Независимость релизов. При десяти командах в одном артефакте очередь на релиз становится узким местом. Смягчение первого порядка — trunk-based development с feature flags и релизом по расписанию (несколько раз в день, автоматически, с автооткатом). Смягчение второго порядка — это уже распил.
8. Эволюция: как модульный монолит готовит распил
Модульный монолит — не «промежуточная станция», на которой обязаны все выйти. Но если распил понадобится, из модульного монолита он делается на порядок дешевле: контракт уже есть, данные уже разделены, зависимости уже ациклические. Вынос модуля превращается из археологической экспедиции в замену реализации порта.
Практическая процедура выноса модуля (strangler fig, Fowler):
- Убедиться, что модуль действительно изолирован: в CI нет исключений из правил, чужих схем в SQL нет, входящих зависимостей — только на
api. - Перевести синхронные вызовы на асинхронные там, где это допустимо. Каждый оставшийся синхронный вызов после выноса станет точкой отказа.
- Заменить внутрипроцессные события на outbox. Событие пишется в таблицу той же транзакцией, отдельный процесс доставляет его в брокер. Реализация — в «Saga, распределённые транзакции, outbox и идемпотентность».
- Подставить удалённую реализацию порта. Интерфейс
BillingApiне меняется — меняется класс, который его реализует: вместо прямого вызова — HTTP/gRPC-клиент с таймаутом, ретраем и circuit breaker. - Запустить в режиме двойной записи / сравнения (
shadow traffic): новый сервис получает копию трафика, ответы сравниваются, расхождения логируются. - Переключить трафик по флагу, с возможностью мгновенного отката.
- Разделить данные физически — как правило, самый долгий шаг, и делают его последним.
# Шаг 4 в коде: тот же порт, другая реализация. Вызывающий код не меняется вообще.
class RemoteBillingClient(BillingApi):
def __init__(self, http: "HttpClient", breaker: "CircuitBreaker") -> None:
self._http = http
self._breaker = breaker
def issue_invoice(self, order_id: UUID, amount: Decimal, currency: str) -> InvoiceView:
# Всё, чего не было в локальном вызове, появляется здесь:
# ключ идемпотентности, таймаут, ретрай, автомат защиты.
with self._breaker:
resp = self._http.post(
"/invoices",
json={"order_id": str(order_id), "amount": str(amount), "currency": currency},
headers={"Idempotency-Key": f"invoice:{order_id}"},
timeout=2.0,
)
return InvoiceView(**resp.json())
Именно этот фрагмент — лучший аргумент в пользу узких контрактов. Чем меньше методов в порте и чем проще их типы, тем меньше работы на шаге 4 и тем меньше сюрпризов на шаге 5.
9. Типичные ошибки
Модули по техническим слоям. services, repositories, dto — это не модули. Проверка: если типичная фича требует правок в четырёх «модулях» — границы проведены не там.
Публичный контракт возвращает доменные сущности. Отдали наружу ORM-объект — отдали наружу всю схему БД вместе с ленивой загрузкой. Через квартал внешний код зависит от полей, о которых вы не подозревали. Только DTO.
Общий модуль common / utils / core. Он растёт, в него стекается всё, и в итоге все модули зависят от него, а он — от всех. Дисциплина: shared_kernel содержит только типы без бизнес-логики (Money, UserId, Result, часы), он не имеет права зависеть ни от одного модуля, и его размер должен вызывать подозрение при росте.
Циклы «временно разрешим». Один цикл — это уже не два модуля, а один большой. Правило independence/beFreeOfCycles должно ронять сборку, а не выводить предупреждение.
События внутри транзакции с синхронной доставкой. Иллюзия слабой связи при жёсткой связности; падение подписчика откатывает публикацию. Отсюда dispatch_after_commit.
FK через границу модуля. Самый дешёвый способ намертво сцементировать модули. Ссылайтесь по идентификатору, целостность проверяйте в приложении или фоновым сверяющим процессом.
«Сделаем модульный монолит, а через год всё равно распилим — значит, можно сейчас не стараться». Ровно наоборот: качество границ определяет цену распила. Небрежная граница в монолите — это боль в квартал; та же граница в микросервисах — это боль в год.
Распил ради «модности» или найма. Явная цена без явной выгоды. Каждый вынесенный сервис должен иметь письменную причину: другой профиль масштабирования, другой рантайм, другой контур комплаенса, независимый релизный цикл команды. Нет причины из списка — нет выноса. Формат ADR для такого решения — в статье «Архитектурные решения».
Отсутствие автоматической проверки границ. Повторим третий раз, потому что это ошибка номер один: правило, которое проверяет человек на ревью, — это не архитектура.
10. Как это выглядит в проде
Shopify. Один из крупнейших Rails-монолитов (миллионы строк, тысячи разработчиков). Вместо распила — программа модуляризации: разбиение на компоненты, инструмент Packwerk для статической проверки границ пакетов в Ruby, постепенное ужесточение правил. Их инженерный блог — образцовое описание пути «монолит → модульный монолит».
Stack Overflow. Классический пример эффективности плотного монолита: архитектура 2016 года — девять веб-серверов на IIS, обслуживающие сотни миллионов просмотров в месяц при загрузке CPU в единицы процентов. Урок: вертикальное масштабирование и аккуратная работа с БД закрывают нагрузку, о которой большинство команд только мечтает.
Amazon Prime Video. Кейс 2023 года: сервис мониторинга качества был построен на распределённых компонентах (Step Functions + Lambda + S3 для передачи кадров между шагами). Основная стоимость оказалась в оркестрации и передаче данных между компонентами. Переезд в единый процесс, где кадры передаются в памяти, дал −90% стоимости. Важная оговорка: это не «микросервисы плохие», а «границу провели по шагам обработки одного потока данных — там, где данные нужно передавать миллионы раз в секунду».
Segment. Публично описали обратный переход от ~140 микросервисов к монолиту: эксплуатационные издержки (общие библиотеки, версии, очереди на каждый destination) превысили выгоду от изоляции.
Spring Modulith. Пример того, как идея модульного монолита оформилась в поддерживаемый фреймворком инструмент: проверка структуры тестом, генерация документации, @ApplicationModuleListener, событийный outbox из коробки.
Общий вывод из этих кейсов: направление миграции не имеет «правильной» стороны. Правильно — соотносить архитектуру с текущими ограничениями и уметь двигаться в обе стороны.
11. Мини-итог
- Монолит — это про единицу развёртывания, а не про качество кода. Модульность — независимая ось, и она важнее.
- Большая часть боли, приписываемой монолиту, вызвана отсутствием внутренних границ и после распила становится дороже, а не дешевле.
- Распределённость имеет постоянную базовую цену: частичные отказы, отсутствие транзакций, версионирование контрактов, наблюдаемость, инфраструктура. Платить её нужно за конкретную выгоду.
- Модульный монолит = один артефакт + модули с узкими публичными контрактами + собственные данные + ациклические зависимости + проверка правил в CI.
- Границы проводятся по бизнес-способностям и языковым швам (bounded context), а не по техническим слоям.
- Инструменты, которые превращают договорённость в правило:
import-linter(Python), ArchUnit / Spring Modulith (JVM),internal+depguard(Go), NetArchTest (.NET), ESLint/Nx (TypeScript), Packwerk (Ruby), схемы и роли БД — везде. - Селективное масштабирование и изоляция отказов частично достижимы внутри монолита: один образ с разными ролями, bulkhead с отдельными пулами и семафорами.
- Из модульного монолита распил делается заменой реализации порта, а не археологией. Это и есть главная страховка.
Источники
- Martin Fowler. MonolithFirst, MicroservicePremium, StranglerFigApplication, Presentation Domain Data Layering.
- Sam Newman. Monolith to Microservices. O’Reilly, 2019 — страница книги. Лучший разбор техник декомпозиции и признаков, что декомпозиция не нужна.
- Eric Evans. Domain-Driven Design. Addison-Wesley, 2003 — bounded context и стратегическое проектирование.
- Vaughn Vernon, Tomasz Jaskuła. Strategic Monoliths and Microservices. Addison-Wesley, 2021.
- Simon Brown. Modular Monoliths — доклад, с которого термин вошёл в обиход; см. также C4 model и Spring Modulith Reference.
- Shopify Engineering. Deconstructing the Monolith и Packwerk.
- Segment. Goodbye Microservices.
- Prime Video Tech. Scaling up the Prime Video audio/video monitoring service.
- Neal Ford, Rebecca Parsons, Patrick Kua, Pramod Sadalage. Building Evolutionary Architectures, 2nd ed. O’Reilly, 2022 — фитнес-функции как способ автоматизировать архитектурные правила.
- Документация инструментов: import-linter, ArchUnit, Go internal packages.
Тема границ и контрактов тесно связана с общими принципами проектирования — см. трек «Принципы разработки», а способы выразить границы в коде разобраны в треке «Паттерны проектирования».
Что дальше
Мы разобрали, как получить модульность, не платя за распределённость, и какие сигналы говорят о том, что налог на распределённость пора всё-таки заплатить. Следующий шаг — разобраться, что именно вы покупаете за эти деньги: как проводить границы сервисов, чем их связывать, что делать с данными, которые больше не помещаются в одну транзакцию, и во что превращается эксплуатация.
Микросервисы: границы, коммуникация, данные, эксплуатация
Полезно также заглянуть в карту трека с критериями выбора архитектуры и в «Устойчивость», если решение о распиле уже принято: устойчивость перестаёт быть опциональной ровно в тот момент, когда вызов метода становится сетевым.