Продолжение статьи о продуктовой гипотезе и дистрибуции. Здесь — только инженерная часть.
Введение
В первой статье я описывал продуктовую гипотезу: возможно ли построить B2B SaaS-платформу и со временем маркетплейс приложений, не забирая у вендоров код, инфраструктуру и контроль над backend-сервисами.
Эта статья — продолжение, но уже с инженерной стороны. Она не про рынок и не про стратегию, а про то, как такая платформа реально проектировалась и строилась, какие технические решения мы принимали и почему итоговая архитектура выглядит именно так.
Откуда вообще появилась идея платформы
Идея платформы родилась не из архитектурных экспериментов и не из желания «сделать экосистему».
Внутри группы компаний существовало много разных продуктов и еще к тому же, нам на поддержку постоянное передали в контур новые и новые. Они решали разные бизнес-задачи, но почти у каждого был собственный back-office. Когда мы вместе с архитектором и CTO посмотрели на эти решения внимательнее, стало очевидно: при всей разнице предметных областей они снова и снова реализуют один и тот же набор функций.
Пользователи и роли, аудит действий, статистика, отчетность, подключенные финансовые решения, административный интерфейс и не только… — всё это каждый раз писалось заново. Эти части редко являлись конкурентным преимуществом, но стабильно съедали время и ресурсы.
В какой-то момент стало понятно, что масштабировать такой подход дальше означает лишь накапливать легаси. Тогда и появилась идея вынести этот повторяющийся слой в универсальный эксплуатационный фундамент, поверх которого можно строить продукты быстрее и предсказуемее.
Почему мы начали с требований, а не с архитектуры
На старте проекта мы сознательно не начинали с выбора стека или архитектуры. Вместо этого мы начали с функциональных и бизнес-требований.
Нужно было понять, что именно должна уметь платформа, если она претендует на роль фундамента для множества продуктов — текущих и будущих. Мы описали минимальный набор функций и расширенный функционал, который неизбежно понадобится со временем.
Ключевой момент: требования группировались не по сервисам и не по технологиям, а по продуктам как бизнес-сущностям.
В нашей модели продукт — это не один backend-сервис. Один продукт может включать несколько сервисов, а один сервис может использоваться в нескольких продуктах.
Только после этого к работе подключился наш архитектор.
Архитектура сверху вниз
Первым человеком, который начал помогать выстраивать общую архитектурную картину, стал наш архитектор — Максим Шлемарев.

Максим Шлемарев
Solution Architect / Архитектор
Мы использовали Archi для построения концептуальных схем. На этом этапе нас интересовали не конкретные технологии, а домены и границы ответственности: какие части системы должны быть изолированы, где допустимы зависимости, а где они недопустимы.
Этот подход можно описать как проектирование сверху вниз. Сначала формировалась универсальная модель платформы без привязки к реализации. Затем, при переходе к конкретному продукту, архитектура детализировалась через бизнес- и системную аналитику.

Подробнее по схеме
Где Api-client - возможность обращаться в систему по публичному апи;
[react] Backoffice - фронтент-приложение написанное на React;
Единственная публичная точка обращения к backend системы - это ApiGateway;
Все остальные backend-сервисы закрыты от внешнего мира в kubernates;
В роли Edentity auth manager’а используется коробочное решение Keycloack;
Генерация и проверка прав управляется отдельным user-access-приложением написанным на golang;
СоmmonServices - основные core-сервисы управления системой (организации, приложения, продукты, конструктор страниц);
Billing - фин.част системы (платежный шлюз, управление заказами, платежные системы, терминал/касса);
В качестве отслеживания за состоянием системы (ошибки, логирование, трассировка) используется: sentry, grafana, prometeus, elk/kibana, jaeger.
Каждый сервис проектировался так, чтобы быть максимально атомарным.
В случае если один из сервисов прекращает свою работу, это никак не влияет на работу всей системы в целом. Из нее просто выключается та область функционала, за которую отвечал отключенный сервис.
Для того чтобы система была максимально безопасна и фильтровала в себе весь входящий трафик, была выбрана одна публичная точка входа (ApiGateway). Все остальные сервисы изолированы внутри kubernates.
ApiGateway в зависимости от входящего запроса знает к какому сервису обращаться, агрегировать запросы к нескольким сервисам либо вовсе блокировать запрос. Любой действие в системе логируется.
Чтобы в каждом из бизнесовых сервисов не приходилось делать свою ролевую модель и управления правами был реализован отдельно стоящий сервис с общим RBAC - контрактом. В одном месте происходит авторизация пользователя, задаются права. Вертикальные права контролируются на уровне единой точки входа (ApiGateway). Таким образом ролевую модель можно настраивать для любого сервиса, в том числе и вендора. Плюс имеется возможность при передаче данных общего контракта контролировать горизонтальные права на уровне каждого из сервисов при необходимости.
Как от архитектуры перешли к UI и UX
Довольно быстро стало ясно, что основная сложность всех этих продуктов не только архитектура.
Чтобы решить проблему неопределенности со стороны бизнеса, мы начали с интерактивных прототипов UI в Figma. Это были не статичные макеты, а анимированный прототип админки и готовые бизнес-сценарии, в которых можно было пройти основные пользовательские действия и потыкать заранее то как будет это работать.


Уровень детализации был близок к будущей реализации, что позволяло обсуждать с бизнесом не требования, а реальные действия пользователей в UI. Это стало отличным решением, чтобы снять большую часть неопределенности и объяснить бизнесу как возможно будет эксплуатировать спроектированные продукты.


Для создания интерфейсов мы осознанно выбрали IBM Carbon Design System — не как визуальный стиль, а как инженерно описанную систему проектирования утилитарных B2B-интерфейсов. Команда IBM проделала огромную работу, описав frontend-компоненты и UX-паттерны для back-office решений. На этих принципах построена и крупнейшая SaaS-платформа IBM — IBM Cloud.
Как из прототипов UI вырос конструктор страниц
На этапе прототипирования стало очевидно, что административный UI состоит из ограниченного набора повторяющихся паттернов. Таблицы с действиями на сущностью, карточки сущности и ее редактирования и карточки ее создания — стандартные действия CRUD (Create, Read, Update, Delete), всё это повторялось от продукта к продукту, от раздела к разделу.
В этот момент стало ясно, что нет смысла программировать каждую страницу отдельно. Так появился конструктор страниц — отдельный инструмент внутри платформы. Страницы описываются декларативно через конфигурацию, а платформа сама рендерит готовый back-office UI.

Страницы описываются декларативно через JSON-конфигурации. Платформа рендерит back-office UI самостоятельно, проверяя права доступа и обращаясь к удалённому backend-сервису вендора за данными.
Флоу выглядит так:
В сервисе product-settings регистрируется продукт;
Описываются маршруты и страницы через соответствующие разделы UI;
Импортируются JSON-конфиги страниц;
В user-access настраиваются права на страницы и компоненты;
При открытии страницы конструктор:
Проверяет права;
Запрашивает данные у backend-сервиса;
Рендерит UI.
Неправильно сформированный конфиг не ломает систему — он просто не применяется.
Пример реального Json конфига страницы раздела: Продукты
{ "titleKey" : "UI.Foundation.Products.title", "entityIdName" : "productId", "templateType" : "table", "content" : { "model" : "products", "action" : "index", "columns" : [ { "id" : "name", "type" : "text", "labelKey" : "UI.Foundation.Products.tableHeaderProduct", "isSortable" : true, "pageKey" : "foundationProductEdit" }, { "id" : "vendorName", "type" : "text", "labelKey" : "UI.Foundation.Products.tableHeaderVendor", "isSortable" : true }, { "id" : "description", "type" : "text", "labelKey" : "UI.Foundation.Products.tableHeaderDescription", "isSortable" : false } ], "columnsActions" : [ { "id" : "edit", "labelKey" : "UI..Table Fast Edit.title", "pageKey" : "foundationProductEdit" } ], "toolbarOptions" : { "actions" : [ { "labelKey" : "Table Toolbar.buttonCreate", "type" : "primary", "pageKey" : "foundationProductCreate", "icon" : "Add" } ], "filters" : { "search" : true, "refreshTable" : true, "queryFilter" : [ { "queryKey" : "organizationId", "labelKey" : "UI.Foundation.Products.tableFilterVendorLabel", "type" : "filterableMultiselect", "options" : { "product" : "Foundation", "model" : "organizations", "action" : "listByVendor" } } ], "settings" : true } }, "batchActions" : [], "pagination" : true } }
Микросервисы, инфраструктура, финансы и SAGA
Платформа построена на микросервисной архитектуре и развернута в AWS и Google Cloud. Она Kubernetes-native и масштабируется на уровне сервисов.
Для контроля работы такой большой системы и по��держания ее работоспособности 24/7 было релизовано автоматическое поднятие подов в kubernates, набор всевозможных технических показателей в prometeus, которые алертят в случае каких-то неисправностях.
Также контролируются и бизнесовые метрики.
Был реализован отдельный сервис, в который слетается огромное количество показателей системы из других сервисов. И в случае, если эти показатели не совпадают со стабильными показателями в определенный временной интервал, система начинает всячески об этом уведомлять.
Главный коммерческий финансовый продукт требовал особого внимания
Далее хотим поделиться самой полезной и интересной частью - SAGA, чтобы не затягивать и без того длинную статью. Архитектор Максим Шлемарев
При синхронном взаимодействии сервис Accounts мог не получить ответ от PaymentAggregator из-за таймаута или сетевого сбоя, даже если платёж был успешно обработан. Это приводило к несоответствию данных.
Чтобы сервис Accounts всегда знал реальный статус операции — даже при сбоях, уходе клиента со страницы или остановке одного из сервисов — был реализован паттерн SAGA.


Ранее при синхронном взаимодействии сервис Accounts отправлял запрос в сервис PaymentAgregator и не получал гарантированного ответа. Если в PaymentAgregator платеж был обработан, но сервис Accounts не «дождался» ответа по причинам таймаута / ошибки сети, созданная и успешно проведенная транзакция в сервисе PaymentAgregator оставалась в сервисе Accounts в статусе «IN_PROGRESS», по причине чего возникала такая проблема, как несоответствие данных.
Необходимо настроить транзакционную часть между сервисами биллинга таким образом, чтобы сервис Accounts всегда имел информацию о реальном статусе платежа, даже если:
Сервис PaymentAgregator приостановил работу по причине технических неполадок;
Клиент покинул страницу оплаты;
Произошел сбой в сети.
Управление статусами (webhook)
Для отправки уведомлений о статусах транзакций внешним системам используется webhook. Для финализации статуса транзакции через webhook необходимо зафиксировать статусы completed, declined, error.
После финализации статуса сервис Accounts отправляет callback:
Во внешнее приложение - если статус терминальный;
В терминал - всегда, независимо от статуса (включая статус IN_PROGRESS, после этого терминал ожидает дальнейшие обновления по WebSocket).
Callback отправляется через API на backend терминала. В случае отсутствия ответа 200 от терминала, запрос должен повториться через failedCallbacks.
Актуализация статуса
Актуализация статуса происходит через запросы / WebSocket. Для получения информации по ордеру после завершения SAGA на стороне терминала реализован метод API getOrder. После отправки запроса на создание депозита будет возвращен статус IN_PROGRESS (без redirectUrl и paymentDetails, которые поступят позже по callback от сервиса Accounts и далее будут переданы терминалом на фронт по WebSocket. Метод API getOrder будет использоваться в случае необходимости ручного запроса или проверки.
Шаг 1: Создание ордера
Customer формирует запрос на создание ордера (через интерфейс ExternalApp);
ExternalApp отправляет запрос на создание платежа вызовом CreatePayment в Сервис Accounts;
В рамках flow по созданию ордера Сервис Accounts:
создает запись ордера (Accounts->Orders: createOrder);
сохраняет ордер в БД со статусом in_progress (Orders->Database: saveOrder(in_progress)) - получает Id ордера из таблицы orders;
записывает статус ордера в таблицу order_status_log (OrderStatusLog->Database: saveStatusLog);
записывает данные ордера в таблицу order_log (OrderLog->Database: saveOrderLog);
Сервис Accounts отправляет события в Kafka [topic] saga_orders_ready_to_validate.
Шаг 2: Валидация пользователя и транзакции
Сервис Blacklist проверяет пользователя;
Сервис Blacklist отправляет результаты валидации в Kafka [topic] saga_orders_validated;
Сервис Accounts получает результаты валидации из Kafka, и если:
статус одного из сервисов валидации (Blacklist или AntiFrod) = invalid, сервис Accounts обновляет статус ордера и инициирует callback в терминал через API. Callback содержит финальный статус и поле error (без redirectUrl или paymentDetails). В случае отсутствия ответа 200 от терминала, запрос должен повториться через failedCallbacks. После фиксации статус ордера на error по результату валидации = invalid одного из сервисов валидации последующие результаты валидации = invalid из других сервисов проверки не будут записываться.
статус всех сервисов валидации (Blacklist и AntiFrod) = validate, сервис Accounts действует в зависимости от типа платежа в соответствии с описанием Шаг 3.
! Внешнее приложение будет получать callback в любом случае, независимо от типа (H2H или H2C)
Шаг 3: Проведение платежей в зависимости от типа (H2H или H2C)
Единая модель для всех типов платежей (H2H, H2C)
Клиент всегда направляется на наш терминал, который отображает Preloader - пока SAGA выполняется (проверка валидности и запуск транзакции):
После того как результаты проверки транзакции или валидности получены, терминал отображает одно из двух возможных состояний, в зависимости от типа операции:
Если H2C - платеж переходит в iframe, клиент переходит через внешний шлюз для оплаты;
Если H2H - мы генерируем нужную форму для ввода реквизитов, клиент вводит данные в нашем терминале;
Терминал выполняет авторизацию;
Сервис Accounts отправляет запрос на обработку транзакции в Kafka [topic] saga_payments с обязательными полями согласно описания;
Сервис PaymentAggregator получает запрос из Kafka [topic] saga_payments и обращается к PaymentSystem (запрашивает redirectUrl для H2C или генерирует форму для ввода реквизитов для H2H);
Сервис PaymentAggregator получает результаты от PaymentSystem (подтверждение или redirectUrl) и передает в Kafka [topic] saga_transactions;
Сервис Accounts получает ответ через Kafka [topic] saga_transactions и пушит обновление статуса в терминал:
Accounts отправляет в Терминал Back данные - redirectUrl или paymentDetails + актуальный статуса ордера (эти данные отправляются терминалу по API через callback даже если статус не финализирован);
Terminal Back пушит полученные данные в Terminal Front через Websocket для отображения UI клиенту (если ответ от терминала не поступил - данные будут повторно отправлены через failedCallbacks).
Шаг 4: Результат проведения платежа
Сервис Accounts обновляет статус ордера в таблице order_status_log (Accounts->OrderStatusLog: updateStatus, OrderStatusLog->Database: saveStatus);
Сервис Accounts сохраняет информацию об ордере в таблицу order_log (Accounts->OrderLog: logSuccess, OrderLog->Database: saveLog);
Сервис Accounts отправляет callback с финальным статусом ордера;
Сервис Accounts передает терминалу redirectUrl или paymentDetails;
Терминал пушит данные на frontend через Websocket канал;
Frontend обновляет UI для клиента в зависимости от статуса (редирект на PaymentSystem через iframe для H2C или отображает форму с реквизитами для ввода данных для H2H) (может использовать запрос getOrder для получения актуального статуса в случае отсутствия Websocket).
Данные, которые backend терминала отправляет по WebSocket после получения события от сервиса Accounts:
Единый WebSocket-канал
Для обмена данными между терминалом и backend используется один общий WebSocket-канал для всех терминалов.
Терминал будет получать redirectUrl и paymentDetails только по callback от сервиса Accounts и передавать на фронт через WebSocket. Клиент получит на фронте статус IN_PROGRESS сразу, а платежные данные - асинхронно.
Безопасность и зрелость системы
Платформа прошла сертификацию PCI DSS версии 4, включая penetration testing, сканирование уязвимостей и анализ архитектуры. Это был сложный, но крайне полезный этап, который сильно повысил зрелость всей системы.


Для того чтобы не сертифицировать всю систему состоящую из большого количества сервисов, которые не относятся к проведению платежей и т.к система представляет собой набор небольших микросервисов, нам не составило труда разделить области ответственности. Всю бизнес-логику, которая требует обязательного сертифицирования (карточные данные, общение с платежными системами), мы изолировали в отдельную инфраструктуру.
Вместо итога
Эта статья честный срез инженерных решений, которые мы принимали.
Нам будет особенно интересно услышать:
Где вы видите слабые места описанного подхода?
Какие решения вы приняли бы иначе?
и с какими проблемами масштабирования B2B-платформ вы сталкивались сами?
Хорошая архитектура редко рождается в одиночку — чаще всего она появляется из споров и сомнений.
