Модульная архитектура во фронтенде дает командам автономность: каждая пилит свой модуль и не ждет соседей. Потом появляется BFF, один на всех, и автономность начинает утекать обратно. Каждая новая ручка проходит через согласования, релизы приходится синхронизировать, узкое место просто переезжает с клиента на сервер.
Привет, Хабр! Меня зовут Маргарита Козырева, я — сеньор‑фронтенд в «Лаборатории Касперского», работаю в Kaspersky Security Center. В айти семь лет, значительная часть из них ушла на дизайн‑системы, которые я строила с нуля.
Мы решили сделать серверный слой таким же модульным, как клиент. В статье разбираю, из чего состоит платформа, что остается за командой, как модуль регистрируется в рантайме и какой ценой это дается.
Микрофронтенд верхнеуровнево
Базовая задача звучала так: изолировать разные предметные области, чтобы команды деплоились независимо друг от друга. Связанность падает, система собирается обратно через контракты и договоренности между командами. У каждого фронтенда свои зависимости, объединяет их хост — базовое приложение, в которое встраиваются остальные. Дальше внутри своего фронтенда можно выбирать технологии и стек, а платформенные задачи решать на уровне хоста.
В Kaspersky Security Center, нашей системе управления корпоративной защитой, единый хост связывает больше 20 микрофронтендов. В нашей парадигме они называются плагинами, но слова «микрофронтенд» и «модуль» привычнее, поэтому дальше я буду пользоваться ими.
Вот так выглядит хост без установленных плагинов.

На нем авторизация, маршрутизация, безопасность, подключение к серверу. Теперь добавим микрофронтенды:

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

Хост все это дело собирает в единый интерфейс.

Снизу, на сервере, картина похожая. Там тоже не один цельный кусок логики, а набор отдельных сервисов с доменным разделением: каждый отвечает за свою область и общается с соседями через контракты.
Фронтенд модульный, бэкенд разделен по доменам, между ними обычно остается общий серверный слой для интерфейса. Это и есть наш товарищ BFF.
Зачем нужен BFF
BFF соединяет интерфейс и доменные бэкенд‑сервисы, которые зачастую все еще живут как один большой монолит. Базовая причина его существования простая: клиенту не хватает данных из одного конкретного запроса. Но пользы в BFF сильно больше:
Собирает экран из нескольких источников. Домены нарезаны по своей внутренней логике: пользователи, события, корзины. При этом любой пользовательский сценарий пересекает сразу несколько доменов, и, чтобы собрать один экран, нужно сходить в несколько эндпоинтов. Без BFF вся интеграционная логика расползается по клиенту.
async function loadDashboard(userId: string) { const user = await api.users.getUser(userId) const orders = await api.orders.getByUser(userId) const billing = await api.billing.getBalance(userId) return { title: user.name, ordersCount: orders.total, balance: billing.amount } }
Держит бизнес‑логику и логику представления по разные стороны. Можно ведь не собирать данные на клиенте и научить доменный сервис сразу отдавать удобный ответ под экран. Но тогда доменный бэкенд начинает знать про конкретный UI: какие поля нужны вебу, какие действия нужны в мобилке, как назвать заголовок. Контракты сервисов разрастаются под каждого клиента.
async function getDashboardView(userId: string) { // Плох const user = await api.users.getById(userId) const orders = await api.orders.getByUser(userId) return { title: user.name, subtitle: `${orders.total} active orders`, webActions: buildWebActions(user), mobileActions: buildMobileActions(user)} }
Владеет пользовательским контекстом. Кто текущий пользователь, какая у него сессия, права, воркспейс. Подробно разберу это ниже.
Скрывает внутреннюю структуру бэкенда. Клиенту незачем знать, из каких сервисов собирается экран, какие у них адреса, версии, форматы ответов и как они связаны между собой. Вместо этого фронт получает один понятный контракт под свой пользовательский сценарий.
Помогает с авторизацией, потому что часто оказывается точкой входа в экосистему бэкенд‑сервисов. Эту фичу тоже раскрою дальше.
Важный момент: не весь трафик идет через BFF.

Трафик течет из клиента (браузеры, сайты) через Gateway, который его маршрутизирует, проксирует и балансирует. Дальше он делится на BFF и стримы событий. Маркетинговые стримы с рекламными данными, кликами, мониторингами текут мимо BFF, он их вообще никак не контролирует. Его зона ответственности — конкретные бизнес‑операции и бизнес‑данные, без которых система не работает.
При этом BFF хорошо масштабируется горизонтально: Gateway может балансировать нагрузку между десятками его экземпляров. Это возможно, потому что BFF у нас stateless и не хранит состояние.
BFF и Gateway на схемах стоят рядом, и их часто путают. Разница в том, что Gateway ничего не знает про UI. Он не понимает, какой экран открыт у пользователя и какие данные этому экрану нужны, он работает по правилам «вот этот путь сюда, вот этот туда». BFF стоит ближе к интерфейсу: понимает пользовательский сценарий, адаптирует бэкенд‑API под экран и прячет от клиента внутреннюю структуру сервисов.
Олдскульный BIF и современный
Поговорим немного про аналоги. Есть такое понятие, как BIF, backend in frontend. Им пользуются все, иногда даже не зная расшифровки. Я делю его на олдскульный и современный.
Олдскульный BIF родился где‑то в лохматых 2015–2016 годах из попыток справиться с хаосом на клиенте. Это паттерн, при котором интеграционная логика уезжает прямо в браузер.
У нас она в основном жила на AJAX‑запросах: клиент сам ходил в разные бэкенд‑API, получал данные, но повлиять на форму этих API чаще всего не мог. Поэтому агрегацию приходилось делать прямо в интерфейсе: функция дергает несколько запросов, группирует ответы, складывает результат в пресловутый Redux, модель или локальный стейт, и уже из этих данных рисуется интерфейс.
async function loadDashboard(userId: string) { const [user, orders, billing] = await Promise.all([ api.get(`/users/${userId}`), api.get(`/orders?userId=${userId}`), api.get(`/billing/${userId}`) ]) return { name: user.name, ordersCount: orders.total, balance: billing.amount } }
На маленьком примере это выглядит нормально, но подход быстро превращается в антипаттерн: клиент начинает знать слишком много про структуру бэкенда.
Современный BIF — это что‑то вроде Next или Nuxt. Внешне кажется, что код живет совсем рядом с клиентом: здесь собрали данные, здесь же отрендерили экран. Но технически это все еще BFF: код выполняется на сервере, ходит в бэкенд‑сервисы из внутренней сети и отдает браузеру уже подготовленный JSON или готовую страницу. По сути это серверный BFF, просто очень плотно интегрированный с фронтенд‑кодом.
export default async function DashboardPage(userId) { const user = await getUser(userId) const orders = await getOrders(userId) const balance = await getBalance(userId)return ( <Dashboard data={{ name: user.name, ordersCount: orders.total, balance: balance.amount, }} /> ) }
BFF решает ту же задачу интеграционной логики на сервере и не нагружает клиент. Агрегирует данные, адаптирует контракты под интерфейс, прячет внутреннюю структуру сервисов и дает клиенту узкий удобный API в понятной разработчику нотации. Фронтенд после этого работает с пользовательским сценарием, детали серверной интеграции его не касаются.
Для небольшого фронтенд‑приложения, где мало сценариев и экспериментов и нет разницы между мобилкой, десктопом и браузером, такая схема выглядит хорошо.
Что ломается при росте
Проблемы начинаются, когда фронтенд начинает существенно расти. Был хост и три модуля, потом стало пять, пятнадцать, пятьдесят…
Картина получается странная. Фронт состоит из кучи микрофронтендов с множеством команд поддержки, бэкенд состоит из большого количества микросервисов под отдельные задачи, а общий BFF превратился в бутылочное горлышко между ними. Все постоянно меняется: команды правят эндпоинты, перекладывают BFF целиком, растет хаос. Любое изменение приходится согласовывать, потому что оно проходит через общие интеграционные точки и может задеть соседние модули.
Постепенно размываются границы ответственности, становится сложнее понять, где заканчивается логика конкретной фичи и начинается платформенный слой. Дорожают релизы, откаты, тестирование, сопровождение. Даже локальная разработка оказывается намертво встроена в общий жизненный цикл. Команды теряют автономность, завязываются друг на друга и постоянно рискуют задеть соседей.
Особенно заметно это в on‑prem‑поставках, где продукт существует в разных конфигурациях: с разными лицензиями, возможностями, набором установленных модулей. Если BFF монолитный, то остается либо вырезать из него куски под конкретную поставку, либо тащить лишний код и обрастать условиями и фича‑флагами. Серверный слой подстраивается под каждую конфигурацию, хотя конкретному заказчику нужна только его конфигурация.
Модульный BFF
Если UI уже живет как набор модулей, логично зазеркалить этот подход на серверном слое.
Полностью отказываться от общего BFF мы не стали. Общий слой, он же хост BFF, никуда не исчезает, мы просто сделали его тоньше и четче по ответственности. Заставлять каждый модуль заново строить свою инфраструктуру тоже не нужно: хост работает как платформа и берет на себя пул обязательств. Доменная интеграция, связанная с конкретными плагинами и бэкенд‑сервисами, переезжает в BFF конкретных модулей, которые развивают и сопровождают независимые команды.

Конструкция получается такая: у BFF‑платформы есть модули, привязанные к своим плагинам. Команда владеет UI‑слоем и своим BFF‑модулем под этот UI. Внутри него она ходит в нужные бэкенд‑системы, агрегирует данные и собирает контракт под свой сценарий. Фронтенд и BFF замыкаются внутри одной команды: меньше согласований, быстрее решения, ниже риск задеть соседей. Фронтендеры теперь владеют личным кусочком агрегированного слоя.
Ответственность делится так:
Host BFF как платформа. Общий серверный слой, который берет на себя инфраструктуру выполнения фич, но не их доменную логику. В нем живут общие механизмы: авторизация, пользовательский контекст, транспорт, маршрутизация, подключение серверных модулей, доступ к общим API, логирование, трассировка, метрики.

BFF‑модуль команды. Серверный слой конкретного домена: логика, интеграции, контракт под UI, мапперы, агрегаторы.

Фронтенд‑модуль и его бэкенд‑модуль вместе образуют вертикальный модуль под конкретный пользовательский сценарий.
Хост при этом скрывает транспорт, маршрутизацию, брокеры сообщений (какие именно, абсолютно неважно), коннекторы, пользовательский контекст и прочие технические детали доставки вызовов. Важно, чтобы он при этом не превращал систему обратно в один большой бэкенд, где любой модуль может ходить куда угодно без ограничений.
Вот как идея выглядит на реальном модуле.
{ "plugin": { "id": "vulnerability_management", "version": "1.0.%REV%", "integration": { "routes": { "getter": "handlers.getRoutes", "prefix": "vulnerability-management" } } } }
В конфиге описано, что это вообще за модуль: уникальный идентификатор, версия, точки интеграции с платформой.
export const userApi = api.injectEndpoints({ endpoints: (build) => ({ getAllPermissions: build.query({ queryFn: (args) => createQueryFn( server.user.getAllPermissions )(args) }) }) })
На клиенте вызывается серверный метод, какой‑нибудь getAllPermissions, которому незачем знать про брокеры, коннекторы, очереди и внутреннюю маршрутизацию.
export type UserServiceSchema = { getAllPermissions: () => Promise<{ data: Permissions }> }
И контракт серверного API: по нему видно, какие методы модуль предоставляет и что они возвращают. Для разработчика это выглядит как обычное типизированное API; транспорт, маршрутизацию и связь с серверным модулем берет на себя хост BFF.
Жизненный цикл модуля
Чтобы код действительно стал частью платформы, команда собирает его в артефакт: клиентский бандл, серверная часть, config.json, схемы интеграции. Дальше артефакт деплоится независимо от основной платформы.
В рантайме хост подхватывает этот артефакт, загружает клиентский бандл, регистрирует его в клиентском рантайме и понимает, что фронтенд‑плагин теперь можно встроить в интерфейс. Следом подключается серверная часть модуля. Бэкенд‑плагин поднимается отдельно и сообщает хосту: «Я живой, все окей, вот мой конфиг, вот API, готов принимать вызовы». Ориентируемся мы в первую очередь именно на серверную часть.
Хост сохраняет эту информацию в registry и с этого момента знает, что модуль доступен и куда маршрутизировать вызовы. Только после этого в зарегистрированный сервер плагина начинает течь трафик.
Регистрация модуля в рантайме
Это важный момент, на нем я остановлюсь отдельно. На первый взгляд кажется, что для регистрации достаточно config.json: там уже есть ID, версия, точка входа, настройки, на билдтайме все как будто склеивается.
Но конфиг отвечает только на вопрос «какой модуль в принципе может существовать в системе». Модуль декларативно описывает себя: кто он, какая у него версия, какие настройки интеграции. Ответа на более важный вопрос там нет: живой ли этот модуль прямо сейчас, доступен ли он, готов ли обрабатывать запросы.
Поэтому нужен второй шаг. Чтобы хост понял, что модуль реально доступен, должна подняться его backend‑часть. Когда бэкенд‑плагин стартует и подключается к платформе, он делает вызов и сообщает хосту: «Я на месте».
const config = { id: 'cookie-module', version: '1.0.0', integration: { service: { hasServerFunctions: true } } } connector.announceConfig(config) connector.announceApi(['getCookies', 'setCookie'])
В коде выше задействован connector, адаптер над брокером сообщений. Углубляться в него не буду, но стоит понимать, что такой адаптер есть: он знает, в какую очередь отправить сообщение, как подписаться на ответы, как связать request‑response по идентификатору и что делать с тайм‑аутами.
// plugin-client.ts // Просто вызов метода своего модуля const assets = await pluginServerApi.getAssets() const vulns = await pluginServerApi.getVulnerabilities({
Благодаря этому на клиенте появляется способ вызвать серверную часть модуля, все остальное клиентский хост BFF делает автоматически и прячет от разработчиков. Глазами фронтендера вся эта сложная конструкция выглядит так: вызвал метод, получил данные, отрисовал UI.
Понятно, что под капотом это не прямой вызов функции. Платформа должна принять вызов, понять, из какого модуля он пришел, доставить его до нужной серверной части и вернуть ответ.
Пора развернуть магию и посмотреть на нее изнутри.
Декомпозируем магию
Если кратко: фронтенд‑плагин вызывает серверное API своего модуля, вызов проходит через клиентский хост, транспорт и BFF‑хост, затем доставляется в соответствующий backend‑плагин.

Если подробнее, то фронтенд‑плагины A и B обмениваются событиями и передают данные внутри клиентского рантайма через event bus (шина событий тянет на отдельную статью). Из шины вызов попадает в клиентский хост, который собирает модули, тот общается с серверной частью через socket.io.
Между клиентским хостом и BFF‑хостом лежит транспортный слой. Как именно он устроен, не так важно, у нас это сокеты. Важно, что нет множества REST‑ручек и методов.
Вызов попадает в BFF‑хост и входит в слой асинхронной доставки сообщений. На схеме это NATS, но с тем же успехом там может стоять NSQ или что‑то еще. Через этот слой сообщение доезжает до парных бэкенд‑плагинов A и B, они выполняют свою серверную логику и при необходимости обращаются к бэкенд‑сервисам.
Конкретные детали транспорта и очередей остаются внутренней реализацией. Важна сама цепочка: она помогает отделить клиентскую часть, платформенный слой и доменную серверную логику модуля.
{ "method": "getCookies", "params": { "userId": "42" }, "reqId": "req_12345" }
Общение у нас асинхронное, но сам вызов генерализован независимо от типа коммуникации. Идея в том, чтобы сделать одно универсальное сообщение, которое можно масштабировать и менять вместе с эволюцией схемы, вместо того чтобы плодить REST‑ручки. Для большого энтерпрайза REST не самый гибкий вариант: поддерживать его сложно, генерировать пачками неудобно, хотя вписать в концепцию BFF, конечно, можно.
Для генерализации подходит RPC, Remote Procedure Call. Глобально: создается объект, в котором сказано, какой метод нужно вызвать и какие параметры передать.
await api.getCookies({ userId })
Для разработчика это выглядит почти как обычный вызов метода.
{ moduleId: 'cookie-module', method: 'getCookies', params: { userId }, reqId: 'req_12345' }
Но под капотом — это универсальное сообщение: хост получает его, смотрит на moduleId и method, понимает, в какой бэкенд‑модуль доставить вызов, и передает дальше через брокер. Бэкенд‑плагин выполняет нужный handler и возвращает результат.
Весь трафик платформы течет ровно через две точки: клиент и хост.
// host.service.ts // После подключения Host становится центральной точкой // обработки вызовов. onRequest((request) => { if (isPlatformCall(request)) { return handlePlatformCall(request) } return callPluginApi(request) })
Он обеспечивает полный контроль, его можно логировать, мониторить, отслеживать и смотреть задержки.
Фичи модульного BFF
Пользовательский контекст и авторизация
Один из привычных и безопасных способов хранить авторизацию в браузере — куки. Для клиента это удобно: пользователь залогинился, браузер хранит сессию, запросы уходят с нужной авторизационной информацией.
Для бэкенд‑экосистемы модель работает хуже. Один пользовательский сценарий задевает несколько микросервисов. Если каждый из них будет отдельно ходить в сервис авторизации, чтобы по sessionId понять, кто пользователь, актуальна ли сессия и какие у него права, мы получим лишнюю нагрузку, дополнительные задержки и бесконечный DDoS собственного сервиса авторизации.
Авторизационный слой можно вынести в BFF. Он получает запрос с браузерной сессией, проверяет ее и обменивает сессионную авторизацию на привычный JWT. Во внутренние сервисы уходит уже нормализованный токен, который они читают без отдельного похода в авторизацию.
Заодно на уровне BFF появляется место, где собирается пользовательский контекст: кто пользователь, какая сессия, какие права, воркспейс, какие настройки важны для этого интерфейса. Дальше контекст прокидывается в бэкенд‑плагины и микросервисы в любом удобном виде.
Как модули общаются между собой
Есть соблазн разрешить одному модулю ходить в другой напрямую. Контракты же у всех есть, значит, модуль A может просто вызвать API модуля B. Так появляется скрытая зависимость между командами: одна поменяла контракт, вторая внезапно сломалась. Релизы начинают цепляться друг за друга, границы ответственности размываются.
Обмениваться данными модули, конечно, могут. У нас много технических, служебных модулей, и они так или иначе друг с другом общаются. Просто вместо прямого вызова в соседний модуль лучше использовать явный контракт: событие или общую шину.
// Модуль A: публикует событие, не зная, кто его слушает eventBus.publish('assets:selected', { assetIds }) // Модуль B: получает данные через общую шину let selectedAssetIds: string[] = [] // события не было -eventBus.subscribe('assets:selected', ({ assetIds }) => { selectedAssetIds = assetIds // данные пришли — обогащаем сценарий, не пришли — продолжаем работать renderDetails(selectedAssetIds) })
Если данные пришли, модуль обогащает свой сценарий. Если нет, продолжает работать без них. Система остается устойчивее, команды сохраняют границы, контракты и независимые релизные циклы.
Итоги
Вернусь к проблемам из начала статьи. В монолитном BFF у нас было много согласований: изменения проходили через общие интеграционные точки, с командами приходилось постоянно координироваться. Большому энтерпрайзу это неудобно.
Модульный BFF решает проблему: у каждого модуля свой изолированный контракт, границы строго определены. Мы не ходим лишний раз договариваться к соседям, риск их задеть минимальный, нужную доработку легко делаем внутри своей зоны ответственности. Понятно, где заканчивается логика модуля и начинается платформенный слой. Если модуль работает в рамках своего контракта, локальная правка с меньшей вероятностью сломает функциональность соседей. Релизы дешевеют, потому что модули можно динамически перевыкатывать без пересборки общего BFF‑хоста.
У подхода есть своя цена, бесплатно он не дается. Появляется больше инфраструктуры, правил совместимости, требований к наблюдаемости, контрактов. Все это нужно версионировать по API‑схемам и подчинять общим правилам сборки, а работа с API‑схемами требует дисциплины сильно выше обычной.
Плюс распределенный BFF плохо отвечает на вопрос «откуда что пришло». Логировать и трассировать сложнее, хотя и возможно.
Отдельная сложность ложится на платформенную команду: на ней клиентский хост, BFF‑хост и связь между ними. Когда вызов проходит через общий хост, коннектор, брокер и бэкенд‑плагин, без трассировки быстро перестаешь понимать, где он потерялся и где замедлился. Так что трейсинг, метрики и контроль здесь обязательная часть платформы.
Когда подход уместен. Если у вас небольшой продукт, одна‑две команды, мало интеграций, небольшой бэкенд‑ландшафт и никакой реальной модульности, все это не нужно. Один классический BFF дешевле и понятнее в сопровождении хотя бы потому, что в нем нет разных интеграционных схем.
Но системы склонны эволюционировать, расти и масштабироваться. Была одна команда, потом появилась вторая, третья, пятая. Растет реальная модульность на UI, темпы развития модулей расходятся, стоимость синхронизации через общий BFF становится заметной. В on‑prem‑поставках все почти всегда приходит к модульности на уровне общего слоя с разными конфигами, и вот там модульный BFF окупается.
То есть подход оправдан там, где уже есть реальная организационная и техническая потребность. Если вы — большой энтерпрайз, у которого модульного BFF все еще нет, стоит задуматься: для вас это должно быть стандартом де‑факто. Если UI уже плагинный, почему BFF все еще монолитный?
Для меня итог перехода такой: экспериментируем в модулях, стабилизируем в платформе. Все, что связано с конкретными пользовательскими сценариями, развивается внутри модуля. То, что становится общим и повторяющимся, уезжает в платформенный слой.

