Я Шевкопляс Дмитрий, технический руководитель проекта Swapno — сервис для обмена автомобилями ключ-в-ключ, без дилеров. Механика — как в Tinder: свайпаешь чужие авто, если оба владельца лайкнули машины друг друга — Swap Match, начинается обмен. В этой статье расскажу, как мы спроектировали и написали бэкенд на Go за 3 месяца: от выбора архитектуры до matching engine, ИИ-модерации фото и observability в продакшене. С реальными ошибками, которые мы допустили, и тем, как их чинили.

Проблема: рынок обмена авто сломан

В России ~300 тысяч автомобилей, владельцы которых готовы к обмену. Сейчас они разбросаны по форумам, Avito и тематическим каналам в Telegram. Процесс обмена — это боль: нужно вручную искать подходящий вариант, договариваться о доплате, проверять VIN. Дилеры берут 10-30% комиссии и затягивают процесс на недели.

Мы решили это автоматизировать. Свайп-механика решает проблему поиска: вместо бесконечного скроллинга — быстрое «да/нет». Мэтч решает проблему доверия: оба хотят машину друг друга. ИИ-оценка и VIN-проверка решают проблему прозрачности.

От MVP к мобильному приложению

Первую версию мы запустили как Telegram Mini App — для быстрой проверки гипотезы без App Store и регистрации. Валидировали спрос, собрали первых пользователей, обкатали matching engine. Сейчас готовимся к переходу на нативное мобильное приложение. Бэкенд с самого начала проектировался как API-first — смена клиента не затрагивает серверную часть.

Mini App Swapno
Mini App Swapno

Архитектура: монолит с чистой архитектурой

На старте была развилка: микросервисы или монолит. Я выбрал монолит — и ни разу не пожалел.

Когда всего несколько разработчиков — микросервисы убивают. Я попробовал прикинуть: отдельный сервис для авто, для свайпов, для мэтчей, для уведомлений. Получилось 5 репозиториев, 5 Dockerfile, service mesh для общения между ними, и главное — каждый раз когда меняешь формат ответа в одном сервисе, ты идёшь чинить десериализацию в другом. В монолите поменял структуру — компилятор сразу показал все места, где она используется. Всё.

Но «монолит» не значит «каша». Я сразу жёстко разделил слои:

internal/
├── domain/ # Сущности и бизнес-правила (чистые структуры, нет зависимостей)
├── service/       # Бизнес-логика, оркестрация
├── repository/
│   ├── postgres/  # SQL-запросы
│   └── redis/     # Кеш и очереди
├── handler/       # HTTP-хендлеры (Huma)
├── middleware/    # Auth, rate limiting, logging
└── telegram/      # Бот, уведомления

Правило одно: зависимости направлены внутрь. handler → service → repository. domain не импортирует вообще ничего. Если я завтра захочу вынести свайпы в отдельный сервис — это service/swipe.go + repository/postgres/swipe.go + свой main. Пара дней, не месяцев.

Стек

Компонент

Технология

Почему именно это

HTTP

Fiber v2 + Huma v2

Fiber — быстрый, привычный express-стиль. Huma — типизированный OpenAPI поверх любого роутера. Автоматическая валидация, генерация документации

БД

PostgreSQL 17 + pgx/v5

Надёжно. FILTER WHERE для аналитики — одна из лучших фич, про которую мало кто знает

Кеш

Redis 7

Сессии, очереди свайпов, rate limiting, кеш статистики — всё в одном месте

S3

MinIO → Selectel S3

Локально MinIO для разработки, в проде — S3-совместимое облако. Один интерфейс, разные бэкенды

Логи

zerolog

Структурированный JSON, самый быстрый логгер для Go, context-aware через logger.L(ctx)

Отдельно про Huma. Я перепробовал gin, echo, chi — и везде одна проблема: ты описываешь API в коде, а OpenAPI-спеку генерируешь отдельно, и они расходятся. В Huma ты описываешь Go-структуры с тегами — и получаешь валидацию, документацию, типизированные ошибки из одного места. Впервые я реально открыл Swagger UI и он совпал с тем, что отдаёт сервер.

Ловушка: Huma использует указатели для optional-полей. Когда я написал BrandID *int в query-параметре — получил panic при обращении к nil. Потратил час на дебаг, прежде чем понял: в query-параметрах Huma нужно использовать обычные типы, а не указатели. Указатели — только для body.

Matching Engine: сердце продукта

Это самая интересная часть системы, и здесь больше всего набитых шишек.

Очередь свайпов

Первая наивная реализация: пользователь нажимает «свайп» → бэкенд идёт в PostgreSQL с запросом на фильтрацию, исключение просмотренных, сортировку, LIMIT 1. Работает? Работает. На 100 юзерах — нормально. Но запрос занимает 50-100ms, а свайпают быстро — по карточке в секунду. Ощущение задержки убивает UX.

Решение — предзаполненная очередь в Redis:

swipe:queue:{user_id} → [car_id_1, car_id_2, ..., car_id_50]

GET /swipe/next:

1. LPOP swipe:queue:{user_id} — O(1), ~0.1ms. Моментально.

2. Если очередь пуста — пересобираем из PostgreSQL одним тяжёлым запросом, кладём 50 результатов в Redis.

SELECT c.id FROM cars c
 JOIN user_interests ui ON ui.user_id = $1
 WHERE c.status = 'active'
   AND c.user_id != $1
   AND c.body_type = ANY(ui.body_types)
   AND c.brand_id IN (SELECT id FROM brands WHERE brand_group = ANY(ui.brand_groups))
   AND c.price BETWEEN (my_car.price - ui.max_surcharge) AND (my_car.price + ui.max_surcharge)
   AND c.id NOT IN (SELECT swiped_car_id FROM swipes WHERE swiper_user_id = $1)
 ORDER BY random()
 LIMIT 50

Тяжёлый запрос выполняется раз в 50 свайпов, а не на каждый. Пользователь видит карточку за 0.1ms.

Проблема, которую я не предвидел: что если пользователь свайпнул авто, а оно за это время было удалено или деактивировано? LPOP уже выдал этот car_id. Решение — в хендлере проверяем существование и статус авто. Если не подходит — берём следующий из очереди. В худшем случае — 2-3 LPOP'а вместо одного, всё равно быстро.

Мэтч-детекция

Мэтч — это когда оба владельца лайкнули авто друг друга. При лайке проверяем взаимность:

func (s SwipeService) Like(ctx context.Context, swiperID uuid.UUID, carID uuid.UUID) (MatchResult, error) {
     if err := s.swipes.Create(ctx, swiperID, carID, "like"); err != nil {
         return nil, err
     }
    
     // Проверяем: владелец swiped_car лайкнул наше авто?
     swiperCar,  := s.cars.GetByUserID(ctx, swiperID)
     targetCar,  := s.cars.GetByID(ctx, carID)
    
     reciprocal, _ := s.swipes.Exists(ctx, targetCar.UserID, swiperCar.ID)
     if !reciprocal {
         return &MatchResult{Match: false}, nil
     }
    
     // Мэтч! Считаем доплату.
     surcharge := math.Abs(swiperCar.Price - targetCar.Price)
    
     match, err := s.matches.Create(ctx, swiperCar.ID, carID, swiperID, targetCar.UserID, surcharge)
     if err != nil {
         return nil, err
     }
    
     go s.notifier.NotifyMatch(ctx, match)
    
     return &MatchResult{Match: true, MatchID: match.ID, Surcharge: surcharge}, nil
 }

Тут я первое время боялся race condition: два пользователя лайкают друг друга одновременно → два мэтча? На практике matches таблица имеет UNIQUE constraint на пару (car1_id, car2_id), и мы нормализуем порядок (меньший UUID первым). Даже при параллельном запросе один INSERT пройдёт, второй получит conflict → ON CONFLICT DO NOTHING. Мэтч создаётся ровно один.

Rate Limiting свайпов

Мы заложили многоуровневую систему лимитов — конкретные значения ещё подбираем по метрикам, но архитектура уже готова. Реализация — Redis INCR с TTL до полуночи:

func (s *SwipeService) checkLimit(ctx context.Context, userID uuid.UUID) error {
     key := fmt.Sprintf("rate:swipe:%s", userID)
     count, _ := s.redis.Incr(ctx, key).Result()
    
     if count == 1 {
         s.redis.ExpireAt(ctx, key, endOfDay())
     }
    
     limit := s.getLimitForUser(ctx, userID) // 5, 10, or ∞
     if count > int64(limit) {
         return apperr.ErrSwipeLimitReached
     }
     return nil
 }

Баг, который я словил в проде: endOfDay() возвращала полночь по серверному времени (UTC+3), а Redis TTL работает в абсолютных Unix timestamp'ах. В итоге для пользователей из Владивостока лимит сбрасывался в 4 утра по их времени. Пришлось перевести на UTC и смириться с тем, что «день» для всех — это UTC-день. Не идеально, но предсказуемо.

ИИ-модерация фото: fail-open и его последствия

При публикации авто мы модерируем все фото одним batch-запросом к OpenAI Vision API. Проверяем: это фотография автомобиля? Нет NSFW? Нет номеров телефонов поверх фото?

Ключевое архитектурное решение — fail-open: если ИИ недоступен — публикуем авто, алертим в Telegram-канал и отправляем в очередь ручной модерации. Модератор получает уведомление, открывает админку и проверяет фото вручную. Пользователь не заблокирован, а мы не пропускаем контент без проверки — просто проверка становится асинхронной.

func (m *Moderator) CheckImages(ctx context.Context, images []ImageData) []int {
     if m == nil || len(images) == 0 {
         return nil // модерация отключена — пропускаем
     }
    
     result, err := m.client.Moderate(ctx, images)
     if err != nil {
         logger.L(ctx).Error().Err(err).Msg("moderation API failed, fail-open → ручная модерация")
         return nil // AI не справился — модератор разберётся
     }
    
     return result.RejectedIndices
 }

На практике ИИ-модерация отрабатывает в 95%+ случаев. Ручная модерация — это страховка, а не основной поток. Но когда OpenAI API лёг на 10 минут и 3 пользователей пытались опубликовать авто — все опубликовали без задержки, а модератор проверил их фото в 5 минут.

Временные фото и S3 lifecycle

Загруженные фото сначала попадают в tmp/cars/{car_id}/ в S3. При успешной публикации — перемещаются в cars/{car_id}/. S3 lifecycle rule удаляет всё из tmp/ через 14 дней.

Изначально я поставил TTL 1 день. Казалось логичным: зачем хранить черновики? Через неделю получил алерт: «Не удалось скачать ни одного фото для модерации». Пользователь загрузил фото в пятницу вечером, а опубликовать решил в воскресенье. Фото уже удалены. Он получил невнятную ошибку и ушёл. После этого поднял TTL до 14 дней:

if len(imageData) == 0 && len(photos) > 0 {
     return nil, apperr.ErrPhotosExpired
     // → HTTP 422 "фото устарели и были удалены. Загрузите фото заново"
 }

Ещё один сюрприз с фото: nginx стоит перед Go-сервером и имеет свой client_max_body_size. Я выставил лимит в Go (20 МБ), но забыл про nginx (по умолчанию 1 МБ). Пользователь загружает фото с iPhone — 15 МБ HEIC — и получает HTML-страницу с 413 Request Entity Too Large от nginx. Не JSON, а сырой HTML. Пришлось: поднять nginx лимит до 50 МБ, добавить раннюю проверку Content-Length в Go-хендлере, и научить фронтенд парсить HTML-ошибки от nginx.

Оптимизация SQL: как мы ускорили дашборд в 4 раза

Для админского дашборда нужны 15 метрик: пользователи, авто, свайпы, мэтчи — всего, за сегодня, активные, премиум и т.д. Первая реализация — 15 подзапросов в одном SELECT:

SELECT
     (SELECT COUNT(*) FROM users),
     (SELECT COUNT(*) FROM users WHERE created_at >= CURRENT_DATE),
     (SELECT COUNT(*) FROM users WHERE subs_status = 'premium'),
     ...

Каждая таблица сканировалась 3-5 раз. На 10 000 записях — нормально. На 100 000 — запрос занимал 200ms. Для дашборда, который Prometheus скрейпит каждые 15 секунд — это проблема.

Переписал на CTE с FILTER WHERE — одна из самых недооценённых фич PostgreSQL:

WITH
   u AS (
     SELECT
       COUNT(*)                                           AS total,
       COUNT(*) FILTER (WHERE created_at >= CURRENT_DATE) AS today,
       COUNT(*) FILTER (WHERE subs_status = 'premium')    AS premium,
       COUNT(*) FILTER (WHERE banned_at IS NOT NULL)       AS banned
     FROM users
   ),
   c AS (
     SELECT
       COUNT(*)                                           AS total,
       COUNT(*) FILTER (WHERE status = 'active')          AS active,
       COUNT(*) FILTER (WHERE created_at >= CURRENT_DATE) AS today,
       COALESCE(AVG(price) FILTER (WHERE status = 'active' AND price > 0), 0) AS avg_price,
       COUNT(DISTINCT user_id)                            AS users_with
     FROM cars
   ),
   -- swipes, matches аналогично
 SELECT ... FROM u, c, sw, m;

4 seq-scan'а вместо 15. Один round-trip. Плюс Redis-кеш на 60 секунд. Запрос с 200ms упал до 15ms, а с кешем — 0.1ms.

N+1: 50 запросов → 2

Классика жанра. Список 25 авто в админке — для каждого нужно имя бренда и модели. Первая реализация:

for i, car := range cars {
     brand,  := catalog.GetBrandByID(ctx, car.BrandID)   // запрос в PG
     model,  := catalog.GetModelByID(ctx, car.ModelID)    // ещё запрос в PG
     items[i].BrandName = brand.Name
     items[i].ModelName = model.Name
 }
 // 25 авто × 2 запроса = 50 запросов

Классический N+1. Решение — batch:

brandIDs, modelIDs := collectIDs(cars)
 brandsMap,  := catalog.GetBrandsByIDs(ctx, brandIDs)  // WHERE id = ANY($1) — 1 запрос
 modelsMap,  := catalog.GetModelsByIDs(ctx, modelIDs)   // 1 запрос

 for i, car := range cars {
     items[i].BrandName = brandsMap[car.BrandID].Name
     items[i].ModelName = modelsMap[car.ModelID].Name
 }
 // 2 запроса вместо 50

Я знал про N+1, но всё равно написал наивную версию первой. Потому что «сначала работает, потом быстро». Оптимизировал когда увидел в логах, что admin car list отвечает за 400ms.

Observability: то, что спасает в 3 часа ночи

Я подключил полный стек мониторинга с первого дня в продакшене. Не потому что так написано в книжках, а потому что уже обжёгся: на предыдущем проекте ловил баги по скриншотам от пользователей и grep по логам на сервере через SSH.

Трейсинг (OpenTelemetry → Jaeger):

Каждый HTTP-запрос создаёт трейс. Внутри — спаны на PostgreSQL (otelpgx), Redis (redisotel), S3, внешние API. Реальная история: пользователь жалуется что «публикация тормозит». Открываю Jaeger, нахожу трейс — 3.2 секунды. Из них 2.8 секунды — загрузка 5 фото в S3 последовательно. Переделал на параллельную загрузку — 800ms. Без трейсинга я бы гадал неделю.

Ещё один кейс: /metrics эндпоинт возвращал 500, Prometheus не мог скрейпить метрики. В логах — ничего полезного, потому что panic ловился middleware. Проблема оказалась в gofiber/adaptor — он не реализует http.Flusher, а promhttp.Handler() ожидает его. Заменил на нативный Fiber handler с prometheus.DefaultGatherer.Gather() — починилось. Без мониторинга мониторинга (тавтология, да) я бы узнал об этом когда отвалились все алерты.

Метрики (Prometheus + Grafana):

Бизнес-метрики: swapno.swipes{direction="like"}, swapno.matches, swapno.registrations, swapno.photo_uploads{status="success|fail"}. Технические: latency, error rate, goroutines. Всё через OTel SDK → Prometheus exporter.

Важный паттерн — nil-check на метриках. Если OTel не инициализирован (например, в тестах или в dev-режиме без Jaeger) — метрики = nil. Вместо if-else на каждом вызове:

if appOtel.SwipesTotal != nil {
     appOtel.SwipesTotal.Add(ctx, 1, metric.WithAttributes(...))
 }

Некрасиво, но безопасно. Сервис работает без OTel, метрики просто не пишутся.

Логи (zerolog → Loki):

Структурированный JSON с trace_id в каждой строке:

logger.L(ctx).Info().
     Str("car_id", carID.String()).
     Int("photos", len(photos)).
     Msg("car published")
 // → {"level":"info","car_id":"...","photos":3,"trace_id":"abc123","message":"car published"}

В Grafana: видишь ошибку в логе → кликаешь на trace_id → попадаешь в Jaeger с полным трейсом запроса. Это экономит часы дебага.

Алертинг через Telegram-бота:

Критичные ошибки улетают прямо в Telegram-канал админам для оперативности, пример формата:

? Ошибка в бэкенде
⚙️ Операция: publish_moderation_skipped
❌ Ошибка: не удалось скачать ни одного фото для модерации
? Детали: car_id=167f0617-..., photos=3
? 08.04.2026 13:50:04

Именно такой алерт помог мне поймать баг с истёкшими фото, о котором я рассказывал выше.

Что дальше

  • Нативное мобильное приложение — Telegram Mini App была первой итерацией. Бэкенд API-first, переход на мобильный клиент не требует изменений серверной части.

  • Улучшение matching — учёт гео-локации, предпочтений, ML-модель на основе истории свайпов

  • Чат между мэтчами — обсуждение деталей обмена прямо в приложении

Итоги

За 3 месяца мы написали production-ready бэкенд на Go:

  • ~15 000 строк Go-кода

  • 40+ API-эндпоинтов

  • Полная observability с первого дня

  • Время ответа — p95 < 50ms для основных эндпоинтов

Монолит с чистой архитектурой — не dirty hack, а осознанный выбор для маленькой команды. Главное что я вынес: не бойся писать наивный код первой итерацией. Пиши тупо, деплой, смотри метрики, оптимизируй по факту. Половина оптимизаций из этой статьи родились из реальных проблем в продакшене, а не из планирования на бумаге.

Если хотите разбор конкретного компонента — matching engine, ИИ-модерации или observability стека — напишите, сделаю отдельную статью.

Комментарии (7)


  1. IvanoDigital
    14.04.2026 11:42

    Прежде чем что-то запускать, вначале необходимо провести Custdev, а такое вообще нужно рынку. Кто-то вообще готов менять шило на мыло? У вас уже сколько сделок было?


    1. AdrianoVisoccini
      14.04.2026 11:42

      Первую версию мы запустили как Telegram Mini App — для быстрой проверки гипотезы без App Store и регистрации.

      так они и провели


    1. vantusam
      14.04.2026 11:42

      Ну там уже 1000+ машин добавлено, думаю, что удалось ответить на твой вопрос


  1. Squoworode
    14.04.2026 11:42

    Swapno

    Чо-то нейминг не очень, имхо...


  1. Einherjar
    14.04.2026 11:42

    Мэтч решает проблему доверия: оба хотят машину друг друга

    Я не понимаю что это за сценарий такой.

    А как хотелки пользователей могут так вот пересекаться?

    Ну тоесть вот допустим пользователь А ездит на старом авто, стоимостью допустим 2млн, и допустим хочет его сменить. В том же ценовом сегменте очевидно будет шило на мыло, следовательно надо доплатить и обменять на новое за, допустим 4млн. Идет и свайпает свои хотелки. Тут все понятно и логично.

    Но пользователю Б, кто выставил более новое авто за 4млн то на кой ляд может понадобиться лайкать какие то старые более дешевые ведра?

    Единственный случай когда это работает это только если пользователь Б это как раз дилер или какой-н мутный перекуп, тогда непонятно почему вы позиционируете

    сервис для обмена автомобилями ключ-в-ключ, без дилеров


    1. sibvic
      14.04.2026 11:42

      Скорее всего, это на "побаловаться". Типа на мерсе покатался, хочу такую же, но BMW. Очень узкая категория. Тем более вообще не понятно, как там кто-то что-то менять решится. Обычно в объявлении одно, а при реальной проверке - другое.


  1. Bigdoc
    14.04.2026 11:42

    Не понял идеологии сервиса. Что такое обмен ключ в ключ? Без доплат? Зачем менять авто на практически такое же (если исходить из равной цены)?