Modular Monolith concept page: one deploy unit with hard internal module boundaries (catalog/orders/payments), own DB schemas per module, in-process event bus, dependency-cruiser as fitness function, Strangler Fig extraction to microservice when operationally justified.
В 2014–2018 индустрия с разбегу прыгнула в microservices: каждое стартап-выступление было про "мы развалили монолит, теперь у нас 80 сервисов". К 2022–2026 пошла обратная волна и десятки публичных post-mortems: Segment (2018) свернули 50+ destination-микросервисов обратно в монолит — они стали unmaintainable; Amazon Prime Video (2023) переписали audio/video monitoring из serverless + microservices в монолит и получили −90% costs; Istio вернулся к монолитному control-plane; InVision и Deliveroo опубликовали ретроспективы "premature microservices killed our velocity".
Параллельно крупнейшие в мире продукты так и не уходили из монолита: Shopify (Rails 1M+ строк), GitHub (Rails core), Stack Overflow (~10 серверов .NET), Basecamp, Square — все работают на модульных монолитах и публично этим гордятся.
Modular Monolith — компромисс между Big Ball of Mud и распределённой системой: один deployment unit, но внутренние границы между модулями настолько строгие, как если бы это были отдельные сервисы. Получаем organizational benefits (own boundaries, own ownership, own data) без operational tax распределённой системы (network latency, distributed tracing, eventual consistency hell, 50 CI pipelines).
Это default-выбор для greenfield и команд до ~50 инженеров. Микросервисы — отдельный модуль выделяем, когда есть конкретный operational driver (independent scaling, deploy cadence, failure isolation, tech stack), а не из карго-культа.
"Монолит = один deployable unit. Модульный монолит = один deployable unit с внутренними границами, гарантированными машиной (lint, ArchUnit, dependency-cruiser), а не социальным контрактом. Communication через public API и events, не через shared mutable state и не через cross-schema SQL. Модуль выделяется в сервис, когда нужно, а не из эстетики."
Спектр архитектур:
[ Big Ball of Mud ] -- [ Layered Monolith ] -- [ Modular Monolith ] -- [ Microservices ]
хаос n-tier, coupling DDD modules distributed
Layered Monolith (controllers/, services/, repos/) — это не modular: модули по технической роли, а не по бизнес-доменам. Modular = по bounded contexts. Удалить модуль = удалить папку + миграции, и остальное компилируется.
Три кита модульности:
modules/<name>/api/index.ts, всё остальное private.И главное: enforcement машиной. Без dep-cruiser/ArchUnit все границы за полгода превращаются в big ball of mud.
Один процесс, один deploy, три модуля внутри.
Client edge (вход):
HTTP client → Load balancer (50K rps capacity)Single process / single deploy unit (монолит):
Bootstrap / DI composition root — единственное место, где модули собираются вместе. Содержит ADR-001 про выбор modular monolith over microservices и ADR-002 про когда extract.catalog-api (public), catalog-domain (Product, Stock), catalog-repo.orders-api (public, с ADR-003 про inter-module communication), orders-domain (Order aggregate), orders-repo.payments-api, payments-domain (Charge), payments-repo.In-process event bus — простой EventEmitter с error isolation, capacity 100K rps.dependency-cruiser — CI fitness function, lint-правила.PostgreSQL (один DB, отдельные schema на модуль):
schema: catalog.*schema: orders.*schema: payments.*Future: extracted Payments microservice (показан отдельной зоной для сценария extraction):
Kafka topic: order.eventspayments-svc (separate deploy)payments-svc DB (own)Edges на диаграмме — только физические соединения (так велит CLAUDE.md). Ответы и обратный поток данных идут как reverse-animation по тем же edges.
Ключевые connections:
bootstrap → *-api (composition root маршрутизирует во все модули).orders-api → catalog-api (только через public API, единственный inter-module edge между бизнес-модулями).*-api → *-domain → *-repo → schema-* (внутренний слой модуля).orders-api → bus → payments-api (decoupled, pulse-edges).depcruiser → *-api (CI gate, pulse).payments-api → kafka → payments-svc → schema-payments-svc (extraction path).Что не нарисовано (намеренно): edges между orders-repo и schema-catalog, FK между schemas, прямые импорты modules/orders → modules/catalog/infrastructure/*. Эти стрелки не существуют — и в этом весь смысл.
Три сценария в FlowBuilder, каждый со своим pill в плеере.
Happy path: пришёл POST /orders { items, userId }. Bootstrap дёргает ordersApi.placeOrder. Orders нужны актуальные цены и stock — делает typed call через public API: catalogApi.checkStock({sku, qty}). Дальше Catalog внутри себя: catalog-api → catalog-domain → catalog-repo → schema-catalog (SQL только в своей schema), возвращает StockResult. Orders денормализует productSnapshot (важно — заказ должен пережить изменение цены/каталога), пишет в schema-orders, и публикует OrderPlaced в bus. Payments-handler подписан в bootstrap, асинхронно создаёт pending Charge в schema-payments. Возврат 201.
Инварианты, которые сценарий явно перечисляет в showMessage в конце:
catalog.* или payments.*.PR от разработчика: ему лень добавлять метод findPrice в catalogApi, он импортирует ProductRepository напрямую из modules/catalog/infrastructure/*. Сборка проходит локально — TypeScript-то не против.
dependency-cruiser в CI запускается с правилом no-cross-module-internal: from=modules/orders/** to=modules/catalog/(?!api). Build падает с явным сообщением: "orders may only import from modules/catalog/api/*". PR заблокирован.
Fix: добавить публичный метод catalogApi.findPrice(sku) (расширить контракт явно). Re-run CI → OK.
Сценарий показывает арсенал enforcement-инструментов по стекам:
*.sql миграциях.И четыре antipattern-карточки в конце: модульность без machine enforcement, модуль-per-layer вместо per-bounded-context, cross-schema FK, god shared kernel.
Через год после запуска у payments появились конкретные drivers: (a) PCI scope isolation, (b) deploy cadence 5×/день vs monolith 1×/неделя. Сценарий пошагово показывает Strangler Fig extraction за 6 недель:
OrderPlaced schema versioned (Avro/Protobuf).payments-svc deployed в shadow mode (без traffic).payments-api в монолите превращается в adapter: публикует и в in-process bus, и в Kafka — dual-write. Public API модуля не меняется — orders module про migration не знает.Результат: payments-svc deploys 5×/день, PCI scope = один сервис. Catalog и orders остаются в монолите — у них нет driver.
Ключевой insight, который сценарий проговаривает: event contract уже был structured с day 1, поэтому extraction занял 6 недель, а не 6 месяцев. Anti-patterns в финале — extract everything at once, extract by data ownership (split table → cross-service joins), extract до стабилизации API, extract без operational driver. Реальные примеры: Shopify/GitHub/Basecamp/Stack Overflow/Square — остались монолитом; Amazon Prime Video и Segment — вернулись в монолит после микросервисов.
Три ADR закреплены прямо в узлах диаграммы — кликаются на bootstrap и orders-api.
ADR-001 (bootstrap): Modular monolith over microservices для greenfield
Контекст: команда 8 инженеров, greenfield e-commerce, unknown bounded contexts, time-to-market 6 месяцев. Альтернатива (cargo-cult): начать с микросервисов. Failure modes этой альтернативы для greenfield (из публичных post-mortems): distributed monolith, eventual consistency hell, ops overhead на 2–3 FTE (37% capacity команды), невозможность cross-cutting refactoring, network latency baseline 250ms на запрос, невозможность открыть boundaries в advance.
Решение: Modular Monolith как default. Один TS deploy + 3 модуля с структурой modules/<name>/{api, domain, application, infrastructure, tests}. Запреты machine-enforced: no-cross-module-internal в dep-cruiser, CODEOWNERS на module level, separate Postgres schemas, lint cross-schema FK. Inter-module: только public API + in-process event bus. Shared kernel минимальный (~200 LOC). Extract — когда есть конкретный driver, не раньше.
Ожидания: 3–5× faster initial development, 10× fewer ops bugs, 20–40% lower infra cost, easier hiring. Trade-offs приняты: cannot scale modules independently, single point of failure (mitigated через blue-green), uniform tech stack, build time grows (split test runners при > 5 минут).
ADR-002 (bootstrap): Selective extraction по operational drivers
Контекст: через 8 месяцев появились запросы на extraction — ML team хочет Python, payments хочет 5×/день deploy, Black Friday traffic 20× только на catalog reads. Cargo-cult ответ: extract everything. Реальный анализ через lens Newman'a по каждому модулю: independent scaling? deploy cadence? failure isolation? tech stack mandatory? team autonomy?
Решение: Strangler Fig, один модуль за раз, по конкретному driver. Roadmap: месяц 1–2 — recommendation engine в Python (driver = tech stack). Месяц 3–5 — payments в TS service (driver = PCI + deploy cadence), in-process bus меняется на Kafka, event contract тот же. Месяц 6+ — catalog stays in monolith, read scaling через Postgres read replicas (cheaper). Notifications/audit/analytics — extract только когда заболит.
Operational rules: 6 недель preparation, 2 недели shadow, gradual cutover 1→10→50→100%, rollback button 30 дней, public API модуля не меняется (внутри adapter).
ADR-003 (orders-api): Inter-module communication — public API + in-process event bus only
Контекст: orders нужны catalog данные, нужно уведомить payments + notifications. Альтернативы рассмотрены и отвергнуты:
JOIN catalog.products — самая частая ошибка в "модульных" монолитах: на бумаге модули, в DB big ball of mud, rename column ломает другой модуль, dep-cruiser SQL не видит.catalog/internal/ProductRepository напрямую — обходит public API, silent coupling.Решение: ровно два канала. (1) Direct typed call через public API (import { catalogApi } from 'modules/catalog/api') для request-response. (2) In-process event bus для async/decoupled flows. Event schema versioned, ошибка одного handler не валит других, opt-in async.
DB rules: каждый модуль владеет своей schema; никаких cross-schema FK; данные другого модуля либо денормализуются в event (productSnapshot at order time), либо читаются через API. Shared kernel минимальный (Money, typed IDs, DomainError), изменения требуют review всех team leads.
Главный payoff: когда payments через год extract в Kafka-сервис, меняется только bus impl — event schema и handler logic остаются. Orders не знает, in-process payments или remote. Архитектура portable by design.
Остались модульным монолитом и публично гордятся:
Вернулись из микросервисов в монолит:
Tooling по стекам:
| Стек | Tool / Approach |
|---|---|
| Java/Spring | Spring Modulith — boundary verification, events |
| Node.js/TS | NestJS modules (built-in DI) + dependency-cruiser + Nx workspace constraints |
| Python | Django apps как естественные модули + import-linter |
| Elixir | Phoenix Contexts (first-class) |
| Ruby | Rails Engines |
| JVM build | Gradle/Maven multi-module — compile-time isolation |
| TS monorepo | Nx (module library system + boundaries), Turborepo (build cache + graph enforcement) |
| Java/Kotlin tests | ArchUnit (assert architecture rules в JUnit) |
| .NET | NetArchTest |
internal classes другого модуля. Обходит public API. dependency-cruiser должен ловить, если правила weak — silent coupling.shared/. Изменения там влияют на всех, никто за это не отвечает. Лечится строгим CODEOWNERS + size budget (Money + IDs + errors only).controllers/, services/, repos/). Это layered monolith, не modular. Modular = по bounded contexts.Record<string, any> payload. Debug ад, runtime crashes при schema drift.Modular monolith — default, но не universal hammer.
Книги:
Статьи и talks:
Документация инструментов:
Связанные концепты на этом сайте:
ddd-strategic — bounded contexts = модули.hexagonal-ports-adapters — каждый модуль hexagonal внутри.event-storming — discovery модульных границ.evolutionary-architecture — fitness functions, которые enforce boundaries.micro-frontends — фронтовая параллель той же идеи.