П Прорвёмся!
← Все статьи
· 6 мин DjangoMarketplaceBarterArchitecture

Прямой обмен почти никогда не складывается: что мы поняли, пока строили бартерную платформу

Цепочки из трёх-четырёх участников работают лучше, чем прямые сделки. Переговоры без денег требуют большей инженерной дисциплины, чем с деньгами. И ещё несколько открытий, сделанных по ходу разработки.

Дмитрий Дмитрий лид-разработчик «Прорвёмся!»

Когда начинаешь делать бартерную платформу, кажется, что главное — дать пользователю выложить товар, а другому — предложить обмен. Каталог, поиск, кнопка «предложить обмен». Всё как в обычном маркетплейсе, только без денег.

Потом оказывается, что два человека с взаимным интересом к товарам друг друга — это статистическая редкость. Что переговоры без платёжного эскроу требуют более жёсткой архитектуры, чем переговоры с ним. И что избранное — это не фича для удобства пользователя, а топливо для поиска обменных цепочек.

Barterri — платформа для бартерного обмена товарами и услугами. Люди публикуют объявления, находят предложения, договариваются и завершают сделки. Никаких денег, только взаимная полезность. Звучит просто. Но внутри это оказалось совсем не маркетплейсом.

Прямой обмен — это исключение, а не правило

Когда мы начинали, логика была простая: пользователь A хочет товар пользователя B, пользователь B хочет товар пользователя A. Сделка.

Проблема в том, что такое совпадение — редкость. В реальности один хочет велосипед, но предлагает книги. Другой хочет книги, но предлагает ремонт техники. Третий хочет ремонт, но предлагает велосипед. По отдельности ни одна пара не сходится. А вместе — замкнутый круг, где каждый отдаёт своё и получает чужое.

Именно это открытие и определило архитектуру. Платформа должна не просто сводить пары, а находить циклы в графе желаний.

Избранное стало не списком «посмотреть позже». Каждый добавленный в избранное лот — это ребро графа от одного пользователя к товару другого. На этом графе мы запускаем поиск цепочек из трёх-четырёх участников. Нашли цикл — предложили цепочку всем участникам. Каждый подтверждает — сделка резервируется.

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

Переговоры без денег требуют большей дисциплины, чем с деньгами

В обычном маркетплейсе есть платёж. Покупатель заплатил — продавец отправил. Если что-то пошло не так, есть чек, банк, арбитраж. Деньги сами по себе держат сделку.

В бартере этого якоря нет. Никто никому не платит. Единственное, на чём держится сделка — это договорённость сторон. И если платформа позволяет эту договорённость случайно нарушить, доверие исчезает мгновенно.

Первая мысль была простая: сделаем оффер — предложение обмена — и пусть стороны его редактируют, пока не договорятся. Но редактируемый оффер — это проблема. Допустим, я предложил вам обменять велосипед на книги. Вы подумали и согласились. Но пока вы думали, я изменил состав оффера — добавил ещё один товар. Вы нажали «принять», даже не заметив изменений.

Чья версия сделки теперь действует? На что вы на самом деле согласились?

Мы пришли к тому, что каждая версия оффера должна быть неизменяемой. Не «редактировать предложение», а «создать новую ревизию». Предыдущая версия заморожена навсегда. Когда сторона принимает оффер, она принимает конкретную ревизию. Если за время раздумий появилась новая — платформа обязана сказать: «Вы принимаете устаревшую версию, подтвердите ещё раз».

Это не overengineering. Это единственный способ построить доверие в системе, где нет денег, которые могли бы это доверие заменить. Когда единственная гарантия сделки — это взаимное согласие, платформа не имеет права путать, на какую версию согласия вы дали.

И да, это замедлило разработку. Зато теперь каждая сделка имеет недвусмысленную, зафиксированную историю: кто что предложил, кто что принял и в какой момент. Без этого бартер — не платформа, а доска объявлений.

Фотографии не должны проходить через backend

В маркетплейсе объявлений фотография — это не украшение. Это главный аргумент. Пользователь принимает решение по картинке, а не по описанию. И картинок может быть много — у каждого объявления до десятка снимков в хорошем разрешении.

Если гнать этот трафик через backend — принимать файлы в API, обрабатывать, складывать в хранилище — сервер начинает работать файловым хостингом, а не бизнес-логикой. Масштабировать такое больно, а пользы от проксирования — ноль.

Мы вынесли загрузку на сторону клиента. Backend выдаёт presigned-разрешение на прямую загрузку в объектное хранилище. Фронт отправляет файлы напрямую, минуя сервер приложения. Ключи привязаны к пользователю, формат и размер проверяются до подписания, разрешение действует десять минут.

Но самое интересное — не сам механизм presigned-загрузки. Он известный и документированный. Интересно следствие для продакшен-инфраструктуры: маршрут /media/ не должен вести в backend. Если reverse proxy направит запрос к /media/ в приложение, а приложение не хранит медиа локально, получится либо 404, либо попытка отдать файл из несуществующего пути. И то и другое — ошибка, которую не видно при разработке, где медиа часто лежат на диске.

Поэтому в production-конфигурации роутер backend-сервиса сознательно не принимает запросы к /media/. Это не забыли. Это часть архитектуры.

Реальное время не должно жить в отдельном сервисе

Чат и уведомления в бартерной платформе — не самостоятельные продукты. Чат привязан к конкретному офферу или цепочке. Уведомление сообщает об изменении статуса сделки. Сообщение в чате и смена ревизии оффера — это события одного процесса переговоров.

Первая мысль: вынести realtime в отдельный сервис. Микросервис для чата, свой WebSocket, своя база, свой деплой. Красивая схема, знакомая по многим проектам.

Но тогда чат и доменная логика живут в разных мирах. Сообщение в чате «я согласен на эти условия» не знает, что в доменной модели только что создалась новая ревизия оффера. Уведомление о принятии оффера летит из другого сервиса и может опередить или отстать от реального изменения в базе. А главное — транзакционная целостность между переговорным процессом и его обсуждаемой версией теряется.

Мы оставили всё в монолите. Приложение через ASGI обслуживает и HTTP, и WebSocket. Чаты, уведомления и бизнес-логика разделяют одну сессию, одну модель прав, одну базу. Сообщение в чате и смена статуса оффера могут жить в одной транзакции. Redis-канал доставляет события подписчикам, но источник истины — один.

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

Что получилось

В Barterri в итоге есть:

  • каталог объявлений с поиском, картой, категориями и модерацией;
  • автоматический подбор цепочек обмена по графу избранного;
  • офферы с неизменяемыми ревизиями для безопасных переговоров;
  • чат и уведомления, работающие внутри той же транзакционной модели;
  • прямая загрузка изображений в объектное хранилище;
  • прод-инфраструктура с раздельной маршрутизацией SPA и API через общий reverse proxy.

Но список фич никогда не расскажет, почему прямой обмен — это исключение, а цепочки — правило. Почему редактируемый оффер страшнее, чем отсутствие оффера вообще. Почему фотографии не должны трогать backend, а чат не должен из него уходить.

Мне кажется, это и есть самое интересное в разработке — не то, что мы сделали. А то, что мы поняли, пока делали.