Опирается на правила:
R-DIST-WHEN-1…R-DIST-WHEN-3иR-DIST-WHEN-X1…R-DIST-WHEN-X2из Distributed Patterns Style Guide → раздел 1. Когда нужны распределённые паттерны.
Важно знать
- Распределённые паттерны нужны, когда бизнес-операция охватывает 2+ сервиса и нельзя завершить её одной локальной транзакцией.
- В пределах одного сервиса с одним PostgreSQL — обычная
DataSource.transactionи атомарность БД, никаких саг и outbox.- Перед введением distributed-сложности проверь три альтернативы: объединение в один BC, modular monolith, eventual consistency без саги.
- В Node-экосистеме
@nestjs/cqrsSagas — не распределённые саги: state in-memory, событие живёт в одном процессе — не использовать для durable-оркестрации.- Для durable-оркестрации со стейт-машиной — Temporal (
@temporalio/*), если в стеке; иначе — отдельный@Injectable-orchestrator с state в БД.- Микросервисы из амбиций — без бизнес-требования — главный источник лишней сложности на проекте.
- Если два сервиса всегда меняются вместе — это один Bounded Context, разделён ошибочно.
- XA/2PC в Node-экосистеме не поддерживается драйверами; городить координатор вручную — запрещено.
Распределённые паттерны — saga, outbox, idempotent consumer, eventual consistency — инструменты для cross-service сценариев. У каждого есть цена: дополнительный код, дополнительные таблицы, новые failure modes. Применяем, когда бизнес действительно требует разнесения по сервисам, не «потому что так принято».
Три условия, при которых нужны распределённые паттерны
R-DIST-WHEN-1: распределённые паттерны нужны, когда выполняется одно из следующих условий:
- Операция охватывает 2+ сервиса. «Создать заказ» =
order-service+payment-service+inventory-service. Каждый шаг — отдельнаяDataSource.transactionв своём PostgreSQL; объединить в одну невозможно. - Операция охватывает 2+ датасорсов. Перевод между счетами в разных системах — у каждой свой PG, объединить нельзя по природе.
- Операция требует cross-service побочного эффекта. Изменение статуса заказа → уведомление
customer-service. Если notification-сервис недоступен, заказ всё равно должен обновиться — нужны outbox + retry.
// Три сервиса, три PG. Одной DataSource.transaction, объединяющей все три, не существует.
@Injectable()
export class CreateOrderSagaOrchestrator {
constructor(private readonly dataSource: DataSource) {}
async start(command: CreateOrderCommand): Promise<void> {
const sagaId = uuidv7();
await this.dataSource.transaction(async (m) => {
await m.insert(OrderSagaEntity, {
sagaId,
status: 'ORDER_CREATED',
currentStep: 1,
payload: command,
});
await m.insert(OutboxEventEntity, toOutbox(new RequestPaymentCommand(sagaId, command.orderId, command.amount)));
});
}
}
Если ни одно из трёх условий не выполняется — distributed-паттерны не нужны.
Когда распределённые паттерны НЕ нужны
R-DIST-WHEN-2: если операция в одном сервисе и одном PostgreSQL — используй обычную DataSource.transaction и атомарность БД.
@Injectable()
export class ConfirmOrderHandler implements ICommandHandler<ConfirmOrderCommand> {
constructor(
private readonly dataSource: DataSource,
private readonly orderRepository: OrderRepository,
) {}
async execute(command: ConfirmOrderCommand): Promise<Order> {
return this.dataSource.transaction(async (m) => {
const order = await this.orderRepository.findForUpdate(m, command.orderId);
if (!order) throw new OrderNotFoundException(command.orderId);
order.confirm();
return this.orderRepository.save(m, order);
});
}
}
Сага была бы карго-культом: одна локальная транзакция в одном PG атомарна, никакая compensation не нужна. Если потом понадобится опубликовать OrderConfirmed в Kafka — outbox добавится именно для публикации, не для атомарности внутри сервиса.
Три альтернативы перед введением distributed-сложности
R-DIST-WHEN-3: прежде чем вводить сагу, outbox и идемпотентность, проверь три альтернативы.
1. Объединение сервисов
Если два сервиса всегда меняются вместе, всегда деплоятся вместе, всегда обсуждаются вместе — это один Bounded Context, разделение было ошибкой. Пример: customer-service хранит профиль, customer-preferences-service хранит настройки уведомлений. Если ни один use case не работает с одним без другого — объединить, никаких саг не нужно.
2. Modular monolith
Несколько Bounded Context в одном Nest-процессе и одной БД, логически разделённых через Nest-модули и отдельные PG-схемы. Локальная DataSource.transaction работает через границы модулей — distributed-сложность не нужна. Это дефолтный выбор для нового продукта с небольшой командой.
order-service (modular monolith, один NestJS app):
├── OrdersModule (BC Order, схема orders)
├── PaymentsModule (BC Payment, схема payments в том же PG)
└── InventoryModule (BC Inventory, схема inventory в том же PG)
ConfirmOrderHandler → dataSource.transaction(async (m) => {
ordersRepo.update(m, ...); // обычный вызов, та же транзакция
paymentsService.charge(m, ...); // обычный вызов, та же транзакция
inventoryService.reserve(m, ...);
})
Когда команды вырастают и BC начинают деплоиться независимо — выделяются в отдельные процессы, тогда появляются саги.
3. Eventual consistency без саги
Если нет необходимости в компенсациях — сага не нужна. Достаточно: write в исходный сервис → outbox → событие → обновление read-side. Пример: OrderConfirmed → sber-notify-service отправляет push-уведомление клиенту. Если push не отправится — заказ всё равно подтверждён, никакой compensation не нужно; проблема решается retry на стороне consumer.
| Сценарий | Нужна сага? |
|---|---|
| Создать заказ + списать оплату + зарезервировать товар | Да — нужна compensation при сбое |
| Изменить статус заказа + отправить уведомление | Нет — события + retry |
| Создать профиль Customer + настройки + адрес в одной БД | Нет — DataSource.transaction |
| Возврат средств между счетами разных сервисов | Да — нужна compensation |
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
| Distributed-паттерны для операций в одной БД | R-DIST-WHEN-X1 | DataSource.transaction |
| Микросервисы без бизнес-требования | R-DIST-WHEN-X2 | modular monolith |
@nestjs/cqrs Sagas как durable-оркестрация | R-DIST-SAGA-X3 | отдельный @Injectable со state в saga_<name> таблице |
XA/2PC-координатор поверх нескольких DataSource | R-DIST-TX-X1 | saga с локальными транзакциями |
| Outbox для in-process событий внутри одного NestJS-модуля | R-DIST-WHEN-X1 | прямой вызов в той же DataSource.transaction |
| Разделение тесно связанных сервисов | R-DIST-WHEN-3 | объединение в один BC |
Куда дальше
- Saga — оркестрация vs хореография — главный паттерн для cross-service flow в NestJS.
- Idempotency — обязательное условие для любой cross-service интеграции.
- Outbox + Inbox — как опубликовать событие атомарно с write в БД через TypeORM.
- Eventual consistency — декларация задержки в OpenAPI и bounded staleness SLO.
- Compensation — semantic state-change вместо отката, audit trail, DLQ.
- Distributed transactions — что НЕ делать — почему 2PC и multi-DataSource-commit-цепочки не вариант.