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

Важно знать

  • Распределённые паттерны нужны, когда бизнес-операция охватывает 2+ сервиса и нельзя завершить её одной pgx.Tx.
  • В пределах одного сервиса с одним PostgreSQL — явная pgx.Tx через pgxpool.Pool и атомарность БД, никаких саг и outbox.
  • В Go нет транзакционного менеджера в стиле Spring: транзакция — явный pgx.Tx, пробрасываемый через context.Context или параметр.
  • Перед введением distributed-сложности проверь три альтернативы: объединение в один BC, modular monolith с одним pgxpool.Pool, eventual consistency без саги.
  • Распределение всегда дорогое: больше latency, сложнее debugging, нужен distributed tracing, появляются новые failure modes.
  • Микросервисы из амбиций — без бизнес-требования — главный источник лишней сложности на Go-проекте.
  • Если два сервиса всегда меняются вместе — это один Bounded Context, разделение было ошибкой.

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

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

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

Операция охватывает 2+ сервиса. «Создать заказ» = order-service + payment-service + inventory-service. Каждый шаг — отдельная локальная транзакция в своём PostgreSQL; объединить в одну pgx.Tx невозможно физически.

Операция охватывает 2+ датасорса. Перевод между счетами в разных банках — у каждого свой pgxpool.Pool, pgx.Tx из одного пула не охватывает другой.

Операция требует cross-service побочного эффекта. Изменение статуса заказа → нотификация. Если notification-service лежит, заказ всё равно должен обновиться — нужны outbox + retry, иначе сцепка ломается.

// internal/saga/order_saga.go
// Три сервиса, три pgx.Pool. Одной pgx.Tx, охватывающей все три, не существует.

func (o *OrderSagaOrchestrator) Start(ctx context.Context, tx pgx.Tx, cmd StartOrderSagaCommand) (uuid.UUID, error) {
    sagaID := uuid.New()
    qtx := o.queries.WithTx(tx)
    if err := qtx.InsertOrderSaga(ctx, db.InsertOrderSagaParams{
        SagaID:      sagaID,
        OrderID:     cmd.OrderID,
        Status:      string(SagaPaymentPending),
        CurrentStep: "reserve_payment",
        Payload:     cmd.Payload,
    }); err != nil {
        return uuid.Nil, fmt.Errorf("insert order saga: %w", err)
    }
    return sagaID, o.outbox.Write(ctx, tx, OutboxMessage{
        SagaID:  sagaID,
        Topic:   "payment.commands",
        Payload: ReservePaymentCommand{SagaID: sagaID, OrderID: cmd.OrderID, Amount: cmd.Amount},
    })
}

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

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

R-DIST-WHEN-2: если операция в одном сервисе и одном PostgreSQL — явная pgx.Tx через pgxpool.Pool и атомарность БД.

// core/order/confirm_order_handler.go

type ConfirmOrderHandler struct {
    pool    *pgxpool.Pool
    queries *db.Queries
}

func (h *ConfirmOrderHandler) Handle(ctx context.Context, cmd ConfirmOrderCommand) (*db.Order, error) {
    tx, err := h.pool.Begin(ctx)
    if err != nil {
        return nil, fmt.Errorf("begin tx: %w", err)
    }
    defer tx.Rollback(ctx)

    qtx := h.queries.WithTx(tx)
    order, err := qtx.GetOrderForUpdate(ctx, cmd.OrderID)
    if err != nil {
        if errors.Is(err, pgx.ErrNoRows) {
            return nil, &OrderNotFoundError{ID: cmd.OrderID}
        }
        return nil, fmt.Errorf("get order for update: %w", err)
    }

    updated, err := qtx.UpdateOrderStatus(ctx, db.UpdateOrderStatusParams{
        OrderID: cmd.OrderID,
        Status:  "confirmed",
    })
    if err != nil {
        return nil, fmt.Errorf("update order status: %w", err)
    }

    if err := tx.Commit(ctx); err != nil {
        return nil, fmt.Errorf("commit: %w", err)
    }
    return &updated, nil
}

Здесь сага была бы карго-культом: одна pgx.Tx атомарна, никакая compensation не нужна, никакой outbox для синхронизации внутри одного сервиса не нужен. Если потом понадобится опубликовать OrderConfirmed в Kafka — outbox добавится именно для публикации, не для атомарности внутри сервиса.

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

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

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

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

2. Modular monolith

Несколько Bounded Context в одном Go-бинаре и одном PostgreSQL, логически разделённых: разные пакеты, разные sqlc-схемы, явные интерфейсы между модулями. Одна pgxpool.Pool работает через границы модулей, distributed-сложность не нужна.

order-service (modular monolith):
  ├── core/orders/      (BC Order)
  ├── core/payments/    (BC Payment, схема payments в том же PG)
  └── core/inventory/   (BC Inventory, схема inventory в том же PG)

// ConfirmOrderHandler использует одну pgx.Tx
tx, _ := pool.Begin(ctx)
defer tx.Rollback(ctx)
qtx := queries.WithTx(tx)

qtx.UpdateOrderStatus(ctx, ...)       // orders-пакет
qtx.ChargePayment(ctx, ...)           // payments-пакет, та же транзакция
qtx.ReserveInventoryItems(ctx, ...)   // inventory-пакет, та же транзакция
tx.Commit(ctx)

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

3. Eventual consistency без саги

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

// adapters/in/kafka/consumer.go — notification-service просто применяет событие
func (c *NotificationConsumer) handleOrderConfirmed(ctx context.Context, tx pgx.Tx, event OrderConfirmedEvent) error {
    qtx := c.queries.WithTx(tx)
    return qtx.InsertNotificationTask(ctx, db.InsertNotificationTaskParams{
        CustomerID: event.CustomerID,
        Type:       "order_confirmed",
        OrderID:    event.OrderID,
    })
}
СценарийНужна сага?
Создать заказ + списать оплату + зарезервировать товарДа — нужна compensation при сбое
Изменить статус заказа + отправить уведомлениеНет — события + retry
Создать пользователя + профиль + настройки в одной БДНет — одна pgx.Tx
Перевод между счетами разных банковДа — нужна compensation
Обновить Product + отправить событие ProductUpdatedНет — outbox в той же транзакции

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

Распределённые паттерны для одного сервиса

R-DIST-WHEN-X1: saga для двух операций в одной БД — self-orchestrated сложность. Если все шаги — INSERT/UPDATE в одном PostgreSQL, явная pgx.Tx атомарна и никакая compensation не нужна.

// ПЛОХО — «сага» для двух INSERT в одной БД
func (h *CreateOrderHandler) Handle(ctx context.Context, cmd CreateOrderCommand) error {
    if err := h.queries.InsertOrder(ctx, ...); err != nil {
        return err
    }
    if err := h.queries.InsertOrderItems(ctx, ...); err != nil {
        h.queries.DeleteOrder(ctx, cmd.OrderID) // ручной «rollback» — лишний
        return err
    }
    return nil
}

// ПРАВИЛЬНО — одна pgx.Tx откатит обе вставки при ошибке
func (h *CreateOrderHandler) Handle(ctx context.Context, cmd CreateOrderCommand) error {
    tx, err := h.pool.Begin(ctx)
    if err != nil {
        return fmt.Errorf("begin tx: %w", err)
    }
    defer tx.Rollback(ctx)

    qtx := h.queries.WithTx(tx)
    if err := qtx.InsertOrder(ctx, ...); err != nil {
        return fmt.Errorf("insert order: %w", err)
    }
    if err := qtx.InsertOrderItems(ctx, ...); err != nil {
        return fmt.Errorf("insert order items: %w", err)
    }
    return tx.Commit(ctx)
}

Микросервисы из амбиций

R-DIST-WHEN-X2: распределение всегда дорогое:

  • Latency увеличивается на каждый сетевой hop: 1ms pgx-запрос превращается в 20–50ms HTTP-вызов через chi-роутер соседнего сервиса.
  • Debugging сложнее: нужен distributed tracing, slog с trace_id/span_id, OpenTelemetry.
  • Failure modes появляются новые: timeout, partial failure, retry storm, network partition.
  • Транзакционность теряется: невозможно атомарно обновить два сервиса через одну pgx.Tx, нужны saga + compensation.

Если бизнес не требует разделения — лучше modular monolith. «Мы хотим микросервисы потому что так делают все» — не аргумент. Аргумент — «команды по 6–8 человек на каждый сервис, разный deploy cadence, разные SLA».

Что запрещено — таблица

АнтипаттернПравилоЧто взамен
Saga для операций в одном PostgreSQLR-DIST-WHEN-X1явная pgx.Tx
Ручной «rollback» через DELETE вместо tx.RollbackR-DIST-WHEN-X1defer tx.Rollback(ctx)
Микросервисы без бизнес-требованияR-DIST-WHEN-X2modular monolith с одним pgxpool.Pool
Outbox для событий внутри одного сервисаR-DIST-WHEN-X1прямой вызов в той же pgx.Tx
Разделение тесно связанных BC без независимого деплояR-DIST-WHEN-3объединение в один BC

Куда дальше

  • Saga — оркестрация vs хореография — главный паттерн для cross-service flow в Go.
  • Idempotency — обязательное условие для любой cross-service интеграции.
  • Outbox + Inbox — как опубликовать событие атомарно с write в БД через outbox-writer.
  • Компенсационные транзакции — semantic compensation через отдельную команду, не DELETE.
  • Eventual consistency — bounded staleness, read-your-writes, causal consistency на Go.
  • Distributed transactions — что НЕ делать — почему последовательный commit по двум pgx.Tx не работает.