Может быть, не всё legacy, чему больше года, но у нас и правда был запущенный случай: несколько лет в режиме стартапа над проектом работали разные команды, начиная от аутсорса, заканчивая маленькими инхаус-группами. Мы жили в парадигме «работает — не трогай», но всему есть предел, и в конце-концов техдолг стал слишком сильно блокировать развитие.
Эта статья не о том, как выделить ресурсы на рефакторинг, и не о процессах. Мы фокусируемся на логике принятия технических решений, рассказываем, что выбрали и почему, и приводим примеры реализации современной гибкой и масштабируемой фронтенд-архитектуры.

Историю эволюции онлайн-кинотеатра PREMIER, перехода от startup-like архитектуры к enterprise-решению, в котором только над фронтендом трудится более 25 специалистов, для вас рассказывают: Дмитрий Борцов, руководитель управления разработки клиентских интерфейсов, и Автушенко Андрей, техлид команды веб-разработки.
Причины масштабного рефакторинга
Видеоплатформа PREMIER была запущена в 2018 г. В 2022-м кодовой базе было 5 лет, но не только по этой причине требовался рефакторинг. За время существования проекта сменилось несколько команд разработки, каждая приносила свои инструменты, подходы и стиль. В результате накопился стандартный набор проблем, характерный для проекта, над которым работают в режиме решения исключительно бизнес-задач без возможности выделять ресурсы на рефакторинг и улучшения. Что это были за проблемы?
1. Отсутствие полноценной дизайн-системы
У дизайнеров не было единого подхода к работе и старые макеты обновлялись под новые фичи только при большой необходимости. Из-за отсутствия атомарности в проекте за несколько лет накопилось большое количество невалидных компонентов.

Выше скрины двухлетней давности из папки компонентов, причём в каждой из папок могло находиться еще с десяток компонентов или подпапок. Отсутствовало логическое разделение по бизнес-сущностям, в этой каше было очень трудно найти и понять, какой компонент можно переиспользовать или расширить. Токенов дизайн-системы, конечно, тоже ещё не было — разработчики добавляли свои переменные, цвета, стили, всё это дублировались без попытки унифицировать.
2. Дублирование логики и спагетти-код
В legacy-проекте «очень удобно» исправлять баги. Например: ловишь баг с модалкой; думаешь, обычное дело, за пару стори-пойнтов разберусь; ищешь, где вызывается функция — находишь в одном из 70+ миксинов, который используется в 100+ файлах; пытаешься предсказать, какой из них взорвётся первым после правки.
Сложности с масштабированием в свою очередь порождают переусложнённую логику, дублирование компонентов и спагетти-код.
3. Нет TypeScript и линтинга
О TypeScript мечтала вся команда, но его внедрение требовало ресурсов, о которых ещё только предстояло договориться с бизнесом.
Дела с линтингом тоже обстояли так себе: Prettier для общего форматирования и на этом всё. Никакой вам типизации, никакой проверки кода — только чистая вера в разработчика!
4. Рост time-to-market и техдолга
Весь этот хаос вынуждал нас с одной стороны жертвовать качеством ради скорости. С другой — пропускная способность команды и скорость разработки тоже катастрофически снижалась, потому что даже самая простая задача могла выстрелить в самом неожиданном месте.
С точки зрения бизнеса эти проблемы блокировали развитие. Накопившееся к 2022-му legacy мешало решению бизнес-задач:
запуску продуктовых фичей;
стабильному показателю time-to-market;
найму в команду.
Рефакторинг и переработка архитектурной основы стало необходимостью. Мы взялись за изменения, чтобы на дистанции года-двух получить прирост производительности команды, стабильности и масштабируемости продукта.
План архитектурных изменений
Все решения по изменениям мы принимали исходя из технических и бизнес-требований, чтобы нововведения не завели нас в новый тупик, а обеспечили искомое развитие.
Upstream технологического стека: мигрируем с Vue 2 на Vue 3 ради производительности и возможностей новой версии фреймворка; переходим на Nuxt 3, чтобы получить SSR из коробки и отказаться от собственного нестабильного и мешающего SEO-оптимизации решения для генерации html.
Масштабируемость: реализуем гибкую архитектуру, адаптируемую для любых бизнесовых и технических потребностей, с помощью паттерна Composition Root.
Стабильность: внедряем инструменты разработки, призванные автоматически повысить качество кода, такие как TypeScript, Stylelint, Prettier, Eslint + Tslint, Commitlint; договариваемся с дизайнерами внедрить атомарную дизайн-систему, чтобы не плодить лишние компоненты и логику.
Time-to-market и другие ключевые метрики будем улучшать за счёт масштабируемой архитектуры, которая позволит разработчикам легко переиспользовать код и быстро собирать новые фичи.
Framework agnostic подход в core-решениях поможет нам: в случае чего справиться с внешними факторами, заменить Vue на любой альтернативный фреймворк, проще нанимать и онбордить новых сотрудников. Для реализации ядра, не зависящего от фреймворка, понадобится два глобальных хранилища: одно для бизнесовых данных, другое — для визуальных, влияющих на UX пользователя.
Реализация изменений
Подробно разберём, как мы воплощали в реальность каждый пункт нашего плана и переходили к новой архитектуре и с какими трудностями столкнулись по дороге.
Nuxt 3 на базе Vue 3
Когда мы начинали переезд, Nuxt 3 ещё находился в бете — это был риск, но нас подкупал его потенциал и обещанные фичи.
Сюрпризов получилось больше, чем мы рассчитывали. Обновления выходили чуть ли не каждую неделю и при этом частенько что-нибудь ломали: то баг в свежем релизе, то только внедрили текущую версию, а половина функциональности в следующей уже не работает. Иногда мы просто упирались в проблемы фреймворка. Приходилось разбираться с его кодом, отправлять issue разработчикам, изучать MR, надеясь, что исправление скоро вольют в основную ветку.

Несмотря на все приключения, нам удалось настроить всё так, что Nuxt 3 даже в такой «молодой» версии стал мощной основой для нашего продукта с его крутыми фичами. А заодно прокачали свою адаптивность.

DI и Composition Root
Добиться масштабируемости мы решили с помощью DIP с IoC-контейнером и реализацией Composition Root — чтобы фичи полетели и при этом не ломали соседние части системы. Но давайте обо всём по порядку.
Dependency Inversion Principle (DIP) — принцип, согласно которому классы должны зависеть от абстракций, а не от конкретных реализаций. Такой подход помогает сделать более устойчивую к изменениям систему и позволяет легко заменять модули, не нарушая высокоуровневую логику. При соблюдении DIP классы напрямую зависят от интерфейса, а реализация передаётся через Dependency Injection.
Dependency Injection (DI) — механизм передачи зависимостей в классы, может быть нескольких видов:
constructor injection — для обязательных зависимостей;
property injection — для необязательных зависимостей;
method injection — для зависимостей, требуемых только в конкретных методах.
Эти подходы помогают разделять ответственность и четко указывать, какие зависимости критически важны для работы модуля, а какие — опциональны.
Inversion of Control (IoC) — это подход, при котором управление зависимостями передается внешнему контейнеру или фреймворку.
Для управления зависимостями мы выбрали Inversify — библиотеку, которая предоставляет контейнер, который в свою очередь управляет созданием и жизненным циклом объектов. Кроме того, что централизация зависимостей упрощает тестирование и замену модулей, она позволяет реализовать Composition Root.
Composition Root — это единая точка конфигурации, место в приложении, где происходит конфигурирование и разрешение всех зависимостей. Именно здесь создаются экземпляры высокоуровневых классов, необходимых для работы приложения.
Рассмотрим пример реализации этих принципов в коде. Ниже — входная точка в виде плагина Nuxt 3, в которой инициализируется контейнер и регистрируются ключевые модули: транспортный слой со всеми его бэкендами, адаптерами и модулями; сторы; модули вспомогательных функций, логгеры и прочее.

В такой контейнер можно положить всё что душе угодно, что впоследствии будет использоваться из этого контейнера.
Далее нужно описать уникальные имена каждого модуля для правильной работы Inversify:

Ниже пример применения DI и декораторов из Inversify: App
— корень контейнера, в который кладем транспортный слой, фильтры, хранилища.

Пример одного из бэкендов, в котором регистрируются все его модули, содержащие необходимые методы API:

Inversify предоставляет два декоратора:
injectable
говорит о том, что класс будет доступен в контейнере для дальнейшего внедрения;inject
— что вы внедряете конкретный модуль из контейнера (в примере выше — через конструктор).
Разберём подробнее отдельный модуль одного из бэкендов:

Что здесь происходит:
принимаем в модуль адаптер для запросов, модуль для работы с локальным хранилищем и модуль стора;
реализуем метод отвязки устройства logout и другие методы, которые относятся непосредственно к этому модулю.
Пример использования сущности из контейнера:

Есть $app
, в котором есть транспортный слой, в котором есть несколько бэкендов, в котором есть его модули, в котором есть конкретные методы — этакая матрёшка. В данном примере мы получаем подписки и обновляем их в глобальном хранилище.
Feature Sliced Design
Мы не стали ограничиваться базовой структурой папок, которую обеспечивает Nuxt, и серьёзно занялись структурой компонентов — иначе с большой кодовой базой есть риск снова быстро свалиться в мешанину, какая у нас была в прошлой версии.
Методология Feature Sliced Design (FSD) помогает структурировать код и держать его масштабируемым и модульным. Подробно с FSD можно познакомиться здесь, а в этой статье пройдемся только по основным принципам разделения кода и связке с Nuxt.

В FSD используются три уровня абстракции, первый из них — слои:
App — инфраструктурный слой, который содержит глобальные настройки приложения и роутинг.
Pages — здесь находятся страницы приложения, которые собираются из виджетов и фичей.
Widgets — большие самодостаточные части функциональности, обычно реализующие целые пользовательские сценарии.
Features — основные блоки, которые реализуют конкретные бизнес-функции. Фичи могут включать в себя как UI-компоненты, так и логику работы с данными.
Entities — основные объекты, которые используются в разных частях системы: модели данных, их обработка и другие бизнес-логические единицы.
Shared — слой, где находятся общие компоненты и утилиты, которые могут быть использованы в любом месте приложения.
Второй уровень абстракции FSF — слайсы. Слайсы — это разделение функциональности продукта на более мелкие, самостоятельные части. Это позволяет разработчикам работать над отдельными частями продукта параллельно, повышая скорость и гибкость разработки. Конкретное деление слоёв на слайсы зависит от проекта. Слои App и Shared не имеют слайсов, а сразу состоят из сегментов.
Третий уровень FSD — сегменты — группируют код по его назначению. Обычно они состоят из следующих сущностей:
ui — всё, что связано с отображением: UI-компоненты, стили и т.д.
api — взаимодействие с бэкендом: функции запросов, типы данных, мапперы.
model — всё, что относится к модели данных: схемы валидации, интерфейсы, хранилища, бизнес-логика.
Так изменилась структура проекта после внедрения FSD.

NB: важно не забыть переопределить директории Nuxt в конфиге, чтобы подружить фреймворк и методологию FSD. В фреймворках Nuxt и Next папка pages отвечает за роутинг, а в FSD — за сегмент интерфейса. Поэтому нужно разделить pages и роутинг.
Вот как это сделали мы: ниже слева на картинке в папке routes находятся страницы Nuxt, которые просто рендерят компонент и добавляют необходимую мета-информацию, а справа — сам сегмент интерфейса, который собирается из различных виджетов и фичей.

Framework Agnostic
Несмотря на то, что в этой статье мы рассматриваем примеры из Nuxt, в новой архитектуре мы хотели придерживаться подхода, в котором реализация ядра проекта не привязана к особенностям фреймворка. Принцип framework agnostic призван дать страховку от внешних рисков и упростить онбординг в проект. Но требует иметь два глобальных хранилища, чтобы максимально изолировать глобальные бизнесовые данные и данные, которые нужны для отображения UI, и в будущем переиспользовать ядро как независимую сущность, нанизывая свой визуал со своим фреймворком или UIKit.
В качестве хранилищ мы выбрали:
MobX для бизнес-данных. Mobx легко интегрируется с React, Vue и другими фреймворками, то есть его можно использовать в любом проекте с минимальными изменениями.
Pinia — для визуальных данных, так как это стандарт во Vue/Nuxt-проектах.

На иллюстрации слева пример бизнес-данных, связанных с профилем пользователя, в MobX. Также к бизнес-данным можно отнести, например: продукты, подписки, авторизацию и прочее. Справа — пример Pinia и данных отображения: слайдер с флагами, от которых будет зависеть поведение интерфейса.
Линтинг
Одними из главных целей всего этого рефакторинга для нас были качество кода и скорость разработки. Чтобы поддерживать выбранные стандарты на длинной дистанции, нужны удобные инструменты, которые естественным образом уведут от фривольного стиля написания кода и уберут бесполезные споры о табах и пробелах.
Чтобы разработчики были сконцентрированы на скорости и не отвлекались на синтаксические холивары, внедрили линтеры:
Prettier — для автоматического форматирования кода;
ESLint — для проверки JavaScript, чтобы унифицировать подходы к написанию JS-кода и предотвратить потенциальные проблемы на этапе разработки.
Stylelint обеспечивает консистентность стилей, соблюдение командой вёрстки по БЭМ-методологии, валидирует нейминг классов согласно дизайн-системе и подсвечивает неэффективные практики в CSS.
Запускаем линтеры и валидируем код с помощью Husky — инструмента, который позволяет интегрироваться в цепочку git-коммитов. Мы настроили хуки, которые проверяют код на соответствие правилам перед каждым коммитом:

Если код не соответствует принятым стандартам, husky просто не даёт закоммитить правки.

Автоматические прогоны линтеров на каждый коммит помогают нам сохранять качество кодовой базы на высоком уровне.
С помощью утилиты Commitizen мы наводим порядок в релизах, побуждая команды писать осмысленные коммиты по commit convention.

Красивые и понятные сообщения в истории git это:
удобство командной работы и онбординга в проект;
инструмент для анализа изменений;
возможность автоматически генерировать changelog.
TypeScript
Об этом мечтала вся команда, и наконец-то мы смогли перейти на TypeScript. Поскольку мы стремимся получить максимум выгоды от внедрения любого инструмента, то не стали останавливаться только на валидации входных параметров в функциях, а типизируем всё что можно: от компонентов и их пропсов до транспортного слоя и бизнес-логики, включая параметры роутинга, конфигурации и глобальные утилиты.
Однако TypeScript — это статический анализатор кода. Он не умеет проверять данные в рантайме, а большинство данных мы получаем именно с бэкенда. Поэтому усиливаем валидацию типов с помощью инструмента Runtypes, который позволяет описать ожидаемую структуру ответа, проверять данные внешних API и ещё на этапе разработки видеть, соблюдаются ли контракты со стороны бэкенда.
Пример рантайм-валидации: в методе logout, который уже встречался выше, добавляем передачу данных в функцию validateRuntypes
.

Ниже в описании рантайм-схемы всё просто: импортируем типы из самой библиотеки 'runtypes'
, из получившегося рантайм-типа получаем обычный TypeScript-интерфейс, который в дальнейшем используем в методах.

Функция для валидации данных принимает схему и сравнивает с данными с бэкенда:

Если данные не совпадут с ожидаемыми, то ещё на этапе разработки будет warning.

Все эти инструменты в совокупности создают мощную экосистему автоматизации, которая гарантирует, что кодовая база соответствует принятым стандартам и содержится в порядке.
Теперь нужно убедиться, что код соответствует ожиданиям и бизнес-требованиям.
Тестирование
Используем разные типы тестов, каждый из которых решает свою задачу:
TypeScript и статические анализаторы — фундамент.
Unit-тесты — тестирование отдельных функций или модулей. Мы используем Vitest (основанный на Vite) и по нашему мнению это лучший фреймворк для тестирования фронтенда.
Скриншотные тесты — подскажут, не «поплыл» ли интерфейс, если в коде компонента что-то поменялось. Отслеживаем визуальные изменения с помощью Storybook и Loki.
Интеграционные тесты — проверяем взаимодействие нескольких модулей в связке.
E2E-тесты — чтобы проверить весь процесс от начала до конца в реальных условиях. С данной задачей отлично справляется Playwright (к сожалению, в Vitest браузерное тестирование ещё сыровато).
Все эти типы тестирования складываются у нас в пирамиду тестирования: чем проще обеспечить тестирование, тем его больше, а ресурсозатратные способы используем выборочно.

Принцип пирамиды тестирования:
Не пишем тесты на то, что проверяется типизацией.
Много unit-тестов и скриншотных — они быстро пишутся и проверяют целый модуль.
Интеграционных тестов меньше.
E2E-тестами покрываем ключевые бизнес-процессы, например, авторизацию или оплату подписки. Они хоть и мощные, но медленные, так как требуют поднятия реальной среды, работы браузера и реальных запросов, да ещё и не всегда могут сразу показать, где именно произошла ошибка.
Простые тесты, как и линтеры, запускаются в CI на ранних этапа пайплайна для дополнительной проверки кода.
Трекаем актуальный code coverage на этапе создания MR в GitLab.

Более того, собираем информацию о текущем coverage в XML c помощью cobertura и передаём в GitLab Artifacts. Дальше GitLab может распарсить этот артефакт, подсветить строчки в MR и наглядно показать, где требуются тесты.
Результаты и планы
Как изменились проектные метрики после внедрения новой архитектуры:
Time-to-market сократился примерно в 2 раза благодаря хорошо организованной архитектуре и четкому разделению всего кода на зоны ответственности.
Как следствие, при том же составе команды сильно вырос объем поставки ценности.
После обновления стека технологий поток кандидатов в найме стал заметно больше. И самое классное — теперь на собеседованиях можно обсуждать архитектуру, методологии и принципы вроде SOLID прямо на уровне нашего проекта.
Новые разработчики быстрее вникают в проект. Теперь для первичного онбординга достаточно чтения вводной документации по архитектуре и style-гайдам, где собраны ссылки на методологии и основные технологии.
Показатель | Было | Стало |
Среднее время реализации фичи | 13,49 дней | 6,58 дней |
Capacity команды | ~80 stp | ~170 stp |
Онбординг | 1 месяц | 5 дней |
Конечно, не планируем останавливаться на достигнутом. Мы заложили хороший фундамент, который поможет нам развивать приложение PREMIER и внедрять новые технологии. В ближайших планах по обновлению архитектуры у нас:
переход на Nuxt 4;
внедрение кодогенерации — чтобы автоматически генерировать интерфейсы, а возможно и весь транспортный слой;
Покрытие тестами → 100%.
Если статья была для вас полезна и вам интересна тема карьеры в IT и frontend-разработке, подписывайтесь на telegram-каналы авторов: Дмитрия Борцова, руководителя клиентской разработки PREMIER, и Андрея Автушенко, техлида команды веб-разработки PREMIER.
Также подписывайтесь на Смотри за IT, если хотите знать больше о создании медиасервисов: там инженеры Цифровых активов «Газпром-Медиа Холдинга» таких, как PREMIER, RUTUBE и Yappy делятся своим опытом и тонкостями разработки видеоплатформ.