Опирается на правила:
R-DIST-WHEN-1…R-DIST-WHEN-3иR-DIST-WHEN-X1…R-DIST-WHEN-X2из Distributed Patterns Style Guide → раздел 1. Когда нужны распределённые паттерны.
Важно знать
- Распределённые паттерны нужны, когда бизнес-операция охватывает 2+ сервиса и нельзя завершить её одной локальной транзакцией.
- В пределах одного сервиса с одним PostgreSQL — обычный
@Transactionalи атомарность БД, никаких саг и outbox.- Перед введением distributed-сложности проверь три альтернативы: объединение в один BC, modular monolith, eventual consistency без саги.
- Распределение всегда дорогое: больше latency, сложнее debugging, нужно distributed tracing, появляются новые failure modes.
- Микросервисы из амбиций — без бизнес-требования — главный источник лишней сложности на проекте.
- Если два сервиса всегда меняются вместе — это один Bounded Context, разделён ошибочно.
Распределённые паттерны — saga, outbox, idempotent consumer, eventual consistency — это инструменты для cross-service сценариев. У каждого есть цена: дополнительный код, дополнительные таблицы, дополнительные failure modes. Применяем когда бизнес действительно требует разнесения по сервисам, не «потому что так модно».
Три условия, при которых нужны распределённые паттерны
R-DIST-WHEN-1: распределённые паттерны нужны, когда выполняется одно из следующих условий:
- Операция охватывает 2+ сервиса. «Создать заказ» =
order-service+payment-service+inventory-service. Каждый шаг — отдельная локальная транзакция в своём PostgreSQL; нельзя объединить в одну. - Операция охватывает 2+ датасорса. Перевод между счетами в разных банках — у каждого банка свой PG, объединить нельзя по природе.
- Операция требует cross-service побочного эффекта. Изменение статуса заказа → нотификация. Если notification-service лежит, заказ всё равно должен обновиться — нужны outbox + retry, иначе сцепка ломается.
// Пример: создать заказ + списать оплату + зарезервировать товар
// Три сервиса, три PG. Локальной транзакции, объединяющей все три, не существует.
@Component
public class CreateOrderSaga {
public void run(CreateOrderRequest request) {
var sagaId = UUID.randomUUID();
var orderId = orderService.create(sagaId, request);
var paymentId = paymentService.charge(sagaId, orderId, request.amount());
try {
inventoryService.reserve(sagaId, orderId, request.items());
} catch (Exception e) {
paymentService.refund(sagaId, paymentId);
orderService.cancel(sagaId, orderId);
throw e;
}
}
}
Если ни одно из трёх условий не выполняется — distributed-паттерны не нужны.
Когда распределённые паттерны НЕ нужны
R-DIST-WHEN-2: если операция в одном сервисе и одном PostgreSQL — используй обычный @Transactional (R-JOOQ-TX-1) и атомарность БД.
@UseCase
@RequiredArgsConstructor
public class ConfirmOrderHandler implements UseCaseHandler<ConfirmOrderCommand, Order> {
private final OrderRepository orderRepository;
@Override
@Transactional
public Order handle(ConfirmOrderCommand command) {
var order = orderRepository.findById(command.orderId(), SelectMode.FOR_UPDATE)
.orElseThrow(() -> new OrderNotFoundException(command.orderId()));
order.confirm();
return orderRepository.save(order);
}
}
Здесь сага была бы карго-культом: одна локальная транзакция в одном PG атомарна, никакая compensation не нужна, никакой outbox для синхронизации внутри одного сервиса не нужен. Если потом понадобится опубликовать OrderConfirmed в Kafka — outbox добавится именно для публикации, не для атомарности внутри сервиса.
Три альтернативы перед введением distributed-сложности
R-DIST-WHEN-3: прежде чем вводить сагу, outbox и идемпотентность, проверь три альтернативы.
1. Объединение сервисов
Если два сервиса всегда меняются вместе, всегда деплоятся вместе, всегда обсуждаются вместе — это один Bounded Context, разделение было ошибкой. Пример: customer-service хранит профиль, customer-preferences-service хранит настройки уведомлений. Если ни один use case не работает с одним без другого — объединить, никаких саг и outbox не нужно.
2. Modular monolith
Несколько Bounded Context в одном процессе и одной БД, но логически разделённых: разные схемы, разные пакеты, ArchUnit-правила запрещают cross-module зависимости. Локальные @Transactional работают через границы модулей, distributed-сложность не нужна. Это дефолтный выбор для нового продукта с командой меньше 30 человек.
order-service (modular monolith):
├── orders/ (BC Order)
├── payments/ (BC Payment, своя схема payments в том же PG)
└── inventory/ (BC Inventory, своя схема inventory в том же PG)
CreateOrderHandler @Transactional
→ ordersRepo.insert(...)
→ paymentsService.charge(...) // обычный вызов, та же транзакция
→ inventoryService.reserve(...) // обычный вызов, та же транзакция
Когда команда вырастает и BC начинают деплоиться независимо — отделяются в свои сервисы, тогда появляются саги.
3. Eventual consistency без саги
Если нет необходимости в rollback'ах — нет саги. Достаточно: write в исходный сервис → outbox → событие → read-side обновление. Пример: OrderConfirmed → notification-service отправляет SMS. Если SMS не отправится — заказ всё равно подтверждён, никакой compensation не нужно, проблема решается retry на стороне consumer.
| Сценарий | Нужна сага? |
|---|---|
| Создать заказ + списать оплату + зарезервировать товар | Да — нужна compensation при сбое |
| Изменить статус заказа + отправить уведомление | Нет — события + retry |
| Создать пользователя + создать профиль + создать настройки в одной БД | Нет — @Transactional |
| Перевод между счетами разных банков | Да — нужна compensation |
Что запрещено
Распределённые паттерны для одного сервиса
R-DIST-WHEN-X1: сага для двух операций в одной БД — self-orchestrated сложность. Если все шаги — INSERT/UPDATE в одном PostgreSQL, @Transactional атомарен и compensation тебе не нужен.
// ПЛОХО — «сага» для двух INSERT в одной БД
@Component
public class CreateOrderSagaWrong {
@Transactional public void run(...) {
try {
ordersRepo.insert(...);
try {
orderItemsRepo.insert(...);
} catch (Exception e) {
ordersRepo.deleteById(orderId); // ручной rollback в той же транзакции
throw e;
}
} catch (Exception e) { ... }
}
}
@Transactional сам откатит обе вставки при исключении — никакой compensation не нужен.
Микросервисы из амбиций
R-DIST-WHEN-X2: распределение всегда дорогое:
- Latency увеличивается на каждый сетевой hop (1ms PG-запрос превращается в 30ms HTTP-вызов).
- Debugging сложнее: нужен distributed tracing, корреляционные ID, OpenTelemetry.
- Failure modes появляются новые: timeout, partial failure, retry storm, network partition.
- Транзакционность теряется: невозможно атомарно обновить два сервиса, нужны саги и compensation.
Если бизнес не требует разделения — лучше modular monolith. «Мы хотим микросервисы потому что Netflix» — не аргумент. Аргумент — «команды по 8 человек на каждый сервис, разный deploy cadence, разные SLA, разные стэки».
Что запрещено — таблица
| Антипаттерн | Правило | Что взамен |
|---|---|---|
| Распределённые паттерны для одного сервиса | R-DIST-WHEN-X1 | @Transactional |
| Микросервисы без бизнес-требования | R-DIST-WHEN-X2 | modular monolith |
| Saga для двух операций в одной БД | R-DIST-WHEN-X1 | одна локальная транзакция |
| Outbox для in-process событий | R-DIST-WHEN-X1 | прямой вызов в том же @Transactional |
| Разделение тесно связанных сервисов | R-DIST-WHEN-3 | объединение в один BC |
Куда дальше
- Distributed Patterns → раздел 1. Когда нужны распределённые паттерны — нормативные формулировки
R-DIST-WHEN-*. - Saga — оркестрация vs хореография — главный паттерн для cross-service flow.
- Idempotency — обязательное условие для любой cross-service интеграции.
- Outbox + Inbox — как опубликовать событие атомарно с write в БД.
- Distributed transactions — что НЕ делать — почему 2PC/JTA/XA не вариант.
- Архитектурный выбор: монолит или микросервисы — критерии разделения на сервисы.
- CQRS → когда оправдан — аналогичная логика «применяем по метрикам, не по моде».