Однажды у нас появилась задача, которая (на первый взгляд) выглядела очень простой: сделать опросник в приложении. На макетах всего лишь пара экранов, несколько вопросов, кнопка «Далее». Всё красиво, не сухо, с картинками у вариантов ответа и нормальной подачей, а не в формате «Заполните обязательные поля»

Судя по макету всё просто: сверстать флоу и отправить все ответы одним POST в конце. Самый короткий путь — зашить вопросы, переходы и тексты на клиенте. Делов на пару дней — сделал и забыл.
Но если опросник перестаёт быть одноразовой анкетой, а становится частью живого продукта, начинается веселье: сегодня нужно поменять текст, завтра — картинку, потом порядок вопросов, потом ветвление, потом — похожий сценарий для другой группы пользователей.
Каждая правка текста, картинки, порядка вопросов или маршрута снова уезжает в релизный цикл web, iOS и Android. А синхронизировать такие изменения между тремя платформами намного сложнее, чем кажется на старте.
По некоторым косвенным признакам мы понимали, что с этой анкетой всё будет именно так, поэтому в качестве альтернативы мы выбрали путь backend-driven UI, когда клиент показывает поддерживаемые типы экранов, а backend управляет сценарием: текстами, изображениями, порядком шагов, переходами и состоянием прохождения.
Ниже расскажу почему мы пошли в backend-driven подход, где он действительно помог, а где показал явные ограничения и мог выбить нас из сроков.
Дисклеймер. Все названия систем, endpoint-ы, поля, бизнес-условия и архитектурные детали в статье изменены. Примеры ниже показывают принцип проектирования, а не конкретную внутреннюю реализацию. |
Простая форма или будущий фреймворк
Как уже писал, самый простой вариант очевиден — зашить вопросы на фронте. Клиент знает порядок экранов, показывает их один за другим, собирает ответы и в конце отправляет результат. Для маленькой анкеты это нормальный путь. Особенно если она точно одноразовая и её не планируют развивать.
Но у нас были признаки, что история может быстро вырасти. В дизайне был не просто текст вопроса и радиокнопки. Были:
welcome-анимация,
заголовок и пояснение у каждого вопроса,
картинки у вариантов ответа,
кнопки «Назад» и «Далее»,
progress bar,
финальный экран,
вариант с раскрывающимся блоком прямо внутри вопроса.
Последний пункт особенно хорошо отрезвляет. Если по выбору одного варианта внутри текущего экрана раскрывается дополнительный список, это уже не просто «вопрос — ответ — следующий вопрос», это поведение. А если завтра таких развилок станет больше? А если разные ответы начнут вести в разные ветки? А если один путь должен быть коротким, а другой уточняющий длиннее?
В этот момент опросник перестаёт быть похожим на Google-форму. Он становится полноценным сценарием.

Почему мы не хотели зашивать это на три клиента
Логика на клиенте? Значит изменение начинает ехать через разные релизные циклы web, Android и iOS. Даже если команды синхронизируются, пользователи всё равно обновляются не одновременно.
В итоге можно получить неприятную картину: в web уже новая формулировка вопроса, Android ждёт ближайшего релиза, а часть пользователей iOS ещё какое-то время видит старую версию. Для обычного баннера это может быть терпимо, а для опросника, ответ которого могут использоваться в связанных сценариях аналитики или персонализации — проблема.
Нам хотелось избежать ситуации, где один и тот же пользовательский путь существует в трёх разных версиях только потому, что платформы обновились в разное время.
Поэтому мы решили усложнить себе жизнь на старте: сделать не набор экранов на клиенте, а backend-driven механизм. Фронт умеет показывать определенные типы экранов, а backend управляет сценарием, порядком вопросов, переходами и состоянием прохождения.
Да, это не магия «без релизов вообще». Но всё равно отличная попытка убрать из клиентских релизов то, что можно описать как сценарий: тексты, картинки, порядок шагов, маршруты и правила переходов.
А теперь предлагаю детальнее разобрать «грабли», которые мы обнаружили при проработке такой реализации.
Грабли № 1. Клиент легко становится владельцем маршрута
Что плохого в том, чтобы отдать клиенту весь сценарий сразу:
{ "steps": [ { "code": "question-1" }, { "code": "question-2" }, { "code": "question-3" } ] }
Для линейной анкеты это удобно. Клиент получил массив, ходит по нему вперёд-назад и в конце отправляет результат.
Но как только появляются ветвления это превращается в огромный JSON, который используется клиентом только на половину: клиенту нужно знать, какой вопрос показать после конкретного ответа, какие шаги пропустить, что делать при возврате назад и какие ответы считать актуальными.
Мы не хотели, чтобы приложение знало весь маршрут. Маршрут должен жить там, где есть версия сценария, состояние прохождения и правила переходов, то есть на backend.
Поэтому клиент получает только текущее состояние интерфейса. Он показывает экран, отправляет действие пользователя и ждёт следующий currentStep.
Как разделили ответственность?
В итоге у нас получилось три слоя
№1. Клиент
Клиент — это рендерер. Он умеет показывать заранее поддержанные типы экранов:
приветственный экран,
вопрос с одним вариантом ответа,
вопрос с несколькими вариантами ответа,
экран с раскрывающимся блоком,
финальный экран,
fallback, если сценарий недоступен или что‑то пошло не так.
Клиент не знает весь сценарий и не выбирает следующий вопрос. Он показывает то, что пришло в ответе, и отправляет действие пользователя обратно.
№2. BFF
Промежуточный слой адаптирует данные под конкретный канал. Например, может привести структуру к формату, который удобнее — web, iOS или Android, и добрать специфичный для канала контент
Здесь важно не дать gateway превратиться во второй движок сценария. Если часть маршрута начинает жить в этом слое, логика снова расползается.
№3. Scenario-engine
Этот слой управляет сценарием:
проверяет, доступен ли опросник,
выбирает текущий шаг,
сохраняет ответ,
рассчитывает следующий шаг,
обрабатывает возврат назад, и решает возможен ли такой переход,
завершает сценарий,
хранит состояние прохождения.
Для системного аналитика сложность здесь не в том, чтобы придумать поля JSON, а в том, чтобы провести границу: что решает клиент, что адаптирует bff, а что остаётся в движке сценария.

Грабли № 2. POST может не только сохранять, но и возвращать следующий экран
Мы прочертили границы ответственности каждого компонента. Как настроить их взаимодействие? На первый взгляд достаточно такого порядка:
GET start POST answer GET next POST answer GET next
Логика понятная: одним запросом сохраняем ответ, вторым получаем следующий вопрос. Но после ответа пользователя backend всё равно должен сохранить выбор, проверить состояние сценария и понять, какой шаг следующий. Значит, он уже может вернуть следующий экран, давайте упростим цепочку, например так:
GET start → intro + currentStep POST answer → save answer + next currentStep POST answer → save answer + next currentStep
Первым запросом мы узнаем о доступности опросника конкретному пользователю.
Если доступно, то сразу получаем и первый экран с приветственной анимацией и первый экран вопроса.
А как только пользователь отправил свой ответ на первый вопрос, фронт дергает POST, мы фиксируем выбор и в ответе фронту отправляем следующий экран вопроса.
Это не выглядит как большое архитектурное открытие, но на практике клиенту становится проще: он отправил действие и получил новое состояние интерфейса. Не нужно держать лишнее промежуточное состояние «ответ отправили, теперь отдельно запрашиваем следующий вопрос».

Как выглядит ответ backend
Ниже не реальный контракт, а упрощенный пример для понимания идеи. Если сценарий недоступен, backend отвечает коротко:
{ "flow": { "available": false } }
Клиент в этом случае ничего не показывает. Для пользователя это обычный запуск приложения без ошибок, модалок, пустых экранов и дёрганного интерфейса.
Если сценарий доступен, backend возвращает стартовый пакет: приветственный экран и первый вопрос.
{ "flow": { "available": true, "code": "setup-flow", "version": "1.0" }, "intro": { "type": "animation", "heading": "Давайте настроим сервис под вас", "displayDuration": 2500, "transition": { "type": "auto", "target": "currentStep" } }, "currentStep": { "code": "goal", "type": "single-choice", "heading": "Что хотите настроить в первую очередь?", "hint": "Выберите один вариант", "completionRate": 20, "navigation": { "back": false, "primaryAction": "Далее" }, "validation": { "min": 1, "max": 1, "message": "Выберите один вариант" }, "answers": [ { "code": "a1", "text": "Главный экран", "imageRef": "goal-dashboard" }, { "code": "a2", "text": "Уведомления", "imageRef": "goal-notifications" }, { "code": "a3", "text": "Подсказки", "imageRef": "goal-tips" } ] } }
В одном ответе уже есть всё, что нужно клиенту для старта: контент приветственного экрана, длительность показа этого intro, правило автоматического перехода, первый вопрос, варианты ответа, ссылки на изображения, прогресс, правила валидации выбора и состояние навигации.
После выбора клиент отправляет ответ:
{ "flow": { "code": "setup-flow", "version": "1.0" }, "answer": { "stepCode": "goal", "selected": ["a1"] } }
А в ответ получает следующий currentStep в той же модели.
{ "flow": { "available": true, "code": "setup-flow", "version": "1.0" }, "currentStep": { "code": "preferences", "type": "multi-choice", "heading": "Какие подсказки вам полезнее?", "hint": "Можно выбрать несколько вариантов", "completionRate": 45, "navigation": { "back": true, "primaryAction": "Далее" }, "validation": { "min": 1, "max": 3, "message": "Выберите хотя бы один вариант" }, "answers": [ { "code": "a1", "text": "По настройке сервиса", "imageRef": "tips-settings" }, { "code": "a2", "text": "По новым возможностям", "imageRef": "tips-new" }, { "code": "a3", "text": "По регулярным действиям", "imageRef": "tips-regular" } ] } }
Стартовый GET и последующий POST возвращают клиенту одну и ту же модель: «вот текущее состояние сценария, отрисуй его». Разница только в том, что перед ответом на POST backend ещё сохраняет выбор пользователя и рассчитывает следующий шаг.

Грабли № 3. Сеть может упасть в самый неудобный момент
В мобильном сценарии нельзя считать, что сеть всегда стабильна. Пользователь может нажать «Далее», приложение отправит ответ, а потом не получит ответ.
Ответ сохранился или нет? Нужно повторить запрос? А если повторить, не создадим ли дубль?
Для таких случаев нужна идемпотентная обработка повторной отправки. Если действие уже было принято, backend не должен создавать дубль. Он должен вернуть актуальное состояние сценария.
Клиент повторил последнее действие.
Backend проверил состояние.
Если ответ уже принят — вернул следующий шаг, если не принят — сохранил и вернул следующий шаг. Без этого можно получить неприятные эффекты: дубли ответов, повторный переход или неконсистентное состояние прохождения.
Грабли № 4. Опросник быстро превращается в огромный граф
Что обычно представляют, когда слышат слово «опросник»? Фиксированный список вопросов, который пользователь проходит сверху вниз. Никакой вариативности, в этом сценарии неважно что именно ты ответишь, список вопросов уже ограничен и отрисован.
В backend‑driven сценарии маршрут может быть другим — следующий может экран зависеть от выбранного ответа, а часть вопросов может вообще не показываться.
Похоже на дерево маршрутов, где после каждого ответа backend рассчитывает, какой экран вернуть следующим и выбирает нужную ветку динамически.

На схеме видно, что сценарий нелинейный. После первого вопроса пользователь может попасть в разные ветки: пройти через «Вопрос 2», сразу перейти к «Вопросу 3» или пропустить часть шагов и оказаться на «Вопросе 4». Дальше ветки могут расходиться ещё сильнее: один маршрут приведёт к «Вопросу 5» и сразу к финальному экрану, другой — к «Вопросу 6», затем к «Вопросу 9», третий — к «Вопросам 8» и 10.
Для клиента это всё ещё выглядит одинаково: он показывает текущий экран и отправляет выбранный ответ. Карту переходов хранит и рассчитывает backend.
Внутри конфигурации это может быть описано как набор правил маршрутизации:
{ "step": "goal", "routing": [ { "answer": "a1", "next": "preferences" }, { "answer": "a2", "next": "notifications" }, { "answer": "a3", "next": "final-settings" } ] }
Важно, что эта карта переходов не уезжает на клиент целиком. Клиент отправляет выбранный ответ, а backend возвращает уже рассчитанный currentStep.
Грабли № 5. Progress bar в дереве начинает скакать
В линейном сценарии прогресс считать просто: пройденные шаги делим на общее количество шагов.
В дереве маршрутов всё сложнее. Один пользователь может пройти короткую ветку и быстро дойти до финала, другой уйдёт в более длинный маршрут. Если при этом пользователь вернётся назад и изменит ответ, то длина его маршрута может измениться в процессе прохождения.
Из-за этого progress bar начинает вести себя неожиданно: он может быстро расти на короткой ветке, а потом откатываться или скакать при переходе в другую ветку. Технически система работает правильно, но выглядит это странно.
Мы сделали для себя простой вывод: в сложных ветвящихся сценариях прогресс лучше проектировать не как математически точный процент, а как понятный индикатор этапа. А сами деревья лучше не раздувать без необходимости.
Понимаем, что вариантов слишком много и они отличаются по количеству шагов, значит оставляем самое необходимое для бизнес контекста, а остальное убираем или выносим в отдельный сценарий.
Грабли № 6. Версии сценариев нельзя смешивать
Ещё один вопрос, который лучше решить заранее: что делать, если сценарий изменился, пока пользователь уже начал его проходить?
Выбрали простой принцип: пользователь допроходит ту версию сценария, с которой стартовал.
Пользователь «A» начал v1, значит завершает v1.
Вышла v2.
Пользователь «B» стартует позже, значит получает v2.
Это кажется мелочью, пока не начинаешь думать о данных. Без версионирования ответы из разных версий быстро смешиваются, и потом сложно понять, на какие именно вопросы отвечал пользователь.
Грабли № 7. Состояние прохождения важнее, чем кажется
Сценарий не всегда заканчивается финальным экраном: пользователь может пройти опрос до конца, отказаться от прохождения, уйти на середине, потерять сеть или вернуться позже.
Поэтому нужно хранить не только ответы, но и состояние прохождения. Упрощённо вот так:

Без явного статуса легко попасть в странные ситуации: показать один и тот же опрос повторно, потерять незавершённое прохождение или отправить в аналитику неполные данные как финальные.
Если пользователь явно не завершил сценарий и не отказался, система не должна бесконечно держать его в подвешенном состоянии. Такое прохождение переводится в отдельный технический статус, а этот же сценарий больше не навязывается пользователю повторно.
Грабли № 8. Фича не зависит от релизов (нет)
Самая опасная фраза в BDUI фичах — «теперь всё можно менять без релиза». Нет, не всё.
Backend‑driven UI позволяет быстро менять то, что уже описано в возможностях клиента. Но если клиент не умеет отрисовывать новый тип поведения, backend не сможет научить его этому одной конфигурацией.
Тип изменения |
Нужен клиентский релиз?
|
|---|---|
Текст |
Нет |
Картинка |
Нет |
Порядок шагов |
Нет |
Маршрут между поддерживаемыми экранами |
Нет |
Новый опросник на существующих типах экранов |
Нет |
Запуск на новую группу пользователей |
Нет |
Новый тип экрана, поведения или новый визуальный параметр |
Да |
Мы почувствовали это на практике. В какой-то момент понадобилось поменять визуальное свойство одного элемента. Для пользователя это выглядело как маленькая правка. Но параметра в контракте не было, а значит клиент не знал, что с ним делать.
Это хороший урок: фреймворк развивается постепенно. Заранее предусмотреть все возможные изменения невозможно, да и не нужно. Первая версия должна закрывать основные сценарии, а не пытаться стать универсальным конструктором всего.
Backend-driven UI ускоряет изменения сценария, но не учит клиент новым компонентам автоматически.

Что получилось?
Главный результат — мы убрали клиентский релиз из большинства изменений опросника.
Теперь правка текста, картинки, порядка вопросов или маршрута не начинается с синхронизации web, iOS и Android. Если изменение укладывается в уже поддержанные типы экранов, команда обновляет серверный сценарий, проверяет отображение и включает его для нужной группы пользователей.
Web, iOS и Android получают один и тот же сценарий из общего backend‑слоя.
Команда занимается развитием фреймворка, а не постоянной правкой контента.
Новые версии сценариев можно запускать без разъезда платформ.
Ответы собираются по единой логике и могут сразу использоваться в связанных сценариях аналитики и персонализации.
Backend‑driven UI не делает разработку бесплатной. Он меняет её характер: меньше точечных клиентских доработок, больше работы над общим механизмом.
Чек-лист: где я бы держал фокус в такой задаче с первого дня
В самом начале проработки своей будущей фичи на BDUI рекомендую пройтись по следующим вопросам, а получив ответы сможете отлично проработать логику и корнер-кейсы, уменьшив количество граблей, на которые наступите в самый неожиданный момент.
1. Кто владеет маршрутом?
Если маршрут знает клиент, он быстро начинает становиться владельцем сценария. Для линейной формы это нормально, для ветвящегося backend‑driven сценария — нет.
2. Что считается состоянием прохождения?
Нужно заранее описать статусы: сценарий активен, завершён, отклонён, завис или истёк по времени.
3. Как работает повторный POST?
Мобильная сеть бывает нестабильной, особенно сейчас. Если клиент повторяет последнее действие, то backend должен корректно вернуть актуальное состояние и не создать дубль.
4. Как версионируется сценарий?
Пользователь должен допроходить ту версию, с которой начал. Иначе ответы из разных версий быстро смешаются и потеряют ценность.
5. Как устроено ветвление?
Не каждое дерево стоит делать объемным. Чем больше веток, тем сложнее progress, тестирование, аналитика и логичность пользовательского интерфейса.
6. Что клиент уже умеет отображать?
BDUI работает в границах поддерживаемых компонентов. Если нового поведения нет в контракте и реализации клиента, одной конфигурации не хватит. Продумывайте пределы фронта заранее, но не уходите в излишне глубокую проработку, пытаясь перезаложиться особенно на первом этапе фичи.
7. Какие метрики нужны?
Важно видеть где пользователи отваливаются, фронт должен четко понимать где это произошло - на приветственном экране, на конкретном вопросе, после ошибки валидации или при отправке ответа. Без этого сценарий быстро становится чёрным ящиком.
Вывод
Мы начинали с задачи на несколько экранов: welcome, вопросы, ответы и финал. Можно было бы сделать работу быстро и просто, зашив всё на клиенте.
Но тогда каждая следующая правка снова превращалась бы в синхронизацию web, iOS и Android. Поэтому мы выбрали более сложный путь в начале, но снизили монотонность работ в будущем — сделали сценарий управляемым с backend.
Это не избавило нас от фронтовой разработки полностью. Так не бывает. Но теперь стало проще разделять изменения на два типа.
Если нужно поменять текст, картинку, порядок вопросов или маршрут между уже поддержанными экранами — это обновление сценария.
Если нужен новый тип поведения или новый компонент — это развитие клиента.
Для меня в этом и есть главная польза backend‑driven подхода: он не обещает магию «без релизов вообще», но даёт понятную границу между настройкой сценария и полноценной разработкой.
Подписывайтесь на Телеграм-канал Alfa Digital — там рассказываем о работе в IT, делимся новостями, анонсами митапов и квартирников, рассказываем о технологиях, делимся советами наших экспертов, вакансиями и стажировками, иногда шутим.
По BDUI и SDUI у нас есть большой цикл статей:
Читайте также:
Комментарии (3)

Denis_AA
20.05.2026 11:44Спасибо за статью! Отличный разбор реальных кейсов, а не просто абстрактной теории. Побольше бы таких честных ретроспектив про «грабли» на реальных проектах. Утащил в закладки, автору респект и законный плюс в карму!
lynikol
Спасибо за статью, хороший кейс!
Подскажитие, а что вы делали с ответом, если пользователь нажал на кнопку "назад" в ветвящемся сценарии? Если поменялся ответ, то прошлые из другой ветки удалялись / перезаписывались?
zubbkovv Автор
Такой корнер кейс можно решить через высокий приоритет актуальной ветки: старые ответы не удаляются физически, потому что они всё ещё могут быть полезны для аналитики, но после изменения маршрута backend считает основной последнюю ветку прохождения и использует самые свежие ответы как актуальное состояние сценария (несмотря на то, что пользователь может не завершить опрос, свежая ветка ценнее)