Опирается на правила:
R-DIST-WHEN-1…R-DIST-WHEN-3иR-DIST-WHEN-X1…R-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 — распределённые паттерны нужны, когда выполняется одно из следующих условий:
- Операция охватывает 2+ сервиса. «Создать заказ» =
order-service+payment-service+inventory-service. Каждый шаг — отдельнаяAsyncSessionв своём PostgreSQL; объединить в одну транзакцию невозможно. - Операция охватывает 2+ датасорса. Перевод между счетами в разных банках — у каждого банка свой PG, объединить нельзя по природе.
- Операция требует 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 обновление. Пример: OrderConfirmed → notification-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-X3 | saga с compensation или modular monolith |
| Микросервисы без бизнес-требования | R-DIST-WHEN-X2 | modular 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-стеке.