Часть III: Простые системы — монолит как искусство
Глава 12

Модульный монолит — следующий уровень

Проблема

Проблема: Монолит растёт. Кодовая база — 200, потом 500 тысяч строк. Папки плодятся. Каждое изменение требует понимания половины системы. Вы начинаете задумываться о микросервисах. Но стоп. Может, есть что-то между «комом грязи» и «распределённым адом»?


Есть у программистов одна забавная черта: когда кодовая база становится сложной, первая мысль — «надо разбить на микросервисы». Сервисы — это же взрослый подход. Spotify так делает. Netflix так делает. Значит, и нам пора.

Спойлер: в 90% случаев вам пора не к микросервисам. Вам пора к модульному монолиту.

Модульный монолит — это когнитивный компромисс, который индустрия почему-то игнорирует лет двадцать. Вы получаете почти все преимущества микросервисной архитектуры (чёткие границы, изолированные модули, контракты), но без распределённости. Без сетевых вызовов. Без дебага через пять сервисов. Без Kafka, когда вам нужно просто передать объект из точки А в точку Б.

Звучит как санта-барбара? Давайте разберёмся.


#Package by feature vs Package by layer

Всё начинается с того, как вы раскладываете файлы. И это не вопрос эстетики — это вопрос архитектуры.

#Package by layer — классический хаос

Большинство проектов, особенно на Rails, Django, Spring, начинают с группировки по техническому слою:

src/
├── controllers/
│   ├── UserController.java
│   ├── OrderController.java
│   └── ProductController.java
├── services/
│   ├── UserService.java
│   ├── OrderService.java
│   └── ProductService.java
├── repositories/
│   ├── UserRepository.java
│   ├── OrderRepository.java
│   └── ProductRepository.java
├── models/
│   ├── User.java
│   ├── Order.java
│   └── Product.java

Выглядит аккуратно? Обманчиво.

В чём проблема? Всё, что относится к одному домену (скажем, заказу), размазано по четырём папкам. Чтобы разобраться, как работает создание заказа, вам нужно открыть OrderController.java, потом OrderService.java, потом OrderRepository.java, потом Order.java — и всё это в разных папках. Потом выясняется, что OrderService вызывает UserService.getUser(), и вы прыгаете в другую цепочку.

А теперь представьте, что в проекте 50 таких моделей. Через год никто не знает, где что лежит. Границы между доменами стираются. OrderService спокойно лезет в UserRepository, потому что «так удобнее». И вот у вас уже Big Ball of Mud.

#Package by feature — первый шаг к модульности

Альтернатива — группировать по фиче/домену:

src/
├── orders/
│   ├── OrderController.java
│   ├── OrderService.java
│   ├── OrderRepository.java
│   └── Order.java
├── users/
│   ├── UserController.java
│   ├── UserService.java
│   ├── UserRepository.java
│   └── User.java
├── products/
│   ├── ProductController.java
│   ├── ProductService.java
│   ├── ProductRepository.java
│   └── Product.java

Теперь всё, что относится к заказу — в одном месте. Хотите понять, как работает заказ? Открываете папку orders/ и видите всё дерево за 10 секунд.

Но это только начало. Простая группировка по папкам не даёт настоящей изоляции. OrderService всё ещё может вызвать UserRepository напрямую, потому что языку всё равно — он видит все классы. Модульный монолит требует большего.

Cross-reference

Cross-reference: О связанности (coupling) и связности (cohesion) как фундаментальных принципах — см. Главу 3 «Первые принципы архитектуры». Там мы разбирали, почему processOrder() длиной в 800 строк — это симптом, а не проблема.


#Модули с чёткими границами

Настоящая модульность начинается там, где компилятор или рантайм запрещает одному модулю лезть в детали другого. Не «договорённость в команде», а техническое ограничение.

#Java: Module System (JPMS)

С Java 9 появилась система модулей (Project Jigsaw). Вы описываете, что ваш модуль экспортирует наружу, а что скрывает:

// orders/src/main/java/module-info.java
module com.myapp.orders {
    exports com.myapp.orders.api;       // это — публичный контракт
    exports com.myapp.orders.internal to com.myapp.payment;  // только для конкретного модуля

    requires com.myapp.users.api;       // зависимость от другого модуля
    requires com.myapp.kernel;
}

Классы в com.myapp.orders.internal — не видны никому, кроме того, кому вы явно разрешили. Попытка импортировать их из другого модуля — ошибка компиляции. Железобетонная гарантия, что модуль не превратится в общую свалку.

#Python: namespace packages и импорты

Python — язык взрослых людей, который верит, что вы знаете, что делаете. Никаких exports тут нет. Но можно эмулировать модульность дисциплиной и соглашениями:

orders/
├── __init__.py              # публичное API — только то, что в __all__
├── api/
│   ├── __init__.py
│   └── order_service.py     # публичный интерфейс
├── internal/
│   ├── __init__.py
│   ├── order_validator.py   # скрытая реализация
│   ├── payment_handler.py
│   └── order_repository.py
└── models/
    ├── __init__.py
    └── order.py

Секрет — в __init__.py. Вы контролируете, что экспортируется:

# orders/__init__.py
from .api.order_service import OrderService

__all__ = ["OrderService"]  # всё остальное — детали реализации

Если кто-то импортирует orders.internal.order_validator — формально это сработает. Но convention в команде: «импорты из .internal — красная зона, только если явное исключение». Со временем это становится культурой. И code review это ловит.

#TypeScript: barrel exports

Аналогично в TypeScript/Node.js — паттерн barrel export:

// orders/src/index.ts
export { OrderService } from './api/order-service';
export { Order } from './models/order';
export type { IOrderRepository } from './api/interfaces';

Файлы в internal/ не экспортируются через корневой index.ts. ESLint правило no-restricted-imports может запретить импорты из */internal/*.

#Какой подход выбрать?

Критерий Java JPMS Python packages TypeScript barrels
Enforcement Компилятор Convention ESLint (optional)
Complexity Высокая Низкая Средняя
Flexibility Низкая (жёстко) Высокая Средняя
Learning curve Крутая Пологая Пологая

Совет: Если ваш стек поддерживает реальную модульную систему (Java 9+, .NET assemblies, Rust crates) — используйте её. Если нет — convention + инструменты (linters, code review). Это требует дисциплины, но работает.


#Internal interfaces и контракты между модулями

Модульность — это не про то, как разложить файлы. Это про то, как модули общаются.

В модульном монолите у вас нет сети. Нет HTTP-вызовов. Нет gRPC. Всё вызывается in-process. Это даёт чудовищное преимущество в производительности и простоте, но создаёт риск: ничто не мешает одному модулю взять и вызвать внутренний метод другого напрямую.

#Паттерн «Facade для каждого модуля»

Каждый модуль должен иметь публичное API (Facade), через которое с ним общаются все остальные модули. Всё остальное — internal.

// Модуль Payment — публичный интерфейс
// payment-api/src/main/java/com/myapp/payment/api/PaymentFacade.java
public interface PaymentFacade {
    PaymentResult charge(OrderId orderId, Money amount);
    RefundResult refund(PaymentId paymentId);
    PaymentStatus getStatus(PaymentId paymentId);
}
// Реализация — детали, которые никого не касаются
// payment-impl/src/main/java/com/myapp/payment/internal/
public class PaymentFacadeImpl implements PaymentFacade {
    // Тут: Stripe API, обработка ошибок, ретраи,
    //      запись в базу, логирование
    // Снаружи этого никто не видит и не должен знать
}

Если другой модуль хочет провести платёж — он вызывает PaymentFacade.charge(). Точка. Он не знает, что внутри Stripe, не знает про ретраи, не знает про схему таблиц. И — что важнее — он не может вызвать createStripeSession() напрямую, потому что этот метод не экспортирован.

#Контракты как first-class citizens

Контракты между модулями (DTO, интерфейсы) — это не побочный продукт. Им нужно своё место:

kernel/
├── api/
│   ├── OrderId.java
│   ├── PaymentResult.java
│   └── Money.java
├── events/
│   ├── OrderPlacedEvent.java
│   └── PaymentCompletedEvent.java
└── exceptions/
    ├── OrderNotFoundException.java
    └── PaymentDeclinedException.java

Почему это важно? Потому что контракты меняются реже, чем реализация. Если OrderId — строка сегодня и UUID завтра, вы меняете его в одном месте, а не в каждом модуле, который с ним работает. Теперь реальный пример.

#Пример: межмодульный вызов

# orders/api.py — публичное API модуля orders
from dataclasses import dataclass
from uuid import UUID


@dataclass
class CreateOrderRequest:
    user_id: UUID
    items: list[OrderItem]
    promo_code: str | None = None


@dataclass
class CreateOrderResponse:
    order_id: UUID
    total: Money
    status: str


class OrdersFacade:
    def create_order(self, request: CreateOrderRequest) -> CreateOrderResponse:
        # валидация, логика, сохранение
        ...

    def get_order(self, order_id: UUID) -> OrderDTO:
        ...
# orders/internal/validation.py — скрытая реализация
class OrderValidator:
    def validate_items(self, items: list[OrderItem]) -> ValidationResult:
        ...  # детали, которые никого не касаются
# checkout/order_service.py — другой модуль использует OrdersFacade
from orders import OrdersFacade  # только публичное API

class CheckoutService:
    def __init__(self, orders: OrdersFacade, payment: PaymentFacade):
        self.orders = orders
        self.payment = payment

    def checkout(self, cart: Cart) -> CheckoutResult:
        # Создаём заказ через публичный интерфейс
        order = self.orders.create_order(
            CreateOrderRequest(
                user_id=cart.user_id,
                items=cart.items,
            )
        )

        # Платим через другой публичный интерфейс
        payment_result = self.payment.charge(
            order_id=order.order_id,
            amount=order.total,
        )

        return CheckoutResult(order=order, payment=payment_result)

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

#Типичные ошибки при проектировании интерфейсов

Ошибка 1: Слишком детальные интерфейсы

// Плохо — раскрывает внутренние детали
public interface OrderFacade {
    Order createOrder(List<OrderItem> items, Address shipping, String paymentToken);
    void validateItems(List<OrderItem> items);  // почему это здесь?
    Money calculateTax(Order order);             // и это?
    boolean checkFraud(Order order);             // это вообще security!
    void sendConfirmation(Order order);          // нотификации — не моя забота
}

Ошибка 2: Интерфейс как data class

// Тоже плохо — Facade не должен быть Data Transfer Object
public interface OrderFacade {
    OrderDTO getOrder(String orderId);
    void updateOrder(OrderDTO order);
    void deleteOrder(String orderId);
    List<OrderDTO> searchOrders(String query);
    // через месяц сюда добавляют ещё 5 методов
    // через полгода — это God Interface
}

Как правильно: Facade должен содержать только бизнес-операции (use cases). Не CRUD. Не технические детали. Если метод не имеет смысла для потребителя модуля — он не должен быть в Facade.


#Dependency injection на практике

Модульный монолит без DI — это как машина без масла: ехать можно, но недолго.

DI решает ключевую проблему: модули зависят от абстракций других модулей, а не от конкретных реализаций. Вы регистрируете реализации в одном месте — в корне приложения (composition root) — и никогда не создаёте зависимости руками.

#Spring / Java

// Config — Composition Root
@Configuration
public class AppConfig {

    @Bean
    public OrdersFacade ordersFacade(
            OrderRepository orderRepo,
            PaymentFacade paymentFacade,  // из другого модуля!
            EventBus eventBus
    ) {
        return new StandardOrdersFacade(orderRepo, paymentFacade, eventBus);
    }

    @Bean
    public PaymentFacade paymentFacade(StripeConfig config) {
        return new StripePaymentFacade(config);
    }

    @Bean
    public CheckoutService checkoutService(
            OrdersFacade ordersFacade,
            PaymentFacade paymentFacade
    ) {
        return new CheckoutService(ordersFacade, paymentFacade);
    }
}

Всё. StandardOrdersFacade не знает, как создаётся StripePaymentFacade. Payment-модуль можно переключить с Stripe на Adyen — меняется одна строчка в конфиге, а не сотня мест в коде.

#Python / FastAPI + custom DI

В Python нет встроенного DI как в Spring, но никто не мешает написать свой или использовать fastapi.Depends для прототипов. Для серьёзного проекта — dependency-injector или punq:

from punq import Container

container = Container()
container.register(OrdersFacade, StandardOrdersFacade)
container.register(PaymentFacade, StripePaymentFacade)
container.register(EventBus, KafkaEventBus)

# Использование
checkout = container.resolve(CheckoutService)
result = checkout.checkout(cart)

#TypeScript / NestJS

@Module({
    imports: [
        OrdersModule,
        PaymentModule.register({ provider: 'stripe' }),
        KernelModule,
    ],
    providers: [CheckoutService],
    controllers: [CheckoutController],
})
export class CheckoutModule {}

#Главное правило DI в модульном монолите

Composition Root — только один. Всё приложение собирается в одной точке (обычно main() или стартовый конфиг). Никакой сервис-локатор. Никакого getBean() в середине бизнес-логики. Единственное место, где происходит «склеивание» модулей — это Composition Root.

Почему это важно? Потому что если зависимости размазаны по коду, вы не сможете понять граф зависимостей, не сможете изолировать модуль для теста, и рано или поздно у вас появится циклическая зависимость, которую вы заметите только при деплое.


#Shared kernel vs Separate ways

Это фундаментальный выбор, который вы делаете для модульного монолита.

#Shared Kernel (Общее ядро)

Идея из DDD, адаптированная для модульного монолита: есть небольшое ядро с типами, контрактами и базовыми утилитами, которые используют все модули.

kernel/
├── types/
│   ├── OrderId.java
│   ├── UserId.java
│   ├── Money.java       # денежные суммы — везде
│   └── Address.java     # адреса — везде
├── events/
│   ├── DomainEvent.java # базовый интерфейс для событий
│   └── EventBus.java    # шина событий
├── result/
│   └── Result.java      # Result<T, E> — возвращаемый тип
└── monitoring/
    └── Metrics.java     # метрики — общий интерфейс

Преимущества: DRY, консистентность, легко менять базовые вещи.

Риски: Shared kernel имеет тенденцию разрастаться. Сначала там 5 классов, через год — 50, и ваш «лёгкий» kernel превращается в garbage dump, где лежит всё — от StringUtils до парсера CSV.

#Separate ways (Каждый сам за себя)

Каждый модуль определяет свои собственные типы и не использует shared kernel:

orders/
├── api/
│   ├── OrderId.java     # свой идентификатор
│   ├── Money.java       # своя имплементация денег
│   └── Address.java     # своя структура адреса
├── ...
users/
├── api/
│   ├── UserId.java      # свой идентификатор
│   ├── Money.java       # своя имплементация денег
│   └── Address.java     # своя структура адреса
├── ...

Звучит как кошмар? На первый взгляд — да. На практике — самый прагматичный подход для больших систем.

Преимущества: полная независимость модулей, минимум связей, легко изменять один модуль не трогая другие.

Недостатки: дублирование кода (каждый модуль определяет Money), маппинг между DTO разных модулей.

#Decision Matrix

Shared Kernel Separate Ways
Когда Меньше 5 модулей, одна команда Больше 5 модулей, несколько команд
Изменения Менять kernel = менять все модули Изменения изолированы
Дублирование Минимальное Есть, терпимое
Связанность Средняя (все зависят от kernel) Низкая
Dev speed Быстро, но осторожно Медленнее на старте, быстрее потом
Refactoring Сложно (затрагивает всех) Просто (один модуль)

#Практический совет: Goldilocks kernel

Я видел работающий компромисс: kernel с общими типами, но только read-only. Kernel содержит:

  • Value Objects (OrderId, Money) — это просто данные, их можно менять редко
  • Domain Events (OrderPlacedEvent) — не меняются после определения
  • Базовые интерфейсы (EventBus) — контракты

В kernel не кладём:

  • Логику, утилиты, хелперы (они имеют свойство разрастаться)
  • Реализации (только интерфейсы)
  • Всё, что может меняться (бизнес-логику, правила)

Kernel меняется, только когда меняются контракты между модулями. Раз в несколько месяцев, а не каждый день.


#Migration: Big Ball of Mud → Modular Monolith → Microservices

Самый частый вопрос: «Как это внедрить в существующий проект?».

Хорошая новость: это можно делать постепенно. Плохая: это больно. Но менее больно, чем «big bang rewrite» или внезапный переход на микросервисы.

#Шаг 1: Map the mess

Прежде чем что-то реорганизовывать, поймите, что у вас есть. Для этого — dependency graph.

# Пример для Python — посмотреть импорты между модулями
pip install pipdeptree
# Или написать скрипт, который анализирует все import-ы

Что вы ищете:

  • Циклические зависимости (A → B → A)
  • God-модули (все импортируют core.util.helpers)
  • Мёртвый код (никто не импортирует, а удалить нельзя — вдруг пригодится)

Этот шаг — часто самый неприятный. Потому что вы увидите реальную архитектуру, а не ту, что на диаграммах в Confluence. Признайте её. Она уродлива. Дальше будет лучше.

#Шаг 2: Identify bounded contexts

Вспомните главу 4 — там мы говорили про Bounded Context. Выделите логические домены, глядя на то, как бизнес делит функциональность.

Не смотрите на код. Смотрите на продукт: «Вот это — работа с пользователями. Это — заказы. Это — платежи. Это — уведомления». Даже если сейчас всё лежит в core/utils.py, на уровне логики это разные вещи.

#Шаг 3: Extract one module at a time

Берите один bounded context и вытаскивайте его в отдельный модуль. Только один. За один раз.

# До: всё в одной папке
src/
├── controllers/
├── services/
├── models/
└── utils/

# После: платёжный модуль выделен
src/
├── orders/          # пока всё ещё в монолите
├── users/           # пока всё ещё в монолите
├── payment/         # новый модуль
│   ├── api/
│   ├── internal/
│   └── models/
└── kernel/
    └── api/

Правило: Не делайте больше одного модуля за релиз/спринт. Иначе:

  • Вы не сможете нормально протестировать изменения
  • Поломаете всё сразу
  • Вернётесь к исходному состоянию через неделю, потому что «оно не работает, а дедлайн горит»

#Шаг 4: Define external interfaces

Для каждого модуля определяете Facade — что он показывает наружу. Всё, что не входит в Facade — internal. Это может быть компилятор (Java modules), convention (Python) или code review.

#Шаг 5: Move dependencies

Теперь самое интересное — разрывать зависимости. Типичный паттерн:

# Проблема: OrderService напрямую лезет в UserRepository
class OrderService:
    def get_customer_email(self, user_id: UUID) -> str:
        user = UserRepository.find_by_id(user_id)  # прямая зависимость от другого модуля
        return user.email

Решение: перенести нужную функциональность в интерфейс модуля users и вызывать через него:

# Решение: OrderService использует UsersFacade
class OrderService:
    def __init__(self, users: UsersFacade):
        self.users = users

    def get_customer_email(self, user_id: UUID) -> str:
        return self.users.get_email(user_id)  # через публичный интерфейс

Это называется Dependency Inversion — модули зависят от абстракций, а не от конкретных реализаций. (Да, это та самая буква «D» в SOLID.)

#Шаг 6: Big Ball of Mud → Modular Monolith → Microservices

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

Но если вы всё-таки идёте дальше:

Big Ball of Mud
    ↓ Step 1-5 (2-12 месяцев)
Modular Monolith   ← Вы можете остаться здесь навсегда. И это ок.
    ↓ Если модули реально автономны
Microservices (каждый модуль → отдельный сервис)

Секрет в том, что микросервис из модульного монолита сделать в 10 раз легче, чем из Big Ball of Mud. Потому что:

  • Границы между модулями уже проведены
  • Интерфейсы уже определены
  • Модули не знают внутренностей друг друга
  • Вы уже разорвали циклические зависимости

Всё, что остаётся — вытащить модуль в отдельный процесс, добавить HTTP/gRPC/gRPC слой и настроить деплой. Весь ваш «распределённый» код — это просто адаптер между Facade и внешним миром.


#Паттерн «Чистая архитектура» для монолита

Чистая архитектура (Clean Architecture) Роберта Мартина часто воспринимается как что-то академическое и непрактичное. «Три слоя, круги, абстракции, которые никогда не понадобятся». Но в контексте модульного монолита Clean Architecture обретает практический смысл.

#Как это выглядит

Каждый модуль строится по принципу слоёв, где зависимости направлены внутрь:

┌─────────────────────────────────────────┐
│          Infrastructure                  │
│  (БД, API-клиенты, кэш, брокер)         │
│  ┌───────────────────────────────────┐   │
│  │     Application (Use Cases)       │   │
│  │  (OrderService, CheckoutUseCase)  │   │
│  │  ┌─────────────────────────────┐  │   │
│  │  │   Domain (Entities)         │  │   │
│  │  │  (Order, Payment, Money)    │  │   │
│  │  └─────────────────────────────┘  │   │
│  └───────────────────────────────────┘   │
└─────────────────────────────────────────┘

Правило: Внешний слой может зависеть от внутреннего. Внутренний — никогда не зависит от внешнего.

На практике это означает:

// Domain — сущности и правила
// Не зависит ни от чего
public class Order {
    private OrderId id;
    private List<OrderItem> items;
    private OrderStatus status;

    public boolean canBeCancelled() {
        return status == OrderStatus.PENDING;
    }

    public Money calculateTotal() {
        // чистая логика, без БД, без HTTP
        return items.stream()
            .map(OrderItem::getTotalWithTax)
            .reduce(Money.ZERO, Money::add);
    }
}

// Application — use case
// Зависит от Domain, но не от Infrastructure
public class CreateOrderUseCase {
    private final OrderRepository repository;    // интерфейс
    private final EventBus eventBus;             // интерфейс
    private final PaymentFacade paymentFacade;   // интерфейс из другого модуля

    public CreateOrderUseCase(
            OrderRepository repository,
            EventBus eventBus,
            PaymentFacade paymentFacade
    ) {
        this.repository = repository;
        this.eventBus = eventBus;
        this.paymentFacade = paymentFacade;
    }

    public Order execute(CreateOrderRequest request) {
        Order order = new Order(request.userId(), request.items());
        // валидация, бизнес-логика
        // вызов payment через порт (интерфейс)
        repository.save(order);
        eventBus.publish(new OrderCreatedEvent(order.getId()));
        return order;
    }
}

// Infrastructure — реализация
public class PostgresOrderRepository implements OrderRepository {
    private final JdbcTemplate jdbc;

    @Override
    public void save(Order order) {
        // SQL-запросы, маппинг
    }
}

#Компромиссы Clean Architecture в монолите

Плюсы:

  • Тестируемость: domain и application тестируются без БД и внешних сервисов
  • Изоляция: инфраструктурные детали не просачиваются в бизнес-логику
  • Переключение: хотите заменить PostgreSQL на CockroachDB — меняете один слой

Минусы:

  • Больше файлов: для каждой «сущности» — интерфейс + реализация + DTO
  • Over-engineering для простых CRUD: если ваш сервис — просто прокси к БД, 4 слоя — это перебор
  • Соблазн «абстракции на всякий случай» — IOrderRepositoryFactory, который никогда не будет использован

#Когда Clean Architecture — это overkill

Будем честны: Clean Architecture для модуля из трёх методов — глупость. Вот когда она оправдана:

  • Модуль содержит бизнес-логику, которая сложнее CRUD
  • Модуль будет развиваться и меняться
  • Модуль тестируется независимо от других
  • В модуле несколько инфраструктурных зависимостей (БД + внешний API + кэш)

Для простых модулей (репозиторий настроек, просмотр лога) — хватит простого разделения на слои без лишних абстракций.


#Когда это применимо

Для стартапа: Только если кодовая база начала расти (>50k строк). До этого — обычный монолит с разделением на папки.

Для средней компании (5-20 разработчиков): Идеальный выбор. Модульный монолит даёт организованность микросервисов без operational complexity.

Для enterprise: Если команда одна и система не требует независимого масштабирования частей — да. Если команд много — смотрите в сторону микросервисов (главы 14-15).

Уровень нагрузки: Low-Medium. Если один модуль требует 1000 инстансов, а другой — 3 — модульный монолит не даст их масштабировать по-разному.

Размер базы: Маленькая-средняя (до нескольких сотен гигабайт). Если у вас 100 TB — скорее всего, вам нужна распределённая архитектура.


#Практический кейс: как SoundCloud перешёл на модульный монолит и что из этого вышло

SoundCloud — классическая история архитектурного путешествия. Они начали с монолита на Rails. Потом перешли на микросервисы (как все модные стартапы в 2014-2016). А потом... вернулись к модульному монолиту.

Почему? В 2017 году у них было 150+ микросервисов. Казалось бы — круто. Но на деле:

  • Деплой одной фичи требовал координации 10-15 сервисов
  • Разработчики тратили 30% времени на настройку dev-окружения
  • Интеграционное тестирование стало «развлечением на неделю»
  • Задержка (latency) между сервисами составляла 15-30ms на цепочку

Их решение: консолидация в модульный монолит. Они взяли gRPC как внутренний протокол между модулями (чтобы сохранить чёткие контракты) и объединили логически связанные сервисы в модули одного приложения.

Результат:

  • Dev-окружение поднимается за 5 минут (было — полдня)
  • E2E тесты проходят за 15 минут (было — 4+ часа)
  • Количество production инцидентов сократилось вдвое
  • DORA metrics (частотность деплоев, время восстановления) улучшились

SoundCloud не единственные, кто прошёл этот путь. Вот ещё несколько известных кейсов:

Segment (поглощён Twilio): Начали с монолита → перешли на микросервисы → осознали, что операционные затраты убивают продуктивность → вернулись к модульному монолиту. [Источник: Segment Engineering Blog, 2018]

Shopify: Всегда были монолитом на Rails, но в 2019-2022 провели massive internal реорганизацию в модульный монолит (пакеты с чёткими границами). И это с 3,000+ разработчиков. Вывод: модульный монолит не мешает масштабировать команду, если модули достаточно изолированы.

Cross-reference

Cross-reference: О том, как Shopify масштабирует монолит на тысячи разработчиков — см. Главу 11 «Монолит — не грязное слово». О distributed monolith как анти-паттерне — см. references/anti-patterns.md.


#Чек-лист для самопроверки

  • Вы выбрали package by feature, а не package by layer?
  • Каждый модуль имеет чётко определённое публичное API (Facade)?
  • Модули зависят от интерфейсов друг друга, а не от реализаций?
  • Composition Root — единственное место, где склеиваются зависимости?
  • Циклические зависимости между модулями отсутствуют (проверено инструментом)?
  • Shared kernel (если есть) содержит только стабильные типы, а не логику?
  • Вы знаете граф зависимостей своего приложения?
  • Хотя бы один bounded context выделен в отдельный модуль?
  • Проверено, что изменение internal-реализации одного модуля не ломает другие?
  • Написаны тесты, которые проверяют только публичное API модуля?

#«Не делай так»

#Анти-паттерн 1: Модуль через git submodule

«Давайте сделаем каждый модуль отдельным репозиторием и будем подключать через git submodule». Я видел это трижды. Ни разу не сработало. Git submodule — это CI-ад. Вы будете тратить 20% времени на синхронизацию версий. Используйте monorepo. С модульностью на уровне кода, а не репозиториев.

#Анти-паттерн 2: Service locator как DI

// Плохо — AppContext.get(). знает всё обо всех
public class OrderService {
    public void process(Order order) {
        PaymentService payment = AppContext.get().getPaymentService();
        NotificationService notifier = AppContext.get().getNotifier();
        // А теперь попробуйте это протестировать
    }
}

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

#Анти-паттерн 3: Shared kernel болезнь

Вы начали с малого — UserId, Money, Address в kernel. Но потом кому-то понадобился хелпер для форматирования дат. И валидатор email. И enum для статусов. И эксепшены. И logger. Через год kernel — это папка на 200 файлов, где лежит всё, включая кухонный комбайн.

Лечение: Каждый раз, когда вы добавляете что-то в kernel, спросите: «Это действительно должны знать все модули?». Если нет — кладите в модуль, который это использует.

#Анти-паттерн 4: Эмуляция микросервисов через встроенный HTTP

«Давайте каждый модуль запустим как отдельный HTTP-сервер на localhost с разными портами». Это не модульный монолит. Это распределённый монолит на localhost. Вы получите все проблемы микросервисов (сетевые ошибки, сериализация, дебаг) без их преимуществ (независимый деплой, масштабирование).

#Анти-паттерн 5: Идеологическая чистота

«Ага, я проверил — этот модуль импортирует класс из internal другого модуля! Нарушаем принципы!». Стоп. Модульный монолит — это инструмент, а не религия. Если два модуля настолько связаны, что разрыв зависимости занимает месяц — может, они и не должны быть разными модулями? Соедините их в один. Серьёзно. Лучше один большой согласованный модуль, чем два модуля, которые делают вид, что независимы, а на деле завязаны друг на друга через запрещённые импорты.


#Ключевые выводы

  1. Модульный монолит — это не «недомикросервис». Это самостоятельная архитектура с уникальным балансом: организованность микросервисов при простоте монолита.

  2. Главное — границы. Не папки, не структура файлов, а изоляция: публичное API модуля отдельно, внутренняя реализация отдельно. Компилятор или convention enforcement.

  3. Dependency injection — не опция, а необходимость. Без Composition Root модульный монолит скатывается в Big Ball of Mud. Единственное место, где модули узнают друг о друге — корень приложения.

  4. Делайте миграцию постепенно, по одному модулю за раз. Map → Identify → Extract → Isolate. Не больше одного модуля за спринт.

  5. Микросервисы проще сделать из модульного монолита, чем из Big Ball of Mud. Если вы планируете распределённую архитектуру в будущем — модульный монолит лучший трамплин. И не факт, что вам вообще понадобится прыгать.