SyncFlow --- сервис для синхронизации товаров между маркетплейсами, который я начал строить, работая над Tirebase. Идея родилась из боли: в Tirebase менеджеры тратили часы на ручное обновление остатков между Ozon, Wildberries и собственным сайтом. Autoload Engine решил эту задачу для нас, но я подумал --- проблема-то типовая. Сейчас SyncFlow в активной разработке, и я хочу рассказать про архитектурные решения, которые принимаю на ранней стадии.
Выбор стека: скучные технологии побеждают
Первый соблазн — взять что-то «правильное». Go для микросервисов, Kafka для очередей, Kubernetes для оркестрации. Я остановился и задал себе вопрос: «Какой стек позволит мне одному запустить MVP за несколько недель?»
Ответ оказался скучным:
- Python + FastAPI --- знаю лучше всего, огромная экосистема для интеграций с API маркетплейсов
- PostgreSQL --- единственная база, без MongoDB, без Redis (добавлю позже, когда понадобится кеширование)
- Celery + Redis --- фоновые задачи синхронизации
- Vue 3 + Tailwind --- фронтенд, с которым я продуктивен
- Docker Compose + GitHub Actions + GHCR --- CI собирает образы, деплой через Docker Compose на VPS
Монореп на pnpm + Turborepo. Несколько сервисов (auth, gateway, web), но деплоятся вместе через Docker Compose. Масштабировать буду, когда будет что масштабировать.
MVP: режь безжалостно
Первоначальный список фич занимал 3 страницы. Я вычеркнул 80%:
Оставляю в MVP:
- Подключение магазинов Ozon и Wildberries (только эти два для старта)
- Синхронизация остатков (в одну сторону: из системы клиента на маркетплейсы)
- Простой дашборд со статусом синхронизации
- Регистрация и оплата
Вычёркиваю:
Синхронизация цен(добавлю после запуска)Аналитика продаж(потом)Интеграция с 1С(не на старте)Мобильное приложение(не нужно)Командная работа / роли(позже)API для сторонних интеграций(не нужно пока)
Правило: если фичу можно не делать для первых 5 клиентов — не делаю. Каждая вычеркнутая строка экономит 2-5 дней разработки.
Multi-tenancy: shared DB с tenant_id
Три подхода к multi-tenancy:
- Отдельная БД на клиента --- максимальная изоляция, кошмар в обслуживании
- Отдельная схема на клиента --- компромисс, но миграции на 100 схем --- боль
- Общая БД с tenant_id --- просто, эффективно, достаточно для старта
Выбрал третий вариант. Каждая таблица содержит tenant_id:
# models/base.py
class TenantMixin:
tenant_id: Mapped[int] = mapped_column(
ForeignKey("tenants.id"), index=True, nullable=False
)
class Product(Base, TenantMixin):
__tablename__ = "products"
id: Mapped[int] = mapped_column(primary_key=True)
sku: Mapped[str]
title: Mapped[str]
stock: Mapped[int]
__table_args__ = (
UniqueConstraint("tenant_id", "sku", name="uq_product_sku_tenant"),
Index("ix_product_tenant_sku", "tenant_id", "sku"),
)
Защита от утечки данных между тенантами — middleware, который автоматически фильтрует все запросы:
# middleware/tenant.py
from contextvars import ContextVar
from sqlalchemy import event
current_tenant: ContextVar[int] = ContextVar("current_tenant")
@app.middleware("http")
async def tenant_middleware(request: Request, call_next):
# tenant_id из JWT-токена
tenant_id = request.state.user.tenant_id
current_tenant.set(tenant_id)
return await call_next(request)
# Автоматический фильтр на все SELECT-запросы
@event.listens_for(Session, "do_orm_execute")
def _add_tenant_filter(execute_state):
if execute_state.is_select:
execute_state.statement = execute_state.statement.filter_by(
tenant_id=current_tenant.get()
)
Не уверен, что do_orm_execute event — лучший способ для автоматической фильтрации. Есть риск, что при сложных JOIN-ах фильтр не сработает правильно. Планирую написать интеграционные тесты с двумя тенантами, которые будут проверять изоляцию на каждом эндпоинте --- прежде чем выпускать кого-то на прод.
Биллинг: не пишите сам
Я начал писать собственную систему подписок, потратил два дня --- и понял, что это кроличья нора. Биллинг --- это рекуррентные платежи, возвраты, неуспешные списания, retry-логика, чеки по 54-ФЗ. Подключаю ЮKassa.
# billing/yookassa.py
from yookassa import Configuration, Payment
Configuration.account_id = settings.YOOKASSA_SHOP_ID
Configuration.secret_key = settings.YOOKASSA_SECRET
async def create_subscription(tenant: Tenant, plan: Plan):
payment = Payment.create({
"amount": {"value": str(plan.price), "currency": "RUB"},
"confirmation": {"type": "redirect", "return_url": f"{settings.APP_URL}/billing/success"},
"capture": True,
"save_payment_method": True, # для рекуррентных платежей
"description": f"Подписка {plan.name} — {tenant.company_name}",
"metadata": {"tenant_id": tenant.id, "plan_id": plan.id},
})
return payment.confirmation.confirmation_url
Webhook будет обрабатывать события: успешная оплата, отмена, возврат. Планирую начать с одного тарифа и разделить позже, когда пойму, что именно нужно разным клиентам.
Онбординг: первые 5 минут решают всё
Проектирую онбординг так, чтобы пользователь увидел ценность до того, как задумается «а надо ли мне это»:
- Регистрация — email + пароль (без подтверждения email на старте)
- Сразу после регистрации — визард: «Подключите ваш первый магазин»
- Выбор маркетплейса, ввод API-ключа, тест соединения
- Автоматический импорт первых 50 товаров
- Экран: «Готово! Вот ваши товары. Настройте синхронизацию»
Идея в том, чтобы до момента оплаты человек уже увидел свои реальные данные в системе. Опыт из Tirebase подсказывает, что менеджеры принимают решение в первые минуты.
План привлечения первых клиентов
Рекламу пока не планирую. Первых бета-тестеров буду искать в Telegram-чатах продавцов маркетплейсов — предложу бесплатный месяц в обмен на обратную связь. Если из 10 человек хотя бы 3 останутся платить после бесплатного периода — значит, продукт решает реальную проблему.
Сарафанное радио в нишевых сообществах продавцов работает лучше контекстной рекламы --- по крайней мере, такой у меня опыт из Tirebase.
Что понимаю уже сейчас
Запускать надо раньше, чем будет готово. MVP будет кривым и с багами. Но если он решает реальную проблему — за это готовы платить. Всё остальное допиливается итерационно.
Монолит — правильный выбор на старте. Не уверен, что shared DB будет масштабироваться на сотни клиентов, но для первых десятков это осознанный компромисс. Перейти на schema-per-tenant можно позже, если потребуется.
Не делать то, что уже решено. ЮKassa для биллинга, GitHub Actions + Docker Compose для деплоя, Celery для задач. Каждый час, потраченный на инфраструктуру вместо продукта --- потерянный час.