Границы сервисов, outbox, идемпотентность, события и Saga
Статус: материал архитектурный и актуален для Django 5.2/6.0. Конкретный broker, gateway, orchestrator и observability stack выбираются отдельно и меняются быстрее framework.
Микросервис — не маленький Django app и не контейнер. Это бизнес-возможность, которую команда может изменять, deploy и эксплуатировать относительно независимо. Сервис владеет своим контрактом и данными, имеет понятный SLO и не требует синхронного общего release всей системы.
Разделение покупает автономию ценой сети, eventual consistency, множества pipelines, on-call и сложной диагностики. Если нет команд и процессов, способных нести эту цену, modular monolith обычно надёжнее.
Начать с монолита — не временный провал. В одном Django deployment доступны:
Правильно разделённые business apps, public services и ownership позволяют извлечь сервис позже. «Распределённый монолит» возникает, когда processes уже разделены, но schema, release и call chain остаются настолько связанными, что один сервис нельзя изменить отдельно.
Синхронный вызов сам по себе не доказывает плохую границу. Проблема появляется, когда почти каждый request строит длинную обязательную цепь, contracts меняются lockstep, а сбой одного компонента останавливает всё.
Сильные сигналы:
Слабые причины:
Перед extraction измерьте coupling и составьте runbook. Самым первым часто выбирают capability с небольшим числом зависимостей и понятным asynchronous contract, а не центральный checkout или authentication.
Sales owns orders and commercial terms
Billing owns invoices, payment attempts and refunds
Fulfillment owns reservations and shipments
Messaging owns templates and delivery attemptsContext владеет правилами и public contract. Другой сервис не меняет его таблицы напрямую.
«Database per service» означает data ownership, а не обязательно отдельный физический PostgreSQL server. На старте сервисы могут иметь разные schemas/databases/roles на одном managed cluster, если:
Физическое разделение усиливают по требованиям isolation, scale и compliance. Общая таблица users, которую обновляют пять services, отменяет независимость независимо от числа контейнеров.
Django подходит сервису с relational domain, admin, ORM и HTTP API. Это не означает «один Django project на каждую таблицу». Сервис должен быть достаточно крупным, чтобы оправдать отдельный deployment.
Не все компоненты обязаны использовать Django. Streaming processor или edge proxy может иметь другой runtime. Platform standards важнее одинакового framework любой ценой:
Общий Python package с DTO/helpers допустим, но если он содержит ORM models и требует одновременного обновления всех services, он создаёт lockstep coupling. Contract schema должна иметь совместимую эволюцию, а не одну общую ветку исходников.
HTTP/gRPC используют, когда caller не может продолжить без немедленного ответа: проверить quotation, получить policy decision, создать payment authorization.
import httpx
class BillingClient:
def __init__(self, *, base_url, token, timeout=2.0):
self.base_url = base_url
self.token = token
self.timeout = timeout
def authorize(self, *, order_id, amount_minor, idempotency_key):
response = httpx.post(
f"{self.base_url}/v1/authorizations",
headers={
"Authorization": f"Bearer {self.token}",
"Idempotency-Key": idempotency_key,
},
json={
"order_id": str(order_id),
"amount_minor": amount_minor,
},
timeout=self.timeout,
)
response.raise_for_status()
return response.json()Production client также использует connection pooling, trace propagation и ограниченные retries. Timeout должен укладываться в общий request budget. Если gateway даёт 3 секунды, три последовательных upstream по 2 секунды не образуют рабочий план.
Retry безопасен только для временной ошибки и идемпотентной операции. Timeout не сообщает, выполнил ли server side effect, поэтому idempotency key хранится получателем с unique constraint и прежним результатом.
Не ставьте retry на каждом proxy/library/service без бюджета: один client request превратится в геометрическое число вызовов.
Event сообщает о свершившемся факте и не требует немедленного ответа publisher:
{
"event_id": "01J...",
"event_type": "sales.order_placed.v1",
"occurred_at": "2026-07-19T09:15:00Z",
"order_id": "8e1c...",
"customer_id": "5d9a...",
"total_minor": 150000,
"currency": "RUB"
}Событие содержит уникальный ID, тип/version, время и минимальный стабильный payload. Не публикуйте сериализованный ORM instance: consumer привяжется к внутренней schema и PII.
Команда в broker («зарезервируй товар») тоже допустима, но у неё один логический получатель и контракт результата/ошибки. Не называйте любую очередь event-driven architecture.
Асинхронность подходит уведомлениям, projections и workflow с промежуточным state. Она плохо подходит проверке, без которой текущий HTTP-ответ не имеет смысла.
Классическая ошибка:
commit Order -> процесс падает -> OrderPlaced не опубликованOutbox row записывается одной transaction с заказом. Dispatcher с retry публикует непереданные rows. Это даёт at-least-once, поэтому consumer хранит обработанный event_id/business idempotency key с unique constraint — inbox/deduplication.
Важные детали:
order_id;Broker не создаёт exactly-once business side effect. Consumer проектируют идемпотентным так же, как Celery task.
Без cross-service JOIN есть несколько вариантов:
Локальная копия customer_display_name является осознанной projection и может отставать. Она не даёт Billing право менять профиль. Определите источник истины, допустимый lag и способ rebuild/reconciliation.
Запрос списка, который делает N HTTP-вызовов к User Service, — распределённый N+1. Нужны batch API, projection или изменение contract.
Saga координирует локальные transactions:
Order pending
-> payment authorized
-> stock reserved
-> order confirmedПри отказе выполняется compensation, например release authorization. Компенсация не является магическим rollback:
Saga — явная state machine с timeouts, idempotency, retry и manual reconciliation. Choreography проще для нескольких независимых реакций, но длинный workflow трудно увидеть. Orchestrator делает последовательность явной, но становится важным компонентом. Выбор зависит от процесса.
Не называйте любое компенсирующее действие transaction: ACID между services обычно отсутствует.
Для каждого dependency задайте:
Circuit breaker быстро прекращает вызовы к явно нездоровой dependency и периодически пробует восстановление. Он не исправляет invalid request, не должен скрывать деньги/permissions за «успешным fallback» и требует общей/локальной семантики в зависимости от library/mesh.
Bulkhead часто важнее: отдельный connection pool/queue не позволяет медленному provider исчерпать все web threads. Rate limit защищает dependency, load shedding сохраняет критичные endpoints при saturation.
Gateway может завершать TLS, маршрутизировать, ограничивать request и проверять базовую identity. Но сервис всё равно проверяет:
«Gateway уже проверил login» не означает, что Billing может пропустить object permissions. Gateway не должен превращаться в единственное место всей бизнес-логики.
BFF адаптирует API под web/mobile и агрегирует ответы с ограниченным fan-out. Он не получает права обходить contracts и читать databases services.
Различайте end-user identity и service identity. Внутренний вызов должен отвечать:
Нельзя принимать X-User-Id от публичного клиента как доказательство identity. Gateway/proxy должен удалить spoofed headers, а services — доверять только аутентифицированной внутренней цепочке или проверяемому token.
Центральный identity provider часто лучше самодельного «User microservice», который синхронно вызывается каждым request. Profile data и authentication credentials могут иметь разные boundaries.
OpenAPI/JSON Schema, Protobuf или AsyncAPI фиксируют contract. Правила эволюции:
Consumer-driven contract tests ловят несовместимость до deployment, но не заменяют integration test с auth, timeouts и serialization. Schema registry полезен событиям, если правила compatibility реально включены.
Не обещайте синхронный coordinated release как постоянную стратегию — это признак потерянной независимости.
В Kubernetes Service DNS уже является service discovery. В managed platform эту роль выполняет platform registry/DNS. Отдельный Consul нужен не автоматически.
Микросервис не требует Kubernetes или даже containers: PaaS/serverless/VM подходят при нужных SLO. Оркестратор полезен, когда команда готова управлять:
Один cluster не делает services независимыми, а отдельный pipeline не гарантирует совместимый contract.
Каждый request/event несёт стандартный trace context. Система собирает:
Correlation ID не «единственный способ» диагностики и не заменяет trace propagation. Metric labels не должны содержать request/event IDs.
Для инцидента нужен service catalog: owner, SLO, dependencies, dashboards и runbook. Без ownership десять services означают десять бесхозных процессов.
Безопасный strangler-путь:
Dual write в старую и новую базы без outbox/repair почти неизбежно расходится. Выберите один source of truth на каждом этапе и измеряйте расхождения.
Если половина пунктов отсутствует, сначала улучшите modular monolith/platform. Сетевое разделение раньше организационной автономии обычно увеличивает lead time.
Далее: GraphQL со Strawberry