Если у вас одна платформа, фронтенд работает стабильно и предсказуемо. Но стоит добавить десктоп, мобильное приложение, PWA, и простая логика превращается в гору if-ов.
Меня зовут Денис Кондратьев, я фронтенд-разработчик Точка Банк. В статье расскажу, как мы интегрировали наш мессенджер в браузер, Electron, Capacitor и PWA и сократили время адаптации новых фич в три раза.
С чего всё началось
У нас в Точка Банке есть корпоративный чат — типичный мессенджер с базовым набором функций. Там можно отправлять сообщения, пересылать файлы, получать уведомления, добавлять статусы пользователей. Первая версия работала в браузере и выглядела довольно просто.
Для создания уведомлений мы использовали классическое браузерное API. Так как платформа была одна, её поведение было абсолютно предсказуемым. Проблемы начались позже, когда продакт-менеджеры поставили задачу сделать десктопную версию приложения.
Что пошло не так
Чтобы создать приложение, мы решили завернуть фронтенд в Electron — тогда это было самое популярное решение. Оказалось, что:
уведомления в Electron работают не так, как в браузере;
жизненный цикл приложения другой;
работа с файлами устроена иначе.
Так в коде начали появляться первые ветвления.
if (isElectron) { // используем Electron Notification API new Notification(title, { body } ) ; } else { // браузерный Notification API new window.Notification(title, { body } ) ; }
Конечно, один if нельзя назвать архитектурным кризисом. Но когда следом мы решили сделать мобильную версию на Capacitor, количество if-ов стало расти в геометрической прогрессии.
if (isElectron) { electronAPI.showNotification(title, { body } ) ; } if (isMobile) { capacitorAPI.showNotification( { title, body } ); }
Больше всего пострадали уведомления. Если в браузере это был просто вызов API, то для мобильных пушей нам пришлось развернуть целый слой новой инфраструктуры:
токены устройства;
firebase/APNs;
подписка на сервере.
Последней каплей стал PWA, который пришлось делать в 2022 году, когда Точка Банк попал под санкции, и наш корпоративный аккаунт в Apple заблокировали.
Функции переписывались по много раз, их нужно было адаптировать для браузера, отдельно проверять в Electron, потом снова переписывать для мобильного webview и PWA с их собственным жизненным циклом и системой уведомлений. В какой-то момент стало очевидно, что проблема уже не в конкретной платформе, а в общем подходе.

К чему мы пришли
Так мы оказались в ситуации, где у нас есть:
четыре разные платформы;
n-ное количество фичей;
число платформенных проверок, которое фактически умножалось на количество этих фичей.
Я построил граф зависимостей проекта, и он выглядел как спагетти: каждая фича была обвешана условиями под каждую платформу.

Но ключевая проблема была не в количестве кода, а в его структуре:
Логика оказалась размазана по проекту: один и тот же функционал реализовывался по-разному в разных местах.
Дебаг превратился в квест: если что-то сломалось на одной платформе, нужно было проверить, не сломалось ли на других. Любое изменение тянуло за собой каскад проверок.
Было сложно выкатывать фичи: три дня из пяти уходили на адаптацию под разные платформы, два дня — непосредственно на реализацию бизнес-логики.
Мы поняли, что большая часть времени тратится не на создание продукта, а на борьбу с инфраструктурой.
Наше решение
Чтобы навести порядок, мы решили спрятать работу с платформами в одну коробку — чтобы снаружи был удобный интерфейс, а вся магия происходила внутри.
Шаг 1. Реализация браузера
Мы начали с создания IApp — это контракт, который предоставляет бизнес-логике работу с функционалом платформ: показ уведомлений, сохранение файлов и т. д. Теперь бизнес-логика оперировала только этим интерфейсом и не знала, как реализованы методы на конкретной платформе.
export interface IApp { showNotification(title: string, body: string): void; saveFiles(file: Blob, filename: string): void; onAppStateChange(callback: (isActive: boolean) => void): void; }
Шаг 2. Наследование Electron
Дальше появились конкретные реализации: сначала для браузера, потом — для десктопа.
export class web implements IApp { showNotification(title: string, body: string) { new Notification(title, { body } ) ; } saveFileAs(file: Blob, filename: string) { const a = document.createElement("a"); a.href = URL.createObjectURL(file); a.download = filename; a.click(); } onAppStateChange(callback: (isActive: boolean) => void) { document.addEvenListener("visibilitychange", () => { callback(document.visibilityState === "visible") ; } ) ; } }
Так как Electron — это не полностью отдельная платформа, а тот же браузер с нативной обвязкой, то часть логики решили наследовать от веба (WebApp). Переопределили только методы, которые отличаются: например, работа с файлами, уведомления.
export class web implements IApp { showNotification(title: string, body: string) { new Notification(title, { body } ) ; } saveFileAs(file: Blob, filename: string) { const a = document.createElement("a"); a.href = URL.createObjectURL(file); a.download = filename; a.click(); } onAppStateChange(callback: (isActive: boolean) => void) { document.addEvenListener("visibilitychange", () => { callback(document.visibilityState === "visible") ; } ) ; } } class DesktopApp extends Web { // только отличия override showNotification(title: string, body: string) electronAPI.showNotification(title, { body } ) ; } override onAppStateChange(callback) { electronTransport.onAppStateChange?.(callback); } }
Шаг 3. Сокрытие платформы через AppFabric
Чтобы бизнес-логика не зависела от конкретной платформы, мы применили паттерн Фабрика. Так появилась функция AppFabric: она возвращает готовый объект, соответствующий интерфейсу IApp. Это и есть наша «волшебная коробочка», где происходит работа с платформами.
export const appFabric = (): IApp => { if (isDesktop( ) ) { return new DesktopApp(); } // Фронт запущен в браузере return new Web(); };
Шаг 4. Мобильные пуш-уведомления
С мобильной версией (MobileApp) поступили аналогично — унаследовались от WebApp, переопределив только нужные методы.
class MobileApp extends Web { // уведомления – in-app override showNotification(title: string, body: string) { CapacitorAPI.showNotification( { title, body } ) ; } // жизненный цикл – мобильный override onAppStateChange(callback) { CapacitorAPI.onAppStateChange( (state) => { callback(state.isActive) ; } ) ; } }
Главная проблема — push-уведомления, которых нет в вебе. Для них нужны уникальные методы:
инициализация push;
отправка токена на сервер.
Если добавить их только в MobileApp, бизнес-логика их не увидит. Если добавить в IApp, то TypeScript заставит реализовать их и в WebApp. В итоге мы добавили их в WebApp и столкнулись с протечкой: теперь веб знал о методах, которых у него нет, но которые есть в MobileApp.
class web implements IApp { // Браузер знает про мобильные пуши? initPushNotifications() {} sendTokenToServer(_: string) {} // Настоящие методы Web showWebNotification(notification) { this.notificationsService.showNotification(notification) ; } saveFileAs(file: Blob, filename: string) { const a = document.createElement("a"); a.href = URL.createObjectURL(file); a.download = filename; a.click(); } }
Шаг 5. Спасительный полиморфизм
Чтобы изолировать платформы друг от друга, использовали абстрактный класс. Между интерфейсом IApp и WebApp создали прослойку в виде abstract class App и в нём реализовали два типа методов:
Абстрактные (обязательные): должны быть реализованы в каждой платформе.
Неабстрактные (опциональные): существуют только там, где нужны.
Теперь WebApp наследовался непосредственно от абстрактного класса, а не IApp, и реализовывал только нужные методы.
export abstract class App implements IApp { // Abstract – обязательные // Не реализуешь – не скомпилируется abstract showNotification(title: string, body: string): void; abstract saveFileAs(file: Blob, filename: string): void; // Noop – опциональные // не нужны – ничего не делает initPushNotifications() {} sendTokenToServer(token: string) {} }
Что в итоге получилось
Финальная архитектура выглядит так:
Интерфейс (IApp) ↓ Абстрактный класс (AbstractApp) ↓ **Классы реализаций конкретных платформ **(WebApp→DesktopApp→MobileApp→PwApp) ↑ **AppFabric **
Всего мы использовали три паттерна:
Фабрика — скрывает от бизнес-логики, какой именно класс создаётся, и просто возвращает готовый интерфейс.
Полиморфизм — позволяет каждой платформе иметь свою реализацию, при этом общий контракт остаётся единым.
Адаптер — приводит специфичное API каждой платформы к единому интерфейсу, с которым работает бизнес-логика.
Теперь платформы стали изолированы друг от друга, а бизнес-логика независимой.

Что не так в нашем решении
На первый взгляд, подход выглядит почти идеально, но без компромиссов не обошлось. Мы выделили три основных ограничения.
Онбординг: основная сложность — в непривычной модели разработки. Большинство фронтендеров используют функциональный подход. Для них архитектура с классами, наследованием и абстракциями может показаться перегруженной и неочевидной.
Методы-пустышки: в абстрактном классе есть опциональные методы, которые на некоторых платформах не делают ничего. Это может сбивать разработчиков с толку. Мы сознательно выбрали сделать их пустыми, чтобы не портить пользовательский опыт уведомлениями об ошибках.
«Матрёшка»: Electron, Capacitor и PWA — это по сути WebView с дополнительной обёрткой. Из-за этого появляются методы-дублеры, например doUpdateWeb и doUpdateApp. Приложение работает месяцами без перезапуска, и нам нужно уметь принудительно обновлять фронтенд и нативную часть.
В итоге решение, которое мы создали, не избавляет от всех сложностей, но переводит их в управляемую зону.
Немного итогов
Благодаря этому подходу мы сократили время адаптации новых фич с трёх дней до одного, а общее время разработки — примерно в два раза.
Из этого кейса можно сделать несколько простых выводов:
Если в проекте начинает расти количество if-ов — это почти всегда сигнал к изменению архитектуры, а не локальная проблема.
Классические паттерны вроде Фабрики, Адаптера и Полиморфизма никуда не делись и по-прежнему работают, просто мы отвыкли их применять.
Сам подход оказался шире, чем задача с платформами. Его также можно использовать для SSR, IAuth, IAnalytics и IStorage.
