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

Важно знать

  • Распределённые паттерны нужны, когда бизнес-операция охватывает 2+ сервиса и нельзя завершить её одной локальной транзакцией.
  • В пределах одного сервиса с одним PostgreSQL — обычный Unit of Work (AsyncSession) и атомарность БД; saga и outbox не нужны.
  • Перед введением distributed-сложности проверь три альтернативы: объединение в один BC, modular monolith, eventual consistency без саги.
  • Распределение всегда дорогое: больше latency, сложнее debugging, нужно distributed tracing, появляются новые failure modes.
  • В Python нет аналога @Transactional через несколько AsyncSession — несколько открытых сессий к разным сервисам не дадут атомарности даже если commit'нуть последовательно.
  • Микросервисы из амбиций — без бизнес-требования — главный источник лишней сложности.
  • Если два сервиса всегда меняются вместе — это один Bounded Context, разделён ошибочно.

Saga, outbox, idempotent consumer, eventual consistency — инструменты для cross-service сценариев. Каждый несёт цену: дополнительные таблицы, дополнительный код, дополнительные failure modes. Применяем, когда бизнес действительно требует разнесения по сервисам.

Три условия, при которых нужны распределённые паттерны

R-DIST-WHEN-1 — распределённые паттерны нужны, когда выполняется одно из следующих условий:

  1. Операция охватывает 2+ сервиса. «Создать заказ» = order-service + payment-service + inventory-service. Каждый шаг — отдельная AsyncSession в своём PostgreSQL; объединить в одну транзакцию невозможно.
  2. Операция охватывает 2+ датасорса. Перевод между счетами в разных банках — у каждого банка свой PG, объединить нельзя по природе.
  3. Операция требует cross-service побочного эффекта. Изменение статуса заказа → нотификация. Если notification-service лежит, заказ всё равно должен обновиться — нужны outbox + retry, иначе сцепка ломается.
# order_service/application/saga/create_order_saga.py
import uuid
from dataclasses import dataclass

from order_service.application.ports.order_port import OrderPort
from order_service.application.ports.payment_port import PaymentPort
from order_service.application.ports.inventory_port import InventoryPort


@dataclass
class CreateOrderSaga:
    order_port: OrderPort
    payment_port: PaymentPort
    inventory_port: InventoryPort

    async def run(self, command: "CreateOrderCommand") -> "OrderId":
        saga_id = uuid.uuid4()
        order_id = await self.order_port.create(saga_id, command)
        payment_id = await self.payment_port.charge(saga_id, order_id, command.amount)
        try:
            await self.inventory_port.reserve(saga_id, order_id, command.items)
        except Exception:
            await self.payment_port.refund(saga_id, payment_id)
            await self.order_port.cancel(saga_id, order_id)
            raise
        return order_id

Три сервиса, три PostgreSQL, три AsyncSession. Никакой единой транзакции нет — нужна saga с compensation.

Если ни одно из трёх условий не выполняется — distributed-паттерны не нужны.

Когда распределённые паттерны НЕ нужны

R-DIST-WHEN-2 — если операция в одном сервисе и одном PostgreSQL — используй Unit of Work (AsyncSession) и атомарность БД.

# order_service/application/use_cases/confirm_order.py
from sqlalchemy.ext.asyncio import AsyncSession

from order_service.domain.model.order import OrderId
from order_service.application.repositories.order_repository import OrderRepository


class ConfirmOrderHandler:
    def __init__(self, session: AsyncSession) -> None:
        self._session = session
        self._repository = OrderRepository(session)

    async def handle(self, order_id: OrderId) -> None:
        order = await self._repository.find_by_id_for_update(order_id)
        order.confirm()
        await self._session.commit()

AsyncSession.commit() атомарен внутри одного PostgreSQL — saga была бы карго-культом. Если потом понадобится опубликовать OrderConfirmed в Kafka — outbox добавится именно для публикации, не для атомарности внутри одного сервиса.

В Python особенно важно не путать «несколько await» с «несколько транзакций». Цепочка await session.execute() внутри одной AsyncSession — одна транзакция. Две разные AsyncSession или два разных engine — уже нет атомарности, даже если оба commit() прошли успешно.

# ПЛОХО — иллюзия атомарности через последовательный commit двух сессий
async with order_session.begin():
    await order_repo.save(order)
# Здесь первый commit уже прошёл.
# Если следующий упадёт — order сохранён, payment нет.
async with payment_session.begin():
    await payment_repo.save(payment)

Это R-DIST-TX-X3 — best-effort, не атомарность. При сбое между commit'ами получаем inconsistency без recovery-плана.

Три альтернативы перед введением distributed-сложности

R-DIST-WHEN-3 — прежде чем вводить saga, outbox и idempotency, проверь три альтернативы.

1. Объединение сервисов

Если два сервиса всегда меняются вместе, всегда деплоятся вместе, всегда обсуждаются вместе — это один Bounded Context, разделение было ошибкой. Пример: customer-service хранит профиль, customer-preferences-service хранит настройки уведомлений. Если ни один use case не работает с одним без другого — объединить, никаких саг не нужно.

2. Modular monolith

Несколько Bounded Context в одном процессе и одной БД, но логически разделённых: разные схемы, разные пакеты, импорты только через публичные порты. Одна AsyncSession проходит через все слои — локальный commit() атомарен через границы модулей.

order_app/ (modular monolith)
  ├── orders/        (BC Order, схема orders)
  ├── payments/      (BC Payment, схема payments в том же PG)
  └── inventory/     (BC Inventory, схема inventory в том же PG)
# order_app/orders/application/use_cases/create_order.py
class CreateOrderHandler:
    def __init__(
        self,
        session: AsyncSession,
        order_repo: OrderRepository,
        payment_service: PaymentService,   # внутренний порт BC Payment
        inventory_service: InventoryService,  # внутренний порт BC Inventory
    ) -> None:
        ...

    async def handle(self, command: CreateOrderCommand) -> OrderId:
        order = Order.create(command)
        await self._order_repo.save(order)
        await self._payment_service.charge(order.id, command.amount)
        await self._inventory_service.reserve(order.id, command.items)
        await self._session.commit()   # одна транзакция, одна БД
        return order.id

Когда команда вырастает и BC начинают деплоиться независимо — отделяются в свои сервисы, тогда появляются саги.

3. Eventual consistency без саги

Если нет необходимости в rollback'ах — нет саги. Достаточно: write в исходный сервис → outbox → событие → read-side обновление. Пример: OrderConfirmednotification-service отправляет SMS. Если SMS не дойдёт — заказ всё равно подтверждён, никакой compensation не нужно, проблема решается retry на стороне consumer.

# order_service/application/use_cases/confirm_order.py
class ConfirmOrderHandler:
    async def handle(self, order_id: OrderId) -> None:
        order = await self._repository.find_by_id_for_update(order_id)
        order.confirm()

        # Сохранить событие в outbox — атомарно с изменением заказа
        event = OrderConfirmedEvent(order_id=order.id, customer_id=order.customer_id)
        self._outbox.append(event)

        await self._session.commit()
        # notification-service подберёт OrderConfirmed из Kafka с retry —
        # без всякой саги и compensation
СценарийНужна сага?
Создать заказ + списать оплату + зарезервировать товарДа — нужна compensation при сбое
Изменить статус заказа + отправить уведомлениеНет — события + retry
Создать Customer + профиль + настройки в одной БДНет — один commit()
Перевод между счетами Sber и стороннего банкаДа — нужна compensation

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

АнтипаттернПравилоЧто взамен
Saga для двух операций в одной БДR-DIST-WHEN-X1один AsyncSession.commit()
Несколько AsyncSession.commit() вместо сагиR-DIST-TX-X3saga с compensation или modular monolith
Микросервисы без бизнес-требованияR-DIST-WHEN-X2modular monolith
Outbox для in-process событийR-DIST-WHEN-X1прямой вызов в той же AsyncSession
Разделение тесно связанных сервисовR-DIST-WHEN-3объединение в один BC
Saga state только in-memory (dict/переменная)R-DIST-SAGA-X3таблица saga_<name> в БД

Куда дальше

  • Saga — оркестрация vs хореография — главный паттерн для cross-service flow на FastAPI.
  • Idempotency — processed_event, Idempotency-Key, двойная защита money-операций.
  • Outbox + Inbox — как опубликовать событие атомарно с write в PostgreSQL.
  • Eventual consistency — декларация задержки в FastAPI, bounded staleness SLO, causal consistency через version.
  • Compensation — semantic refund вместо откатов, audit trail, DLQ при сбое.
  • Distributed transactions — что НЕ делать — почему несколько AsyncSession не дают атомарности и нет 2PC в Python-стеке.