Кейс: Миграция монолита в микросервисы для fintech-платформы
Один из наших клиентов — финтех-платформа в СНГ с ~200K активных пользователей и командой из 15 инженеров — обратился к нам с классической проблемой роста: система перестала масштабироваться вместе с бизнесом. Не технически — технически она справлялась. Масштабирование сломалось организационно: четыре команды работали в одном репозитории и постоянно блокировали друг друга.
Ниже — честный разбор того, что мы нашли, что сделали и что получилось. Без приукрашивания и без умолчания о том, где было сложно.
// Контекст: что было
Система — монолитное Django-приложение, ~450K строк кода, которому на момент начала работы было около 6 лет. За эти годы в него складывали всё: платежи, KYC-верификацию, уведомления, аналитику, административные инструменты, интеграции с внешними провайдерами. Единая база данных PostgreSQL — 800 таблиц, часть из которых потеряла актуальную документацию ещё несколько лет назад.
Деплой происходил раз в две недели и каждый раз превращался в событие. Не потому что процесс был плохой — CI/CD был настроен, тесты писались. Проблема была в том, что любой деплой касался всего: одна ошибка в миграции платёжного модуля могла заблокировать выкатку независимой фичи в KYC-процессах. Время от коммита до прода — 14 дней. Для финтеха, который конкурирует с банковскими суперапами, это было критично.
Проблема была не в монолите. Проблема была в том, что монолит не имел никаких границ внутри себя — это был Big Ball of Mud с хорошим CI/CD.
Команда выросла с 8 до 15 инженеров за год — и вместо ускорения разработки получили замедление. Классический симптом: чем больше людей работает в одном пространстве без явных границ, тем выше координационные издержки.
// Диагностика: что мы нашли
Первые две недели мы провели в диагностике — не предлагая решений. Это принципиальный момент: нельзя проектировать границы системы, не понимая, как реально работает бизнес-домен.
Мы провели серию event storming-сессий с командой. Результат: три чётко выраженных bounded context с минимальными пересечениями по данным и бизнес-логике.
- Payments — всё, что связано с движением денег: транзакции, балансы, провайдеры, reconciliation.
- KYC — верификация пользователей, документы, статусы, интеграции с госреестрами.
- Notifications — доставка сообщений: push, SMS, email, in-app; шаблоны, предпочтения, статусы доставки.
Четвёртый контекст — User Management — решили оставить в монолите на первом этапе: он был относительно стабилен, и выигрыш от его выделения не оправдывал риск.
Анализ зависимостей показал ожидаемую картину: 60% межмодульных вызовов шли через прямые импорты и прямые SQL-запросы к чужим таблицам. Это означало, что «просто вырезать» сервис невозможно — нужно сначала разорвать зависимости внутри монолита, а уже потом выносить физически.
// Решение: стратегическая декомпозиция
Мы сознательно отказались от подхода «большого взрыва» — полного переписывания с нуля. Для работающего финтеха с реальными пользователями это неприемлемо: слишком высокий риск, слишком длинный период, когда вы сидите с двумя системами без гарантий успеха.
Выбрали strangler fig pattern — постепенное замещение. Идея: новый сервис разворачивается рядом со старым монолитом, трафик перенаправляется на него через API gateway, старый код постепенно удаляется. В какой-то момент «фига» поглощает дерево.
Для коммуникации между сервисами — event-driven architecture через Apache Kafka. Выбор был неочевидным: команда предлагала REST. Мы настояли на событийной модели по нескольким причинам: финансовые операции требуют audit trail по природе, асинхронность критична для notification-сервиса, и при event sourcing проще реализовать eventual consistency без распределённых транзакций.
Принцип shared nothing — каждый сервис владеет своей базой данных. Единственная точка соприкосновения — event bus. Это фундаментальное требование для независимых деплоев.
// Реализация: как переносили данные
Самая сложная часть — не архитектура и не код новых сервисов. Самая сложная часть — миграция данных без даунтайма для финансовой системы.
Мы использовали dual-write с верификацией. Схема: на переходный период все записи идут одновременно в старую и новую системы. Читать продолжают из старой. Параллельно фоновый процесс верифицирует консистентность — сравнивает записи между источниками. Как только расхождения падают до нуля и держатся стабильно — переключаем чтение на новый сервис, затем отключаем запись в старый.
Это схема занимала от 3 до 6 недель для каждого домена. Payments — самый сложный и самый долгий. KYC прошёл быстрее: данные более статичны.
# Пример схемы события в Kafka (Apache Avro)
# payment_events.avsc
{
"namespace": "fin.payments.v1",
"type": "record",
"name": "PaymentCompleted",
"fields": [
{"name": "event_id", "type": "string", "doc": "UUID v4"},
{"name": "payment_id", "type": "string"},
{"name": "user_id", "type": "string"},
{"name": "amount", "type": "long", "doc": "в тиынах / копейках"},
{"name": "currency", "type": "string", "default": "KZT"},
{"name": "provider", "type": "string"},
{"name": "status", "type": {
"type": "enum",
"name": "PaymentStatus",
"symbols": ["COMPLETED", "FAILED", "REVERSED"]
}},
{"name": "occurred_at", "type": "long", "doc": "unix timestamp ms"},
{"name": "idempotency_key", "type": ["null", "string"], "default": null}
]
}
Версионирование схем через Confluent Schema Registry стало обязательным с первого дня. При финансовых операциях нет права на несовместимые изменения без миграции потребителей.
# Strangler fig: routing layer в API Gateway (Python/FastAPI)
# Постепенное переключение трафика по feature flag
from app.config import settings
from app.clients import legacy_client, payments_client
async def route_payment_request(payload: PaymentRequest) -> PaymentResponse:
"""
Переключение трафика с монолита на payments-service.
Управляется через feature flag в конфиге (0.0 → 1.0).
"""
rollout = settings.PAYMENTS_SERVICE_ROLLOUT # 0.0 → 1.0
if rollout >= 1.0 or (rollout > 0 and _should_route_to_new(payload.user_id, rollout)):
try:
return await payments_client.process(payload)
except PaymentsServiceError:
# Circuit breaker: fallback на монолит при ошибках нового сервиса
if settings.PAYMENTS_FALLBACK_ENABLED:
return await legacy_client.process_payment(payload)
raise
else:
return await legacy_client.process_payment(payload)
def _should_route_to_new(user_id: str, rollout: float) -> bool:
"""Детерминированное распределение по user_id (не случайное — для консистентности)."""
import hashlib
bucket = int(hashlib.md5(user_id.encode()).hexdigest()[:8], 16) % 100
return bucket < (rollout * 100)
Feature flag с детерминированным распределением по user_id — критичная деталь. Случайный роутинг создаёт ситуацию, когда один пользователь в рамках одной сессии попадает то в старую, то в новую систему. Для финансовых данных это неприемлемо.
// Архитектура: что получилось
Итоговая архитектура к концу проекта (5 месяцев активной работы + 2 месяца стабилизации):
- API Gateway — FastAPI, единая точка входа, аутентификация, rate limiting, routing.
- payments-service — Go, PostgreSQL (отдельная БД), Kafka producer/consumer. Go выбрали за предсказуемую latency для финансовых операций.
- kyc-service — Python/FastAPI, PostgreSQL, S3 для документов. Python — из-за богатой экосистемы ML-библиотек для верификации документов.
- notifications-service — Python/FastAPI, Redis для очередей доставки, PostgreSQL для истории. Kafka consumer для реакции на события других доменов.
- Монолит (Django) — продолжает работать для User Management и аналитических функций. Будет декомпозирован позже.
Каждый сервис — отдельный репозиторий, отдельный CI/CD pipeline, отдельное расписание релизов. Команды больше не согласовывают деплой-окна между собой.
// Результаты
Через восемь месяцев после начала проекта — цифры, которые мы можем зафиксировать:
- Цикл деплоя: 14 дней → 2 дня в среднем по всем командам. Payments-команда деплоит независимо 3–4 раза в неделю.
- Три команды деплоят параллельно без координации. Количество «блокирующих» задач упало практически до нуля.
- Инциденты при деплое: −73%. Основная причина: изолированные изменения имеют значительно меньший радиус взрыва. Ошибка в notifications не роняет payments.
- MTTR (среднее время устранения инцидента): −60%. Когда сервисов три вместо одного монолита, локализация проблемы занимает минуты, не часы.
- Команда выросла с 15 до 25 инженеров без роста координационного трения. Новые инженеры онбордятся в один сервис, а не в 450K строк.
Главный результат — не технический. Команда вернула контроль над скоростью разработки. Бизнес снова может планировать фичи на недели, не на кварталы.
// Что было сложнее всего
Честность требует назвать трудности. Их было три.
Distributed tracing. Отследить запрос, который прошёл через API Gateway, payments-service и вернулся через Kafka в notifications-service — нетривиально. Мы потратили три недели на настройку OpenTelemetry + Jaeger и до сих пор считаем, что сделали это поздно. Трейсинг нужно закладывать в архитектуру с первого дня.
Схемы событий. Первые три версии EventSchema были несовместимы между собой — мы недооценили, как быстро будут меняться требования. Schema Registry + строгий backward compatibility policy спасли ситуацию, но это потребовало времени на внедрение дисциплины в команде.
Тестирование интеграций. Юнит-тесты для изолированных сервисов — просто. Contract tests между сервисами через Pact — значительно сложнее организационно. Часть команды воспринимала это как overhead, пока не случился первый инцидент из-за несовместимого изменения API.
// Для кого этот подход подходит
Декомпозиция монолита — дорогое и длинное предприятие. Это не то, что нужно делать ради архитектурной красоты. Признаки того, что время пришло: деплой занимает больше недели, команды постоянно блокируют друг друга, время онбординга нового инженера превышает два месяца, масштабирование одного компонента требует масштабирования всего приложения.
Если вы узнаёте в этом описании свою ситуацию — мы готовы провести диагностику. Первый шаг всегда один: понять, где реальные границы вашего домена, прежде чем рисовать архитектурные схемы.