Опирается на правила: R-DIST-WHEN-1R-DIST-WHEN-3 и R-DIST-WHEN-X1R-DIST-WHEN-X2 из Distributed Patterns Style Guide → раздел 1. Когда нужны распределённые паттерны.

Важно знать

  • Распределённые паттерны нужны, когда бизнес-операция охватывает 2+ сервиса и нельзя завершить её одной локальной транзакцией.
  • В пределах одного сервиса с одним PostgreSQL — обычная DataSource.transaction и атомарность БД, никаких саг и outbox.
  • Перед введением distributed-сложности проверь три альтернативы: объединение в один BC, modular monolith, eventual consistency без саги.
  • В Node-экосистеме @nestjs/cqrs Sagas — не распределённые саги: 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: распределённые паттерны нужны, когда выполняется одно из следующих условий:

  1. Операция охватывает 2+ сервиса. «Создать заказ» = order-service + payment-service + inventory-service. Каждый шаг — отдельная DataSource.transaction в своём PostgreSQL; объединить в одну невозможно.
  2. Операция охватывает 2+ датасорсов. Перевод между счетами в разных системах — у каждой свой PG, объединить нельзя по природе.
  3. Операция требует 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. Пример: OrderConfirmedsber-notify-service отправляет push-уведомление клиенту. Если push не отправится — заказ всё равно подтверждён, никакой compensation не нужно; проблема решается retry на стороне consumer.

СценарийНужна сага?
Создать заказ + списать оплату + зарезервировать товарДа — нужна compensation при сбое
Изменить статус заказа + отправить уведомлениеНет — события + retry
Создать профиль Customer + настройки + адрес в одной БДНет — DataSource.transaction
Возврат средств между счетами разных сервисовДа — нужна compensation

Что запрещено

АнтипаттернПравилоЧто взамен
Distributed-паттерны для операций в одной БДR-DIST-WHEN-X1DataSource.transaction
Микросервисы без бизнес-требованияR-DIST-WHEN-X2modular monolith
@nestjs/cqrs Sagas как durable-оркестрацияR-DIST-SAGA-X3отдельный @Injectable со state в saga_<name> таблице
XA/2PC-координатор поверх нескольких DataSourceR-DIST-TX-X1saga с локальными транзакциями
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-цепочки не вариант.