Назад к блогу

SyncFlow: как я проектирую SaaS для синхронизации маркетплейсов

Проектирование SyncFlow — сервиса синхронизации товаров между маркетплейсами. Multi-tenancy, биллинг, выбор стека, архитектурные решения на раннем этапе.

SyncFlow: как я проектирую SaaS для синхронизации маркетплейсов

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:

  1. Отдельная БД на клиента --- максимальная изоляция, кошмар в обслуживании
  2. Отдельная схема на клиента --- компромисс, но миграции на 100 схем --- боль
  3. Общая БД с 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 минут решают всё

Проектирую онбординг так, чтобы пользователь увидел ценность до того, как задумается «а надо ли мне это»:

  1. Регистрация — email + пароль (без подтверждения email на старте)
  2. Сразу после регистрации — визард: «Подключите ваш первый магазин»
  3. Выбор маркетплейса, ввод API-ключа, тест соединения
  4. Автоматический импорт первых 50 товаров
  5. Экран: «Готово! Вот ваши товары. Настройте синхронизацию»

Идея в том, чтобы до момента оплаты человек уже увидел свои реальные данные в системе. Опыт из Tirebase подсказывает, что менеджеры принимают решение в первые минуты.

План привлечения первых клиентов

Рекламу пока не планирую. Первых бета-тестеров буду искать в Telegram-чатах продавцов маркетплейсов — предложу бесплатный месяц в обмен на обратную связь. Если из 10 человек хотя бы 3 останутся платить после бесплатного периода — значит, продукт решает реальную проблему.

Сарафанное радио в нишевых сообществах продавцов работает лучше контекстной рекламы --- по крайней мере, такой у меня опыт из Tirebase.

Что понимаю уже сейчас

Запускать надо раньше, чем будет готово. MVP будет кривым и с багами. Но если он решает реальную проблему — за это готовы платить. Всё остальное допиливается итерационно.

Монолит — правильный выбор на старте. Не уверен, что shared DB будет масштабироваться на сотни клиентов, но для первых десятков это осознанный компромисс. Перейти на schema-per-tenant можно позже, если потребуется.

Не делать то, что уже решено. ЮKassa для биллинга, GitHub Actions + Docker Compose для деплоя, Celery для задач. Каждый час, потраченный на инфраструктуру вместо продукта --- потерянный час.