
Привет, на связи снова я, Александр Мищенко, тимлид отдела по платформенной разработке в Профи.ру.
В прошлых статьях я уже рассказал:
— Как переехали на React Native всей командой
В этот раз поделюсь ещё одной историей трансформации — как мы перешли на монорепозиторий.
Расскажу, как к этому пришли, почему не стали ударяться в микрофронтенды и как строили архитектуру внутри.
Как всё было раньше
Когда у нас под каждый продукт был отдельный язык разработки, всё выглядело архитектурно просто. Под каждое приложение, веб, мобильный, бэк-офис, — отдельный репозиторий И всё это нормально работало, пока каждая команда жила в своём стеке.
Но с появлением React Native стало ясно: между вебом и нативом очень много общего кода. Значит, с этим можно что-то сделать. Тогда мы сделали третий репозиторий — общий. Там лежали UI-компоненты, утилиты, API.
Кода становилось всё больше, и мы хотели его тоже переиспользовать. Появились отдельные репозитории общих частей продуктов Профи.ру, для бэкофиса, отдельный репозиторий для библиотек фронтенда и бэкенда.
А ещё был легаси монолит, где лежал веб Профи.ру — тот, который постепенно переезжал на Next.js.
В итоге члены команды потихоньку перестали понимать, куда класть код, кто за что отвечает и какую версию где обновлять.
Каждый раз, когда нужно было внести правку в общую часть, приходилось вручную обновлять зависимости и во всех остальных репозиториях. Релизы превращались в цепочку из десяти шагов вместо двух.

Тогда мы поняли, что разработчики тратят уйму времени на рутину. Нужно сделать их процессы удобнее, а релизы быстрее. Но как?
Из чего выбирали
Мы рассматривали три пути: монолит, микрофронтенды и монорепозиторий.
Краткая справка:
Монолит — это когда всё живёт в одном репозитории, без границ �� дополнительных разграничений.
Из плюсов: просто и быстро, так как коммитишь в одно и то же место, и все нужные модули уже доступны без установки.
Минусы: чем больше проект, тем труднее его собирать. Любое изменение требует пересборки всего, а ошибка в одном месте может положить весь проект.
Когда-то такой формат использовали крупные компании: Твиттер, Atlassian и даже Google.
Микрофронтенды — противоположность монолиту.
Каждая команда делает своё мини-приложение: корзину, каталог, профиль. Всё это разворачивается отдельно и сливается воедино уже в браузере пользователя.
Плюсы: независимые релизы и свобода стеков. Одни пишут на Vue, другие на React. И у каждой команды свой пайплайн.
Минусы: дубли зависимостей, большой уровень раздробленности кода и тяжёлая поддержка в продакшене.
Но архитектура всё равно популярная, на ней двигаются Amazon, Dell и ещё очень много имён из бигтеха.
Монорепозиторий — это, можно сказать, попытка сделать гибрид. Всё ещё одно хранилище кода, но внутри он разделён на пакеты — отдельные мини-проекты со своими зависимостями и правилами.
Плюсы: можно обновлять, тестировать и собирать только нужные части, а не всё целиком. И при этом сохранить возможность запускать проект, поддерживать его и тестировать в одном месте. Можно гибко связывать и отвязывать модули друг от друга — меняя релизный цикл.
Минусы… Расскажем ниже в статье, потому что именно этот подход мы и выбрали. И как плюсы, так и минусы познавали на собственном опыте.
Как устроен наш монорепозиторий
Внутри он выглядит примерно так:
/services /backoffice-service /profi-service /uikit-storybook /apps /profi-web /profi-rn /backoffice-web /backoffice-rn /widgets /auth-widget /chat-widget /features /order-card /chat-actions /user-geo /app-review /entities /order-entity /chat-entity /files /profile /shared /uikit-rn /uikit-web /keyboard /map /icons
Каждый пакет внутри верхнего слоя живёт отдельно. У него свой package.json, свои зависимости и точка входа.
Это позволяет нам работать с модулями как с независимыми библиотеками, но без публикаций в npm — всё собирается внутри.
В корне проекта лежит файл pnpm-workspace.yaml, который объединяет пакеты:
packages: - services/**/* - apps/**/* - widgets/**/* - features/**/* - entities/**/* - shared/**/*
Пакетный менеджер
И тут вы можете заметить, что мы используем pnpm. Рассказываю, почему именно его, а не другие менеджеры.
Сначала мы использовали yarn. Первое время он нас устраивал. Но когда репозиторий разросся, установка зависимостей стала занимать минуты вместо секунд. Мы даже пытались ускорить процесс — формировали сами кэш, чтобы не скачивать их каждый раз, но это не решало основную проблему: yarn всё равно тратил много времени на установку.
Мы пробовали обновиться до новых версий, меняли конфигурации, писали в сообщество — но скорость не становилась быстрее. Вывод такой: нам кажется, yarn не подходит для больших монорепозиториев и проектов, где сотни пакетов со сложной структурой. Но он точно хорош там, где количество пакетов исчисляется десятками.
Основная сложность была в том, как yarn обращается с зависимостями. Он поднимает их «наверх» — так называемый hoisting. Например, если у нас три модуля, и каждому нужен React, yarn решает не хранить три копии, а оставить одну общую в корне репозитория. В React Native так нельзя делать.
Почему: RN собирается через инструменты iOS и Android. И в процессе сборки библиотеки должны быть рядом с проектом. Если нужной библиотеки там нет, сборка падает.
Из-за этого нам приходилось конфигурировать yarn и отключать hoisting для React Native приложений, чтобы всё заработало. Так появились дубли и огромные папки с модулями, а время установки только увеличилось.
И тогда мы решили уйти на pnpm: он решал обе проблемы сразу. После перехода установка зависимостей стала занимать 10–15 секунд вместо семи-десяти минут. А ещё у pnpm активное сообщество: обновления выходят часто, и проблемы решаются быстро. Если читали наш текст про RN, то знаете, как это спасительно для разработчиков :)
Правила и зависимости
В этом пункте перечислю несколько принципиальных моментов, которые есть в нашей архитектуре.
1. Изоляция пакетов
Каждый модуль в репозитории — это независимая библиотека со своим package.json. Она решает, что из неё можно импортировать, а что считается внутренним.
Например, в виджете чатов это выглядит так:
{ "name": "@mono/chat-widget", "main": "./src/index.ts", "exports": { ".": "./src/index.ts", "./web": "./src/index.web.ts", "./native": "./src/index.native.ts" }, "main": "src/index.ts", "peerDependencies": { "react": "^18.2.0", "@mono/file-entity": "workspace:^" } }
Как это работает:
Общий typescript-контракт в index.ts позволяет переиспользовать TS.
exports перечисляет только разрешённые точки входа (@mono/chat-widget/web, @mono/chat-widget/native).
С peerDependencies pnpm доставляет нужные пакеты в node_modules.
В итоге никто не сможет импортировать @mono/chat-widget/src/notify.ts, даже случайно. А на слоях services и apps необходимо будет поставить нужные версии зависимостей.
2. Правила импортов и проверки
Мы взяли за основу feature-sliced design и адаптировали под наш монорепозиторий. И создавали архитектуру сами, так как в уже готовой документации не было связки FSD + монорепа. Поэтому разбирались, как обычно, сами.
У нас есть уровни:
services — нативные приложения и их зависимости (iOS, Android);
applications — сами веб- и мобильные приложения, где фиксируются JS-зависимости;
pages — страницы;
widgets — крупные блоки интерфейса, например карточка заказа или чат;
features — связи между доменами, вроде «добавить в избранное» или «работа с геоданными пользователя»;
entities — сущности бизнес-логики, часто их еще называют «домены» (пользователь, заказ, специалист);
shared — общее для всех: UI-кит, утилиты, API-клиент.
Каждый слой может зависеть только от нижних. Фича может использовать сущности и shared, но не может подниматься к pages.
И у нас это, как и везде, контролируется автоматически. Линтер и специальные плагины проверяют:
— порядок зависимостей между слоями;
— запрет относительных импортов между пакетами (../.. и т. п.);
— доступ только к тому, что явно указано в exports.
Чтобы было понятнее, пример.
Допустим, мы делаем блок «Создать заявку». Он состоит из формы (widget), логики отправки (feature) и модели заявки (entity). Все они живут в разных пакетах, но вместе собираются на странице.
Если кому-то из них понадобятся кнопки или модальные окна — их можно взять из shared-слоя. Такой подход помогает писать чистый и предсказуемый код, избегая лишней связанности.
3. Единые зависимости
Ещё одно правило касается библиотек, которые должны существовать в единственном экземпляре — это касается React, роутера, стейт-менеджеров и т. д.
Чтобы не появлялось дублирующих версий, у нас введено разделение зависимостей:
На нижних уровнях (shared, entities, features, widgets, pages) такие библиотеки указываются только как peerDependencies. Буквально это значит: «Мне нужен React 19, но устанавливать я его, конечно, не буду…»
На верхних уровнях (applications, services) версии фиксируются в обычных dependencies.
Это особенно важно, потому что React и похожие инструменты устроены как синглтоны — объекты, у которых в приложении должен быть только один экземпляр. Если в проекте окажутся две разные версии, всё может сломаться.
Эти правила проверяются и локально, и на CI. Если кто-то случайно добавил другую общую библиотеку не туда, проверки перед мерджем не пройдут, а разработчик сразу увидит, где конкретно нарушено правило.
Со стейт-менеджером сложнее: наши старые проекты держались на Redux. И как вы сами понимаете, это глобальный стор, который связывал всё воедино, он концептуально не подходил под FSD.
Мы пробовали подстроиться, но только плодили костыли.
Тогда решили полностью отказаться от единого хранилища и перейти на локальные мини-хранилища. В качестве core-решения выбрали Jotai. Теперь каждая фича хранит данные у себя, общается с остальными только через чётко описанные интерфейсы и не влияет на соседние части.
Как жить с таким масштабом
Когда всё заработало, появился новый вопрос: как держать в порядке трёхсотпакетный монорепозиторий?

Возникли новые челенджи, с которыми нужно что-то делать.
1. Конфликты и очереди
Каждый день — десятки коммитов от разных разработчиков. Ветки пересекаются, кто-то вливается в мастер раньше, кто-то застревает на проверках.
2. Долгие тесты
Маленькие правки проходят быстро, а большие, особенно те, что затрагивают общий код вроде UI-кита, запускают весь цикл проверок и зависают на часы. Пока тесты идут, кто-то уже влился в мастер — и всё приходится начинать заново, так как контекст изменился.
3. Нестабильные интеграционные тесты
Иногда сборка падает не из-за ошибки, а потому что завис симулятор или вообще свет выключили, а с ним вместе и интернет отвалился. Разработчик видит, что билд упал, и тратит время, чтобы понять, что именно пошло не так.
Часть проблем удалось решить. Мы написали инструмент, который автоматически подливает мастер, сверяет контракты и, если всё ок, может даже замёржить ветку. Это не решило всё, но избавило от постоянного ожидания — теперь можно поставить задачу в очередь и уйти пить чай. Почти без рисков.
Остальное пока в процессе. Мы экспериментируем с merge-train-механизмами, параллелим тесты и постепенно доводим инфраструктуру до состояния, когда масштаб не мешает скорости.
Что мы поняли за это время
Автоматизация всё-таки окупается.
Любой линтер, скрипт или тест, который ловит ошибку до релиза, экономит часы всей команде. Чем меньше ручных действий в пайплайнах, тем устойчивее система. Особенно когда у тебя десятки коммитов в день.
Нужно контролировать уровень связанности.
Когда код можно писать где угодно — зависимость растёт. Поэтому мы выбрали FSD и чётко его придерживаемся. Также нам помогает единый кодстайл и атомарные стейт-менеджеры.
Всё это помогает нам ограничивать ответственность модулей, не даёт зависимостям расползаться и упрощает масштабирование.
Ошибаться — это нормально.
Мы пробовали разные инструменты и многое для нас не работало, но по итогу всё-таки нашли свою золотую середину:
pnpm,
управление зависимостями через peerDeps,
разделение структуры по архитектурным слоям в FSD
Jotai в качестве стейт-менеджера.
Не бойтесь экспериментировать, больше читайте про архитектуру, исследуйте разные технологии и, конечно, общайтесь с комьюнити — и тогда вы обязательно ��айдете подход, который хорошо сработает для ваших задач.
Что думаете? Какая у вас архитектура на проектах? Давайте обсуждать и дискутировать, как и всегда :)
