Я Angular-разработчик и один пишу фронтенд своего сервиса «НеБалкон». Сначала был только сайт, но довольно быстро понадоблись личный кабинет, Telegram Mini App и приложения для iOS и Android.
Требования формировал я сам и сам же их постонно менял. Волатильность была приличная: стартап всё-таки. Ищешь PMF, крутишь идею, меняешь УТП, находишь решение получше — и опять идёшь править формы, статусы и пользовательский флоу.
На весь фронтенд было примерно 3 месяца. В такой ситуации заводить отдельный Flutter-проект или, тем более, писать два native-клиента на Kotlin и Swift мне показалось плохой идеей. Я бы сначала изучал новый для меня стек, потом переносил в него уже написанную логику, а после каждой своей новой ‘гениальной’ идеи синхронно правил несколько реализаций.
Остаться на Angular было проще лично мне, но плюс не только в этом. Когда код общий, формы, модели, валидация и багфиксы не начинают жить своей жизнью на каждой платформе. Поменял сценарий один раз — он поменялся в вебе, TMA и мобильных приложениях.
Так сайт и кабинет остались на Angular, TMA получил тот же CSR-клиент, а iOS и Android — Angular внутри Capacitor. Сейчас все 16 экранов кабинета используют одну реализацию, а доля общего TypeScript-кода получилась около 95%.
Конечно, «общий код» не значит «вообще никаких отличий». Кнопка «Пополнить» везде одна, а способов вернуться с платёжной страницы у меня получилось три. Но до этого ещё дойдём.
Что вообще делает «НеБалкон». Кратко для контекста
Если коротко, «НеБалкон» — сервис хранения с забором и возвратом вещей. Клиент оформляет заявку, мы забираем вещи, храним их и возвращаем, когда они снова понадобятся.
В кабинете клиент видит свои вещи, управляет заявками, адресами и кошельком. То есть это не страница с парой кнопок, а основная точка взаимодействия с сервисом. Поэтому хотелось дать к нему доступ и из браузера, и из Telegram, и из обычного приложения.
На момент написания статьи публично работает сайт, а версии для Telegram, iOS и Android существуют как рабочие закрытые сборки.
Всё появлялось по очереди
Я не сел в первый день писать сразу четыре клиента. Сначала сделал сайт, затем кабинет в браузере, после него TMA, а уже потом iOS и Android.

С каждым новым клиентом я добавлял оболочку, но не переписывал профиль, список вещей, заявки и кошелёк. Иначе на очередной смене УТП пришлось бы открывать четыре проекта и вспоминать, где какой экран уже успел разъехаться.
Монорепозиторий: два Angular-приложения, общий кабинет и Go API
Всё это живёт в Nx-монорепозитории. Внутри два Angular-приложения с разными задачами.
apps/web — публичный сайт и кабинет в браузере. Публичные страницы рендерятся на сервере и затем гидратируются на клиенте. /dashboard/**, включая кабинет, работает в CSR. SSR я добавлял ради SEO, а не потому, что очень хотелось усложнить себе сборку.
apps/mobile — обычное CSR-приложение. Его сборка уезжает в Capacitor-проекты для iOS и Android, а тот же Angular-проект разворачивается как Telegram Mini App.
Сам кабинет лежит не внутри приложений, а в libs/cabinet-client и libs/cabinet-core. В apps/* остались верхнеуровневые маршруты, layout и выбор нужных адаптеров.
Backend у меня отдельный: Go, Gin, PostgreSQL и REST API. Все клиенты ходят в один API. Какой именно клиент прислал запрос, я передаю отдельно и использую только там, где это реально нужно. Например, чтобы вернуть пользователя после оплаты.

SSR сразу добавил одно правило: общий код не может считать, что window и document всегда на месте. Всё браузерное я оставляю в приложении или прячу за адаптером.
Где заканчивается «общий код»
«Одна кодовая база» — фраза скользкая. Иногда это один репозиторий, внутри которого всё равно лежат три версии одной кнопки. У меня общий именно код экранов и сценариев, а не только папка в Git.
В libs/cabinet-client я вынес:
-
shell клиентского кабинета;
-
главная страница и задачи;
-
профиль и адреса;
-
список вещей на хранении;
-
заявки на забор и возврат;
-
кошелёк и история операций;
-
тарифы и помощь;
-
маршруты, facades, формы и прикладная логика этих экранов.
Одна фабрика маршрутов лениво подключает все эти страницы. Web и mobile монтируют её под разными базовыми путями, условно /web-cabinet и /app-cabinet. Shell и навигация могут отличаться, сама страница — нет.
Это не значит, что приложение должно выглядеть везде пиксель в пиксель. Shell может менять заголовок, нижнюю навигацию, кнопку «Назад» и safe area. Тащить desktop sidebar на iPhone только ради переиспользования кода было бы уже странно.
Коротко про i18n
Язык сейчас один — русский. Но i18n всё равно пригодился как способ не свалить все тексты в один огромный JSON. Тексты сайта лежат в apps/web, кабинета — в libs/cabinet-client, общих компонентов — в libs/ui-kit. При запуске приложение подмешивает только нужные наборы, поэтому mobile не тащит за собой тексты лендинга.
Что именно делает Capacitor
Для iOS и Android у меня Capacitor. Не Cordova — я сам первое время называл его по старой памяти неправильно. Capacitor берёт Angular-сборку, кладёт её в native shell и даёт JavaScript-мост к возможностям устройства.
Внутри не ссылка на удалённый сайт: HTML, JavaScript и стили входят в саму мобильную сборку. Поэтому изменения фронтенда всё равно проходят обычный релиз в store. В native-проектах остаются permissions, URL schemes, associated domains и конфигурация плагинов.
Из плагинов мне понадобились Camera для фотографий, Barcode Scanner для QR, Browser и App для внешних ссылок и deep links, плюс Dialog, Keyboard и Haptics.
При этом импортировать плагины прямо в общий кабинет нельзя. Иначе libs/cabinet-client быстро зарастёт проверками if ios, if telegram, if browser, и весь мой «единый стек» закончится большим условным оператором.
Как я не пустил платформенный код в общий кабинет
Главная задача тут была не дать общим компонентам узнать о Capacitor, Telegram SDK и браузерных API. Компоненту всё равно, откуда пришла фотография. Ему нужен File[], а не лекция про Camera plugin.
Поэтому в cabinet-core я завёл DI-токены:
-
CABINET_DIALOG; -
CABINET_PHOTO_SOURCE; -
CABINET_QR_SCAN; -
CABINET_EXTERNAL_URL; -
CABINET_PATHS; -
CABINET_PLATFORM.
Общий код просит «выбрать фотографии» или «открыть URL». А будет это <input type="file">, Capacitor Camera, window.location или Telegram WebApp API — решает адаптер.
Вот упрощённый пример с фотографиями:
export interface PhotoSourceAdapter { pickPhotos(options?: { maxCount?: number }): Promise<File[]>;}export const CABINET_PHOTO_SOURCE = new InjectionToken<PhotoSourceAdapter>("CABINET_PHOTO_SOURCE");// Общий код кабинетаconst photoSource = inject(CABINET_PHOTO_SOURCE);const files = await photoSource.pickPhotos({ maxCount: 1 });// apps/web{ provide: CABINET_PHOTO_SOURCE, useValue: webPhotoSourceAdapter }// apps/mobile{ provide: CABINET_PHOTO_SOURCE, useFactory: () => createMobilePhotoSourceAdapter(inject(CameraService)),}

В web я подставляю CDK-диалоги, HTML file input, браузерный QR scanner и обычное открытие URL. В mobile — Capacitor Dialog, Camera, Barcode Scanner и Browser. TMA запускается с теми же mobile providers, а нужная Telegram-ветка выбирается уже внутри адаптера.
Так проверки платформы лежат в нескольких понятных местах, а не размазаны по компонентам. Новая среда всё равно потребует интеграции и тестирования, но сами фичи переписывать не нужно.
Telegram Mini App без отдельного frontend-проекта
Отдельное Angular-приложение для TMA я делать не стал. Telegram запускает тот же apps/mobile внутри своего WebView.
На старте я проверяю window.Telegram.WebApp, платформу и наличие initData, затем вызываю ready() и expand(). В запросе клиент передаёт необязательную подсказку о своей среде: web, native или mini-app.
Важный момент: эта подсказка не участвует в авторизации и не доказывает, что запрос реально пришёл из Telegram. Авторизация работает отдельно. Признак клиента нужен только для некритичного платформенного поведения, например для выбора способа возврата после оплаты.
Одна кнопка «Пополнить», три сценария возврата
С пополнением кошелька пришлось повозиться. Компонент запрашивает платёжную ссылку и открывает её — тут всё одинаково. А вот возвращать пользователя после оплаты приходится по-разному.
В web это обычный URL кабинета. В native-приложении — custom scheme и deep link, условно example-app://wallet/return. В TMA — HTTPS URL внутри домена Mini App: custom scheme там не поможет.

В native отдельная обёртка открывает браузер оплаты и временно сохраняет идентификатор операции. После закрытия браузерного окна приложение возвращает пользователя на условный /app/wallet?ref=<paymentRef>. В TMA платёжная форма открывается внутри WebView, а результат приходит по HTTPS redirect.
В итоге бизнес-операция одна. Разъехался только транспорт вокруг платёжной страницы — и это как раз нормальная работа для адаптера.

Capacitor не отменяет мобильную вёрстку
Наивно было бы завернуть сайт в WebView и считать задачу закрытой. Мобильный viewport всё равно живёт своей жизнью.
В полноэкранных layout я использую dvh, а не vh: видимая высота меняется вместе с browser chrome и клавиатурой. Отступы считаю через env(safe-area-inset-*), иначе кнопка легко уезжает под вырез или системную панель. В native события клавиатуры приходят через Capacitor Keyboard, в TMA и браузере есть fallback на visualViewport.
С модалками та же история. Текстовое подтверждение показываю через нативный Dialog, список действий — через Action Sheet, сложную форму — через Angular CDK Dialog. Засунуть всё в системный alert хочется ровно до момента, когда туда понадобились три поля, календарь и фотография.
А код точно общий?
Написать «95% кода общие» легко, поэтому я всё-таки посчитал SLOC. Срез сделан по состоянию на июль 2026 года.
Считал рабочий TypeScript без пустых строк и комментариев. Выкинул тесты, сгенерированые файлы, нативный и сторонний код, а также результаты сборки. Общий код учитывал один раз, оболочки и адаптеры — отдельно.
Для клиентского кабинета получилось:
-
libs/cabinet-client: 17 973 общих SLOC; -
web wrapper: 12 SLOC;
-
mobile wrapper для iOS, Android и TMA: 29 SLOC;
-
web adapters и interceptor: 299 SLOC;
-
mobile/TMA adapters: 648 SLOC.
Формула получилась такая:
17 973 / (17 973 + 12 + 29 + 299 + 648) = 94,8%
Важно: 94,8% относятся только к TypeScript-коду кабинета. Сюда не входят Xcode, Gradle, публикация в stores, общий deep-link routing и прочая обвязка приложения. Если считать только фичи и точки монтирования, вышло бы 99,8%, но эта цифра уже слишком красивая и мало что говорит о реальной стоимости.
Есть метрика попроще: все 16 экранов кабинета лежат в общей библиотеке. Отдельных копий страниц для web, iOS, Android и Telegram нет.
LOC, конечно, не измеряет сложность и не доказывает качество. Но на вопрос «сколько TypeScript мне пришлось бы поддерживать отдельно» отвечает вполне нормально.
Где пришлось заплатить
Главный плюс я уже описал: поменял поле в заявке или статус заказа один раз — изменение приехало во все клиенты. Для меня это ещё и меньше переключений контекста: один TypeScript, один router, один подход к DI и один набор инструментов.
Но бесплатным это решение не было.
WebView не становится native от хорошей архитектуры. Нативное приложение может быстрее запускаться, лучше выглядеть и точнее соблюдать привычки платформы. Если бы у меня были тяжёлая графика, сложные анимации или фоновые процессы, я бы уже смотрел в другую сторону.
Платформы всё равно нужно знать. Оплата, deep links, permissions, клавиатура, safe area и публикация в store никуда не делись. Общий Angular-код не освобождает от Xcode, Gradle и чтения документации Apple в самый неподходящий момент.
Границу общего кода приходится защищать. Стоит пару раз написать Capacitor.isNativePlatform() прямо в компоненте, и очень быстро таких проверок становится двадцать. Поэтому новые отличия я стараюсь сразу уносить в адаптеры.
UI всё равно компромиссный. Можно аккуратно адаптировать интерфейс, но полностью нативным он не станет. Для «НеБалкона» это нормально: у меня в основном формы, списки, карточки и API. Ради них содержать отдельные native-клиенты пока нет смысла.
Что я бы сделал так же
Если бы начинал ещё раз, оставил бы четыре решения:
-
Сначала общий кабинет, потом оболочки. Страницы и бизнес-сценарии должны жить в библиотеке, а не в первом приложении, которое случайно появилось раньше остальных.
-
Абстрагировать конкретную проблему. Не делать огромный
PlatformService, а заводить отдельные контракты для камеры, диалога, QR и внешних URL. -
Не врать себе красивым процентом. Адаптеры, deep links, тесты на устройствах и релизы в stores тоже стоят времени, даже если не попали в LOC общей фичи.
-
Подключать каналы по очереди. Сначала довести сценарий в web, потом нести его в TMA и native-оболочки. Так хотя бы понятно, что именно сломалось.
Для моего MVP такой подход сработал. Пока платформеные отличия помещаются в адаптерах, я остаюсь на одном стеке и не усложняю. Если проверки платформы начнут расползаться по компонентам и диктовать устройство самих фич — значит, пора пересматривать архитектуру или стек.
Если есть вопросы, нужна помощь с подобным решением у вас — напишите мне в LinkedIn или Telegram.
ссылка на оригинал статьи https://habr.com/ru/articles/1064680/