Архитектурные паттерны Монолит и модульный монолит: недооценённый выбор
0%

Монолит и модульный монолит: недооценённый выбор

Монолит и модульный монолит: недооценённый выбор

Слово «монолит» за последнее десятилетие превратилось в диагноз. На собеседованиях его произносят с извиняющейся интонацией: «ну, у нас пока монолит, но мы, конечно, пилим на сервисы». В резюме пишут «опыт миграции с монолита на микросервисы» — как будто направление стрелки самоочевидно и обратного движения не бывает.

Между тем обратное движение бывает, и у очень заметных компаний. 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. Модульный монолит: определение и свойства

Модульный монолит — система, которая развёртывается как единый процесс, но внутри разделена на модули с явными публичными контрактами, собственными данными и запретом на обращение к внутренностям соседей, причём этот запрет проверяется автоматически.

Ключевые свойства:

  1. Единица развёртывания одна. Один артефакт, одна транзакция, один стектрейс, один процесс отладки.
  2. Модуль владеет своими данными. Никто не читает чужие таблицы напрямую — только через API модуля.
  3. Публичная поверхность модуля минимальна. Наружу торчит фасад и набор DTO; доменные сущности остаются внутри.
  4. Зависимости между модулями явные и ациклические. Циклы запрещены и ловятся сборкой.
  5. Правила проверяет машина, а не ревьюер. Договорённость, которую не проверяет 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 — это шов.

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


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» через границу — например, список заказов с суммами счетов? Три рабочих ответа:

  1. Композиция на уровне приложения: orders отдаёт страницу заказов, потом одним батч-вызовом запрашивает billing.get_invoices(order_ids). Стоимость — дополнительный запрос, зато граница цела.
  2. Read-модель: отдельный модуль reporting подписан на события и держит денормализованную таблицу. Это уже CQRS в миниатюре — см. «CQRS и Event Sourcing».
  3. Явно разрешённый 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):

  1. Убедиться, что модуль действительно изолирован: в CI нет исключений из правил, чужих схем в SQL нет, входящих зависимостей — только на api.
  2. Перевести синхронные вызовы на асинхронные там, где это допустимо. Каждый оставшийся синхронный вызов после выноса станет точкой отказа.
  3. Заменить внутрипроцессные события на outbox. Событие пишется в таблицу той же транзакцией, отдельный процесс доставляет его в брокер. Реализация — в «Saga, распределённые транзакции, outbox и идемпотентность».
  4. Подставить удалённую реализацию порта. Интерфейс BillingApi не меняется — меняется класс, который его реализует: вместо прямого вызова — HTTP/gRPC-клиент с таймаутом, ретраем и circuit breaker.
  5. Запустить в режиме двойной записи / сравнения (shadow traffic): новый сервис получает копию трафика, ответы сравниваются, расхождения логируются.
  6. Переключить трафик по флагу, с возможностью мгновенного отката.
  7. Разделить данные физически — как правило, самый долгий шаг, и делают его последним.
# Шаг 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 с отдельными пулами и семафорами.
  • Из модульного монолита распил делается заменой реализации порта, а не археологией. Это и есть главная страховка.

Источники

Тема границ и контрактов тесно связана с общими принципами проектирования — см. трек «Принципы разработки», а способы выразить границы в коде разобраны в треке «Паттерны проектирования».


Что дальше

Мы разобрали, как получить модульность, не платя за распределённость, и какие сигналы говорят о том, что налог на распределённость пора всё-таки заплатить. Следующий шаг — разобраться, что именно вы покупаете за эти деньги: как проводить границы сервисов, чем их связывать, что делать с данными, которые больше не помещаются в одну транзакцию, и во что превращается эксплуатация.

Микросервисы: границы, коммуникация, данные, эксплуатация

Полезно также заглянуть в карту трека с критериями выбора архитектуры и в «Устойчивость», если решение о распиле уже принято: устойчивость перестаёт быть опциональной ровно в тот момент, когда вызов метода становится сетевым.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов