Архитектура и данные

Монолит или микросервисы: что выбрать для нового веб-продукта и когда разделение действительно оправдано

Новый веб-продукт ещё не запущен.

Пользователей пока нет.

Нагрузка неизвестна.

Бизнес-модель ещё несколько раз изменится.

Но на архитектурном обсуждении уже появляется вопрос:

А будем сразу делать микросервисы?

Аргументы обычно звучат убедительно:

Проект ведь будет расти.
Потом монолит станет сложно поддерживать.
Микросервисы лучше масштабируются.
Большие компании работают именно так.

В результате приложение, у которого пока есть пять экранов и один разработчик, может ещё до первого клиента получить:

API Gateway
   │
   ├── Auth Service
   ├── User Service
   ├── Project Service
   ├── Notification Service
   ├── File Service
   └── Billing Service

У каждого сервиса:

Docker;
environment;
порт;
API;
deployment;
логи;
health-check;
конфигурация.

Вместо одного приложения команда получает распределённую систему.

Но бизнес-функций от этого больше не становится.

Поэтому при проектировании нового продукта мы бы начинали не с вопроса:

«Монолит или микросервисы?»

А с другого:

«Какая архитектурная сложность действительно нужна продукту сегодня — и как оставить возможность разделить его завтра?»

Для большинства новых CRM, SaaS, клиентских кабинетов, внутренних систем и B2B-сервисов между двумя крайностями есть особенно интересный вариант:

модульный монолит.

Именно с него начнём.


Что такое монолит на практике

Слово «монолит» часто используется почти как ругательство.

Будто монолит обязательно выглядит так:

app.js
18 000 строк

if (...)
if (...)
if (...)

Это неверно.

Монолит означает прежде всего, что система разворачивается как единое приложение или тесно связанный deployment unit.

Например:

Internet
   ↓
Nginx
   ↓
Application
   ↓
PostgreSQL

Внутри приложения могут находиться:

авторизация;
клиенты;
проекты;
файлы;
уведомления;
платежи;
админ-панель.

Но все они собираются и разворачиваются вместе.

Это само по себе не является архитектурной проблемой.

Проблема начинается, когда внутри приложения отсутствуют границы.


Плохой монолит и хороший монолит — разные вещи

Плохая структура:

routes/
services/
helpers/
utils/
models/

где любой файл импортирует любой другой.

Например:

payment-service

напрямую изменяет таблицы проектов.

notification-service самостоятельно читает внутренние таблицы платежей.

Модуль пользователей знает детали файлового storage.

А удаление одного поля требует правок в пятнадцати местах.

Это уже не столько проблема монолитного deployment.

Это проблема связности кода.


Модульный монолит устроен иначе

Представим CRM:

Application
│
├── Identity
├── Customers
├── Projects
├── Billing
├── Files
└── Notifications

Каждый модуль отвечает за собственную часть бизнеса.

Например:

Billing

знает:

Invoices
Payments
Refunds

а:

Projects

знает:

Project
Stage
ChangeRequest

Один модуль не должен произвольно менять внутренние данные другого.


Внешне это всё ещё одно приложение

Например:

Browser
   ↓
Nginx
   ↓
Node.js Application
   │
   ├── Identity
   ├── Customers
   ├── Projects
   ├── Billing
   ├── Files
   └── Notifications
   ↓
PostgreSQL

У нас:

один deployment;
один runtime;
одна система логов;
одна основная база;
один release.

Но внутри уже существуют границы.

И это принципиально важно.


Хорошие границы позволяют разделить систему позже

Сегодня:

Application
   │
   ├── Billing
   ├── Files
   └── Projects

Через два года может выясниться, что Billing развивается независимо.

Тогда:

Application
   │
   ├── Projects
   └── Files

Billing Service

Если модуль изначально имел понятный контракт, извлечение становится значительно проще.

Если же Billing на протяжении двух лет напрямую обращался ко всем таблицам проекта, разделение превращается в хирургическую операцию по всему приложению.


Поэтому архитектурный вопрос на старте звучит так

Не:

Сколько микросервисов нам создать?

А:

Какие бизнес-границы уже сейчас стоит сделать явными?

Это намного полезнее.


Разберём реальный новый SaaS

Допустим мы создаём B2B-сервис управления проектами.

Функции первой версии:

регистрация;
организации;
роли;
проекты;
сообщения;
файлы;
уведомления;
оплата.

На старте:

100–500 пользователей;
одна команда;
одна production-инфраструктура.

Самый простой монолит:

Node.js
+
PostgreSQL

спокойно способен обслуживать такой продукт.

Но мы всё равно можем разделить domain на модули:

identity/
organizations/
projects/
messages/
files/
billing/
notifications/

Получается модульный монолит.


Зачем делать модули, если deployment всё равно один

Потому что главная архитектурная сложность большого продукта находится не только в количестве серверов.

Она находится в количестве зависимостей между его частями.

Например правило:

Billing
не изменяет Project напрямую.

Вместо:

await db.projects.update(...);

он вызывает публичную операцию:

Projects.activateAfterPayment(...)

или публикует внутреннее событие:

PAYMENT_CONFIRMED

Теперь граница уже существует.

Физически процессы пока не разделены.

Логически — разделены.


Внутренний вызов значительно дешевле сетевого

В модульном монолите:

Projects
   ↓
Billing

может быть обычным вызовом функции.

Если превратить Billing в отдельный сервис:

Projects
   ↓
HTTP
   ↓
Billing Service

появляется целый новый класс проблем.

Например:

timeout;
DNS;
network failure;
retry;
authentication;
API version;
serialization;
latency.

Раньше вызов либо произошёл, либо выбросил exception внутри одного процесса.

Теперь между двумя строками бизнес-логики находится сеть.


Сеть — одна из главных цен микросервисов

Представим монолит:

const customer =
  await customers.get(id);

В случае микросервисов это может превратиться в:

Projects Service
      ↓
HTTP request
      ↓
Customers Service
      ↓
PostgreSQL
      ↓
HTTP response

Что если Customers Service:

работает медленно?

Что если request дошёл, но response потерялся?

Что если сервис перезапускается?

Что если первая попытка выполнила mutation, а client сделал retry?

Мы снова приходим к:

timeouts;
retries;
idempotency;
circuit breakers.

И это уже не теория.

Это обязательная часть distributed architecture.


В монолите одна транзакция может охватить несколько модулей

Представим оформление заказа.

Нужно:

создать Order;
уменьшить Stock;
создать PaymentAttempt.

В одном PostgreSQL это может быть:

BEGIN

INSERT order

UPDATE stock

INSERT payment_attempt

COMMIT

Если что-то не получилось:

ROLLBACK

Система возвращается к исходному состоянию.


В микросервисах такой роскоши может уже не быть

Представим:

Order Service
Inventory Service
Payment Service

У каждого собственная БД.

Теперь операция:

Создать заказ

выглядит:

Order Service
     ↓
Inventory Service
     ↓
Payment Service

Первая операция прошла.

Вторая прошла.

Третья упала.

Что делать?

Обычный:

ROLLBACK

через три независимые базы уже не работает.


Появляется distributed consistency

Нужно проектировать:

events;
compensating actions;
outbox;
idempotency;
saga.

Например:

ORDER_CREATED
      ↓
RESERVE_STOCK
      ↓
STOCK_RESERVED
      ↓
CREATE_PAYMENT

Если платёж создать не удалось:

PAYMENT_FAILED
      ↓
RELEASE_STOCK

Мы заменили одну database transaction на отдельный распределённый business workflow.

Иногда это оправдано.

Но бесплатно такое разделение не бывает.


Поэтому микросервисы — не способ упростить маленький продукт

Они переносят сложность.

Монолит имеет сложность:

внутри одного codebase.

Микросервисы:

между системами.

Вместо одного приложения нужно управлять взаимодействием нескольких независимых приложений.


Что действительно дают микросервисы

Когда система уже достаточно большая, преимущества становятся реальными.

Первое — независимый deployment.

Например:

Billing

изменяется десять раз в месяц.

А:

Document Archive

раз в полгода.

В монолите каждое изменение Billing требует release всего приложения.

В микросервисной архитектуре:

Billing v18

можно обновить отдельно.

Но это работает только если сервис действительно независим.


Если для deployment одного сервиса нужно одновременно обновить пять других — это не настоящая независимость

Например:

Service A v4
requires
Service B v7
requires
Service C v12

И deployment всегда выполняется:

A + B + C

Мы получили микросервисы физически.

Но монолитный release логически остался.

Это один из признаков distributed monolith.


Distributed monolith сочетает недостатки двух подходов

У него уже есть:

сеть;
deployment нескольких сервисов;
distributed logs;
timeouts;
API versioning.

Но нет главного преимущества:

независимости.

Получается:

сложность микросервисов
+
связность монолита.

Такой результат особенно часто появляется, когда систему разделили по техническим слоям.


Например плохое разделение

User Database Service
Project Database Service
Email Service
Validation Service

Каждая простая операция начинает делать:

HTTP
↓
HTTP
↓
HTTP
↓
HTTP

Сервисы слишком мелкие и постоянно разговаривают друг с другом.


Размер микросервиса определяется не количеством строк

Нет правила:

5000 строк
→ пора выносить.

Или:

50 таблиц
→ микросервис.

Гораздо важнее понятие business capability.

Например:

Billing

может быть самостоятельной способностью бизнеса.

Он имеет:

счета;
платежи;
возвраты;
тарифы;
правила.

И потенциально отдельный lifecycle.

А:

FormattingService

вряд ли является отдельным business domain только потому, что содержит много кода.


Второе преимущество — независимое масштабирование

Представим SaaS.

Обычный API:

CPU 15%

Но обработка изображений:

CPU 100%

В монолите приходится масштабировать всё приложение:

Application × 5

хотя дополнительная мощность нужна только одному типу задач.


При разделении можно получить

Core API × 2

Image Processing × 10

Теперь инфраструктура масштабируется под реальную нагрузку.

Это хороший аргумент в пользу выделения отдельного сервиса.


Но «у нас будет высокая нагрузка» недостаточно

Нужно знать:

Где именно она будет?

Если все части продукта растут примерно одинаково, обычное горизонтальное масштабирование монолита может оказаться вполне разумным.

Например:

Nginx
  │
  ├── API #1
  ├── API #2
  ├── API #3
  └── API #4
       ↓
   PostgreSQL

Сам факт:

много пользователей

не требует автоматически микросервисов.


Третье преимущество — fault isolation

Представим отдельно работающий:

Report Generator

Он получает тяжёлый файл и падает из-за memory limit.

Если он находится внутри основного API:

API process
↓
OOM
↓
основной сервис упал.

Если обработчик изолирован:

Report Worker
↓
OOM

основной продукт продолжает работать.

Это уже реальная operational выгода.


Поэтому тяжёлая обработка часто становится хорошим первым кандидатом на разделение

Например:

video conversion;
AI processing;
large PDF generation;
image processing;
document recognition.

У таких задач:

другая нагрузка;
другой runtime;
другие требования к памяти;
часто асинхронный lifecycle.

Их изоляция может быть оправдана раньше, чем разделение обычного CRUD.


Но отдельный worker ещё не обязательно микросервис

Например:

API
Worker
PostgreSQL

могут оставаться одним приложением и одним codebase.

Worker просто имеет другой entrypoint.

Это важный момент.

Не каждое разделение процессов означает микросервисную архитектуру.


Четвёртое преимущество — разные технологические требования

Допустим основной backend написан на Node.js.

Но для ML-обработки удобнее:

Python.

Нет необходимости переписывать весь backend.

Можно получить:

Core API
Node.js

AI Processing
Python

между которыми существует контролируемый контракт.

Это оправданное технологическое различие.


Но «хотим попробовать другой язык» — слабая причина

Если каждый разработчик выбирает:

Node.js
Go
Python
Rust
Java

для маленьких сервисов только из интереса, эксплуатационная стоимость резко растёт.

Теперь нужно поддерживать:

пять toolchain;
пять наборов библиотек;
пять способов сборки;
пять типов runtime.

Polyglot architecture полезна, когда технология решает конкретную проблему.

Не сама по себе.


Пятое преимущество — независимые команды

Это одна из самых важных причин для зрелой организации.

Представим продукт разрабатывают:

3 человека.

Создание:

12 микросервисов

не создаёт 12 независимых команд.

Все сервисы всё равно меняют одни и те же люди.


А теперь представим 80 разработчиков

Есть команды:

Billing Team
Identity Team
Marketplace Team
Fulfillment Team

Каждой трудно координировать release общего огромного приложения.

Теперь самостоятельные сервисы начинают уменьшать организационную связанность.

Архитектурные границы совпадают с границами ответственности команд.

Вот здесь микросервисная модель раскрывает гораздо больше пользы.


Один разработчик не получает автономию от самого себя

Поэтому размер команды — реальный архитектурный фактор.

Микросервисы особенно полезны, когда появляется необходимость:

нескольким командам
независимо
разрабатывать
тестировать
разворачивать
свои части продукта.

Если команда одна, значительная часть этого преимущества исчезает.


Шестое преимущество — разный release cadence

Например:

Core accounting:
изменяется редко,
очень осторожно.

А:

Recommendation Service:
эксперименты каждую неделю.

Разделение позволяет экспериментировать с одной частью, не затрагивая другую.

Это особенно полезно, если у разных частей системы действительно разная скорость изменений.


Но микросервис должен владеть своим поведением

Представим:

Billing Service

но его таблицы напрямую читает:

Projects Service
Analytics Service
Admin Service

Теперь Billing не может самостоятельно изменить схему.

Любая migration требует координации со всеми потребителями.

Независимость потеряна.


Поэтому данные становятся одним из самых сложных вопросов

Настоящая микросервисная автономность обычно предполагает, что сервис владеет своими domain data.

Например:

Billing
↓
Billing DB

Другие сервисы получают сведения через:

API

или:

events.

Они не делают:

SELECT *
FROM billing.payments;

напрямую.


Почему одна общая база для всех сервисов опасна

Допустим есть:

User Service
Project Service
Billing Service

но все используют:

shared PostgreSQL

и свободно читают таблицы друг друга.

На схеме это микросервисы.

На уровне данных — один связанный монолит.

Например Billing меняет:

payments.status

и неожиданно ломает Project Service, который читал это поле напрямую.


Значит ли это, что каждому сервису обязательно нужен отдельный PostgreSQL-сервер?

Нет.

Data ownership — логическая граница.

На небольшом этапе можно физически использовать один кластер PostgreSQL, но разделить:

schema;
credentials;
ownership.

Например:

billing.*
projects.*
identity.*

и не разрешать произвольный доступ между областями.

По мере роста физическое разделение можно выполнить позднее.


Микросервисы меняют способ построения отчётов

В монолите запрос:

SELECT ...
FROM projects
JOIN payments
JOIN users

естественен.

После разделения:

Projects DB
Payments DB
Users DB

такого JOIN больше нет.

Что делать?


Появляются новые архитектурные варианты

Например:

Analytics projection

получает события:

PROJECT_CREATED
PAYMENT_CONFIRMED
USER_REGISTERED

и строит собственную read model.

Или отдельный reporting pipeline.

Опять же мы получили дополнительную инфраструктуру только потому, что разрезали данные.


Это не плохо

Для крупной системы это может быть именно правильным решением.

Но важно понимать причинно-следственную связь:

микросервисная автономия покупается дополнительной распределённой сложностью.


Локальная разработка тоже становится сложнее

Монолит:

docker compose up

поднимает:

App
PostgreSQL
Redis

и разработчик начинает работу.


Микросервисная система может потребовать

Gateway
Identity
Users
Projects
Billing
Notifications
Files
Kafka/RabbitMQ
PostgreSQL × N
Redis
Object Storage

Для изменения одной кнопки разработчику внезапно требуется половина production-архитектуры на ноутбуке.


Поэтому нужен серьёзный local development story

Например:

Docker Compose;
development mocks;
service contracts;
seed data;
local message broker.

Чем больше сервисов, тем важнее инструменты разработчика.

Иначе скорость команды вместо роста уменьшается.


Тестирование тоже становится другим

Монолит позволяет относительно просто запустить:

application
+
test database

и провести integration test.

В микросервисах появляются:

service A;
service B;
service C;
contracts;
events;
network.

Теперь нужно решить:

что мокировать;
что поднимать реально;
как проверять compatibility.

Возникают contract tests

Например Projects ожидает от Billing:

{
  "status": "paid",
  "amount": 50000
}

Billing обновился и начал возвращать:

{
  "paymentStatus": "paid"
}

Оба сервиса по отдельности:

tests PASS.

Вместе:

production FAIL.

Контракт становится самостоятельной частью архитектуры.


API versioning тоже перестаёт быть только публичной проблемой

Даже внутренние сервисы должны менять контракты аккуратно.

Например:

v1

ещё используется старым Project Service.

Новый Billing уже знает:

v2.

Нужно обеспечить период совместимости или координировать rollout.

Внутри монолита compiler или test suite иногда обнаружил бы изменение сразу.

Через сеть ошибки проявляются иначе.


Наблюдаемость микросервисов значительно сложнее

В монолите пользовательский запрос:

POST /checkout

можно найти в одном application log.

В микросервисах:

Gateway
   ↓
Order
   ↓
Inventory
   ↓
Payment
   ↓
Notification

Ошибка появляется:

на четвёртом шаге.

Теперь нужны:

correlation ID;
distributed tracing;
centralized logs;
metrics.

Иначе расследование выглядит:

Давайте откроем логи пяти контейнеров и попробуем сопоставить время.

Один request ID становится особенно ценным

Например:

requestId:
req-81a7

проходит через:

Gateway
Order Service
Payment Service
Notification Service

Теперь можно восстановить маршрут одного пользовательского действия.

Без этого микросервисы резко усложняют поддержку.


Monitoring тоже размножается

Монолит:

API
Database
Worker

Микросервисы:

Service A health
Service B health
Service C health
...

Нужно понимать:

какой сервис критичен;
какой degraded;
куда направлять alert;
какая команда отвечает.

Само разделение кода не решает эту задачу.


Security perimeter тоже расширяется

В монолите:

Browser
↓
API

Основная authorization выполняется внутри приложения.

В микросервисах появляются:

service-to-service authentication;
network policies;
internal secrets;
service identities.

Нужно решить:

Может ли Projects Service доверять запросу от Billing?
Может ли произвольный контейнер вызвать admin endpoint Identity Service?

Количество внутренних trust boundaries увеличивается.


Поэтому «микросервис находится внутри нашей сети» недостаточно

Внутренняя сеть не должна автоматически означать:

полный доступ ко всему.

Для зрелой системы service identity становится частью безопасности.

Это дополнительная operational стоимость, которую монолиту в таком виде оплачивать не приходится.


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

Теперь перейдём к противоположной крайности.

Оставлять систему монолитной навсегда тоже не является универсальной целью.

Есть реальные признаки, что отдельную область пора выделять.


Первый признак — части продукта масштабируются совершенно по-разному

Например:

Core API:
2 instances
Video Processing:
30 workers

Если ради video processing приходится масштабировать весь core application, граница очевидна.


Второй признак — независимые команды постоянно мешают друг другу

Например:

Billing Team

и:

Marketplace Team

работают независимо, но каждый release требует общей координации.

Разделение может уменьшить организационный bottleneck.


Третий признак — компонент имеет явно собственный lifecycle

Например платёжный контур:

Payment
Refund
Invoice
Subscription
Webhook

стал достаточно самостоятельным domain.

У него:

отдельные бизнес-правила;
отдельная команда;
отдельные releases;
отдельные риски.

Это гораздо более сильный аргумент, чем:

Файл billing.js стал большим.

Четвёртый признак — требуется fault isolation

Тяжёлый модуль:

PDF generation

регулярно оказывает влияние на основной API.

Если архитектурная изоляция уменьшает blast radius, выделение приносит measurable пользу.


Пятый признак — технология или инфраструктура принципиально отличается

Например:

Core:
Node.js + PostgreSQL

но:

Search:
Elasticsearch

или:

ML:
Python + GPU.

Отдельный runtime начинает быть естественным.


Шестой признак — нужна независимая частота deployment

Если небольшой модуль выпускается:

20 раз в неделю,

а основной продукт:

раз в месяц,

единый release может становиться искусственным ограничением.


Седьмой признак — граница domain уже хорошо понятна

Это особенно важно.

Разбить систему легко.

Правильно выбрать границы — сложно.

Если продукт только создаётся, мы ещё можем не знать:

Billing и Subscription — один domain или два?
Project и Task должны жить вместе?
Notification относится к бизнес-модулю или является инфраструктурой?

По мере эксплуатации реальные границы становятся намного заметнее.


Это одна из причин не спешить

Если создать микросервисы слишком рано, они закрепят предполагаемые границы.

Потом бизнес-модель изменится.

И выяснится:

Service A

должен постоянно синхронно обращаться к:

Service B.

Мы физически разрезали то, что логически должно было остаться вместе.


Исправлять неправильные service boundaries дорого

Внутри монолита перенос:

функции из модуля A в B

может быть обычным refactoring.

Между микросервисами это может потребовать:

перенос API;
данных;
events;
deployment;
monitoring;
permissions.

Поэтому стабильность domain boundaries имеет большую ценность.


Martin Fowler ещё много лет назад сформулировал подход Monolith First

Суть идеи очень практична:

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

Это не означает:

Микросервисы плохие.

Это означает:

Их стоимость должна решать существующую проблему.

Какие причины мы не считали бы достаточными

Например:

«Так современнее».

Слабая причина.

«Потом будет миллион пользователей».

Недостаточно без понимания нагрузки.

«У Netflix микросервисы».

Размер Netflix ничего не говорит о вашем стартапе.

«Файл стал большим».

Можно разбить модуль внутри приложения.

«Хотим Kubernetes».

Infrastructure tool не должен определять domain architecture.


Особенно опасный аргумент — «сразу сделаем правильно»

Микросервисная архитектура не является финальной «правильной» стадией любой системы.

Она является одним из способов организации системы с конкретными преимуществами и конкретной ценой.

Для некоторых продуктов лучший результат на протяжении всей жизни может выглядеть:

Nginx
   ↓
Modular Monolith × 2
   ↓
PostgreSQL
   ↓
Workers

И это абсолютно нормальная production-архитектура.


Миллион пользователей сам по себе тоже не требует микросервисов

Представим продукт с огромным количеством:

GET

и редкими writes.

Хорошо оптимизированный монолит можно масштабировать горизонтально:

Load Balancer
   │
   ├── App #1
   ├── App #2
   ├── App #3
   └── App #4

с отдельным PostgreSQL, Redis и CDN.

Архитектура остаётся понятной.


Монолит не означает один сервер

Это распространённое заблуждение.

Монолит может иметь:

20 application instances.

Монолитность относится к architecture/deployment unit, а не к количеству VPS.

Так же как микросервисы можно неудачно запустить:

все на одном VPS.

Количество машин и форма кода — разные измерения.


Вертикальное и горизонтальное масштабирование существуют до микросервисов

Сначала можно:

увеличить CPU/RAM;

далее:

добавить application replicas;

далее:

вынести files в S3;
добавить Redis;
вынести workers.

И лишь затем, если появляются реальные границы, разделять domain services.

Рост продукта не обязан выглядеть как один большой прыжок:

монолит
↓
50 микросервисов.

Практичнее эволюционная архитектура

Например новая CRM.

Этап 1

Nginx
↓
Modular Application
↓
PostgreSQL

Этап 2

Появляются фоновые задачи:

API
Worker
PostgreSQL
Redis

Codebase всё ещё может быть одним.

Этап 3

Файлов стало много:

Object Storage

Этап 4

AI-processing требует GPU:

AI Processing Service

Этап 5

Billing обслуживает несколько продуктов:

Billing Service

Система разделяется там, где есть причина.


Это значительно отличается от проектирования микросервисов по списку таблиц

Плохая схема:

users
→ User Service

projects
→ Project Service

messages
→ Message Service

files
→ File Service

Таблица не является автоматически domain boundary.

Например:

Project
Task
Stage
ChangeRequest

могут составлять один цельный domain.

Разделять их только потому, что таблицы разные, бессмысленно.


Микросервисы полезнее строить вокруг бизнес-возможностей

Например:

Order Management

или:

Billing

или:

Identity

а не:

TableService.

Тогда сервис способен отвечать за завершённую часть поведения.


И не каждый модуль должен когда-нибудь стать микросервисом

Это тоже важно.

Например модуль:

User Preferences

может спокойно жить внутри основного приложения десять лет.

Модульная архитектура не является:

списком будущих микросервисов.

Это способ держать систему понятной независимо от способа deployment.


Как подготовить монолит к возможному разделению

Мы бы начали с нескольких правил.

Во-первых, каждый domain module имеет понятный публичный интерфейс.

Например:

Billing.confirmPayment()
Billing.createInvoice()
Billing.refund()

а не прямой доступ ко всем внутренним таблицам.


Во-вторых, ограничиваем cross-module data access

Например Projects не должен знать структуру:

billing_refunds

Ему нужен бизнес-факт:

payment confirmed.

Это позволит позже изменить внутреннюю модель Billing без переписывания Projects.


В-третьих, полезны domain events

Например:

PROJECT_COMPLETED

интересует:

Notifications;
Analytics;
Billing.

В монолите event может распространяться через простой внутренний event bus.

Позже тот же контракт можно перенести на внешнюю очередь.


То есть не обязательно начинать с Kafka

Сегодня:

in-process event

Завтра:

durable PostgreSQL job

Позже:

message broker.

Бизнес-событие остаётся концептуально тем же.

Infrastructure развивается постепенно.


В-четвёртых, стоит избегать circular dependencies

Плохо:

Projects
→ Billing
→ Projects
→ Notifications
→ Billing

Такой graph очень трудно разделить.

Лучше понимать направление dependencies и переносить shared concepts на подходящий уровень.


В-пятых, модуль должен иметь владельца данных

Даже внутри одной БД можно договориться:

projects tables
→ Projects module
payments tables
→ Billing module

Другой модуль не выполняет произвольные UPDATE.

Это дисциплина, которая ничего не стоит на уровне infrastructure, но сильно облегчает будущее разделение.


Как понять, какой сервис выносить первым

Не нужно выбирать самый большой модуль.

Хороший кандидат обычно имеет сразу несколько свойств:

понятная domain boundary;
мало тесных транзакций с остальными;
самостоятельный workload;
реальная причина независимого deployment;
измеримая выгода от isolation.

Пример: генерация документов

Допустим SaaS создаёт большие PDF.

Первоначально:

Core API
↓
generatePDF()

При большом документе:

CPU 100%
RAM 1.5 GB

API начинает тормозить.

Хороший следующий шаг:

Core API
   ↓
Job Queue
   ↓
Document Worker

Если позже subsystem развивается отдельно:

Document Service

Он уже имеет естественную границу.


Пример: Notifications

На старте:

sendEmail()

внутри приложения.

Потом появляются:

email;
Telegram;
SMS;
push;
templates;
retries;
delivery status;
preferences.

И нагрузка растёт.

Теперь Notifications начинает выглядеть как самостоятельная capability.

Можно выделять.


Но выносить его в первый день необязательно

Если продукт отправляет:

20 писем в день,

отдельный Notification Service вряд ли решит реальную проблему.

Он просто создаст дополнительный deployment.


Пример: Billing

На старте:

один тариф;
один способ оплаты;
один webhook.

Модуль внутри монолита может быть достаточен.

Через два года:

subscriptions;
multiple providers;
refunds;
invoices;
tax;
plans;
limits;
entitlements.

Billing становится самостоятельной сложной подсистемой.

Теперь выделение может иметь хороший смысл.


Но выносить Billing нужно осторожно из-за данных

Другие части продукта не должны одновременно продолжить:

UPDATE payments

напрямую.

Иначе сервис физически вынесен, а ownership не изменился.

Правильная миграция архитектуры включает и изменение зависимостей.


Как технически извлекать сервис из модульного монолита

Обычно безопаснее делать это постепенно.

Было:

Core
├── Projects
└── Billing

Сначала вводим явный internal interface:

Projects
↓
Billing API abstraction

Хотя Billing ещё внутри того же процесса.


Затем убираем прямой доступ к данным

Projects больше не делает:

SELECT *
FROM payments;

Только:

Billing.getPaymentStatus(...)

Следующим этапом можно отделить данные

Например:

billing schema

получает отдельного DB user.

Другие модули больше не имеют прямых прав.


Затем implementation можно вынести за сеть

Было:

Billing.getStatus()

Стало:

HTTP/RPC
↓
Billing Service

Calling code концептуально уже готов к границе.


Потом появляется независимый deployment

Core v18
Billing v6

И только тогда можно говорить, что сервис действительно стал автономнее.


Такой путь намного безопаснее большого переписывания

Плохая стратегия:

Сейчас за три месяца перепишем весь монолит на 30 микросервисов.

До завершения проекта:

старый продукт продолжает меняться;
новая архитектура отстаёт;
границы оказываются неправильными.

Поэтапное извлечение позволяет получать пользу после каждого этапа.


Иногда выделение сервиса можно остановить посередине

Например после:

ясных module boundaries
+
отдельного worker

проблема исчезла.

Нет никакой обязанности продолжать:

↓
HTTP service
↓
отдельная DB
↓
Kubernetes.

Архитектура существует для продукта.

Не продукт для архитектуры.


Сравним варианты

КритерийМонолитМодульный монолитМикросервисы
Запуск нового продуктаОчень простойПростойСложнее
DeploymentОдинОдинНесколько независимых
Локальная разработкаПростаяПростаяСложнее
ТранзакцииПростыеПростыеРаспределённая согласованность
Границы domainМогут отсутствоватьЯвныеОбязательны
Независимое масштабированиеОграниченоОграниченоСильная сторона
Независимые командыОграниченноХорошо до определённого масштабаСильная сторона
ObservabilityПрощеПрощеЗначительно сложнее
Network failures внутри domainНетНетДа
Independent deploymentНетНетДа
Fault isolationОграниченоЧастичноЛучше при правильном дизайне
Стоимость инфраструктурыНижеНижеВыше
Требования к DevOps maturityНижеУмеренныеВысокие
Возможность эволюцииЗависит от структурыОчень хорошаяХорошая при верных границах

Из этой таблицы хорошо видно:

модульный монолит часто даёт значительную часть архитектурной дисциплины микросервисов без большей части распределённой стоимости.


Что мы бы выбрали для нового CRM

Если это новая CRM:

до нескольких тысяч пользователей;
одна команда;
один основной продукт;

мы бы обычно начинали с:

Modular Monolith
+
PostgreSQL
+
Worker
+
Object Storage при необходимости

а не с десятка сервисов.


Что мы бы выбрали для нового SaaS

Аналогично:

Core Application
├── Identity
├── Organizations
├── Projects
├── Billing
├── Files
└── Notifications

с хорошими границами.

По мере роста можно отдельно вынести:

AI processing;
media processing;
billing;

или другой subsystem, где появилась конкретная причина.


Что насчёт интернет-магазина

Можно начать с модулей:

Catalog
Orders
Inventory
Payments
Customers

в одном приложении.

Если бизнес вырастет:

Catalog

может масштабироваться отдельно.

Inventory может интегрироваться с большим количеством складов.

Payments — стать самостоятельной финансовой подсистемой.

Но на первом этапе единая транзакционная модель может быть значительно проще и безопаснее.


А когда мы бы сразу серьёзно рассматривали микросервисы

Например если новый продукт фактически создаётся внутри уже крупной организации.

Есть:

несколько независимых команд;
существующая платформа сервисов;
централизованный monitoring;
CI/CD;
service discovery;
message broker;
platform engineering.

И заранее известны устойчивые domain boundaries.

Тогда стоимость микросервисов уже частично оплачена существующей инфраструктурой.


Или если части системы имеют принципиально разные требования

Например продукт изначально состоит из:

Transactional API

и:

GPU inference cluster.

Очевидно, запускать их как один process бессмысленно.

Здесь разделение существует не ради моды.

Оно следует из workload.


Ещё один хороший критерий — стоимость отказа

Если падение:

Recommendation Engine

не должно останавливать:

Checkout,

разделение может давать полезную fault isolation.

Но если два сервиса всё равно критически зависят друг от друга синхронно, физическое разделение не обязательно улучшит resilience.


Микросервисы не гарантируют отказоустойчивость автоматически

Можно создать:

10 сервисов,

каждый из которых требует:

User Service

для любого запроса.

User Service упал.

Теперь:

все 10 сервисов
фактически недоступны.

Мы создали распределённую архитектуру и один центральный bottleneck.

Resilience тоже нужно проектировать.


Иногда монолит имеет меньший blast radius, чем плохие микросервисы

Например одно приложение имеет локально доступные данные.

У него нет:

пяти сетевых hops

для простого запроса.

Значит меньше зависимостей способно его разрушить.

Разделение само по себе не делает систему надёжнее.


Что следует измерить перед разделением

Вместо архитектурного спора полезно посмотреть на факты:

какой модуль создаёт нагрузку;
какой модуль чаще меняется;
какой модуль вызывает инциденты;
где команды блокируют друг друга;
какие deployment действительно должны быть независимыми.

Если проблема измеряется — можно оценить, решит ли её сервис.


Хороший архитектурный вопрос

Не:

Получим ли мы пользу от микросервисов?

Она почти всегда найдётся.

А:

Будет ли эта польза больше стоимости распределённой системы?

Стоимость включает:

network;
retries;
idempotency;
observability;
deployment;
security;
data consistency;
testing;
local development;
operations.

Вот это уже реальное сравнение.


Как понять, что монолит пока не мешает

Например:

deployment быстрый;
tests укладываются в разумное время;
команда понимает domain;
модули имеют границы;
масштабирование приложения работает;
release одной функции не создаёт большой риск.

Тогда сама по себе монолитность не является проблемой.

Не нужно создавать проблему, чтобы потом решить её микросервисами.


Как понять, что модуль пора выделять

Пример сильной комбинации:

Billing имеет чёткую domain boundary;
его развивает отдельная команда;
он масштабируется отдельно;
у него собственный release cadence;
прямые транзакции с core минимальны;
есть mature monitoring/CI/CD.

Теперь extraction выглядит экономически и технически оправданным.


А вот слабая комбинация

«Billing folder стал большим».

И:

«Хотим современную архитектуру».

Этого мало.


Самое неприятное последствие слишком ранних микросервисов

Они могут замедлить получение ответа на главный вопрос нового продукта:

Нужен ли он вообще пользователям?

Пока команда строит:

service discovery;
distributed tracing;
message broker;
API gateway;
CI/CD десяти сервисов,

конкурент выпускает полезную функцию в обычном монолите.

На ранней стадии скорость изменения продукта часто важнее возможности независимо масштабировать компонент, который пока никто не использует.


Но и быстрый MVP не должен быть бесструктурным

Из идеи:

Сначала монолит.

не следует:

Можно писать как угодно.

Наоборот.

Если есть вероятность роста, именно модульная структура позволяет сохранить скорость сейчас и свободу позже.


Это компромисс, который мы чаще всего считаем наиболее практичным

Один deploy
+
Явные domain modules
+
Контролируемые зависимости
+
Ownership данных
+
Internal events
+
Тестируемые контракты

Пока сеть между модулями не нужна.

Когда она действительно понадобится — архитектура уже подготовлена.


Можно сформулировать путь развития так

Неструктурированный монолит

не является обязательной первой стадией.

Лучше:

Модульный монолит
        ↓
Выделенные workers
        ↓
Независимые инфраструктурные компоненты
        ↓
Отдельные сервисы там,
где появилась реальная причина

Это эволюция.

Не революция.


Не нужно заранее знать финальную архитектуру

Это ещё один важный момент.

У продукта через три года могут появиться функции, которых сегодня нет даже в бизнес-плане.

Поэтому попытка уже сейчас нарисовать:

Service #1
...
Service #27

создаёт иллюзию точности.

Гораздо полезнее спроектировать:

правильные текущие границы
+
возможность изменять их.

Архитектура должна позволять ошибаться дёшево

Если границу модуля внутри монолита выбрали неправильно, её можно перенести относительно легко.

Если создали:

отдельный сервис;
отдельную БД;
внешний API;
message contracts;
deployment;
monitoring,

перенос становится намного дороже.

На раннем этапе, когда неопределённость высока, обратимые решения особенно ценны.


Иногда лучшее архитектурное решение — пока ничего не разделять физически

Но подготовить:

границы;
контракты;
ownership;
events.

Это не технический долг.

Это сознательное управление сложностью.


Что в итоге выбрать

Если новый проект — это:

CRM;
SaaS;
клиентский кабинет;
B2B-сервис;
интернет-магазин;
внутренняя система,

и его делает одна небольшая команда, мы бы в большинстве случаев начинали с модульного монолита.

Это даёт:

простую разработку;
простые транзакции;
простой deployment;
простую диагностику;
низкую инфраструктурную стоимость.

При этом правильные module boundaries оставляют возможность дальнейшего разделения.


Микросервисы становятся сильным решением позднее

Когда одновременно появляются:

доказанные domain boundaries;
независимые команды;
разный workload;
разный release cadence;
необходимость fault isolation;
реальная потребность в independent scaling.

Тогда дополнительная distributed complexity начинает окупаться.


Вместо вывода

Монолит и микросервисы часто обсуждают так, будто это выбор между:

старой архитектурой

и:

современной архитектурой.

На практике всё намного интереснее.

Хорошо спроектированный модульный монолит может годами обслуживать серьёзный production-продукт.

А плохо спроектированные микросервисы могут превратить даже относительно простой SaaS в систему, где для открытия одной страницы требуется успешная работа шести процессов, трёх API и очереди сообщений.

Микросервисы дают реальные преимущества:

независимое масштабирование;
независимый deployment;
изоляцию отдельных нагрузок;
автономию больших команд;
разные технологические runtime.

Но вместе с ними появляются:

сетевые сбои;
timeouts;
retries;
idempotency;
distributed consistency;
contract testing;
distributed tracing;
service-to-service security;
сложный deployment.

Поэтому вопрос должен звучать не:

«Достаточно ли наш проект серьёзный для микросервисов?»

А:

«Какая конкретная проблема нашего продукта настолько серьёзна, что ради её решения мы готовы заплатить стоимость распределённой системы?»

Если такого ответа пока нет, это хороший сигнал не спешить.

Сделать приложение модульным.

Определить domain boundaries.

Ограничить зависимости.

Закрепить ownership данных.

Отделить тяжёлые background jobs.

Наблюдать за реальной нагрузкой и работой команды.

А затем разделять именно те части, которым это действительно нужно.

Так архитектура развивается вместе с продуктом.

Не раньше него.

И в долгосрочной перспективе именно это часто оказывается намного важнее количества прямоугольников со словом Service на архитектурной схеме.

Есть похожая задача?

Опишите продукт, интеграции и ограничения. До разработки зафиксируем объём, риски и критерии приёмки.

Обсудить проект →