27 нояб. 2025 г.·4 мин чтения

Границы TypeScript-монорепозитория для web, server и общего кода

Границы TypeScript-монорепозитория помогают командам делиться типами между web, server и shared-пакетами, не привязывая каждое изменение к одному релизу.

Границы TypeScript-монорепозитория для web, server и общего кода

Что идёт не так, когда границы размыты

Плохие границы в TypeScript-монорепозитории обычно начинаются с удобства. Одной команде нужен тип, она берёт его из другого пакета, а потом заодно тянет и хелпер, раз он уже там есть. Через неделю web-приложение импортирует серверный код для валидации, сервер импортирует общие UI-константы, и никто уже не может сказать, чем на самом деле владеет каждый пакет.

Сначала это кажется быстрым решением. Потом даже маленькие изменения начинают задевать всё вокруг.

Простое переименование в серверной модели может вызвать пересборку web-приложения, падение тестов в не связанных пакетах и долгий прогон CI для кода, который по сути не менялся. Кто-то обновляет общую утилиту для одного endpoint, и теперь трём командам нужно проверять, всё ли ещё работает в их части репозитория. Дело не в самом переименовании. Дело в радиусе поражения.

Хуже всего — релизная связанность. Когда общий код смешивает runtime-логику, правила приложения и типы в одном месте, каждый потребитель наследует тот же риск релиза. Команда web хочет выпустить правку текста. Команда server всё ещё тестирует новый auth flow. Если обе зависят от одной и той же версии пакета с разнородными обязанностями, одна команда ждёт другую.

Вот так монорепозиторий превращается в пробку. Репозиторий общий, а владение — неясное. Люди перестают доверять границам пакетов, потому что эти границы мало что значат.

Типичный пример выглядит безобидно. Пакет server экспортирует API-типы, валидаторы запросов, хелперы для базы данных и пару утилит для работы с датами. Пакет web импортирует типы, а потом начинает использовать и валидаторы. В итоге клиентский бандл подтягивает код, который имел смысл только на сервере, и бэкенд-рефакторинг становится проблемой фронтенда.

Цель проще, чем многие команды думают. Делитесь типами, а не графиком релизов. Дайте командам переиспользовать контракты, не таща за собой runtime-код, которым они не владеют. Если команда web меняет страницу, команде server не нужен дополнительный review на версию. Если команда server меняет код хранения данных, дизайн-система не должна из-за этого пересобираться.

Хорошие границы не мешают делиться кодом. Они делают это скучным, предсказуемым и безопасным.

Решите, чем владеет каждый пакет

Владение пакетом должно отвечать на простой вопрос: кто решает, что сюда входит, и что этот пакет делает каждый день? Если у пакета две задачи, люди начинают относиться к нему как к ящику для всего подряд. Именно так и начинается релизная связанность.

Пакет web должен владеть только браузерной частью. Туда стоит класть страницы, UI-компоненты, состояние форм, клиентский data fetching и браузерные хелперы. Если коду нужен DOM, local storage или React-view, его место в web.

Пакет server должен владеть только серверной частью. Туда стоит класть API-обработчики, запросы к базе, фоновые задачи, проверки авторизации и серверную конфигурацию. Если код обращается к базе данных или использует server secrets, держите его вне web, даже если импорт кажется удобным.

Пакет shared должен оставаться маленьким и простым. Это не второе приложение. Используйте его для контрактов, которые должны быть одинаково понятны обеим сторонам: типы запросов и ответов, имена событий и общие доменные термины вроде OrderStatus или UserProfile. В TypeScript monorepo boundaries именно здесь команды делятся смыслом, не делясь каждой деталью реализации.

Сделайте владение явным

Один раз запишите правила и держите их рядом с репозиторием:

  • у каждого пакета одна понятная цель
  • им владеет одна команда или один человек
  • другая команда не меняет его без согласования
  • потребители могут предлагать изменения, но решение остаётся за владельцем
  • запрещённый код описан простыми словами

Небольшой пример делает это понятнее. Допустим, одна команда делает клиентский dashboard, другая команда отвечает за API, а третья — за billing. Пакет dashboard не должен импортировать billing-логику только ради повторного использования хелпера. Пакет API не должен лезть в компоненты dashboard ради форматтера. Если всем трём нужен один и тот же тип invoice, вынесите его в пакет shared contracts и назначьте ему отдельного владельца.

Последний пункт особенно важен. Когда за пакет никто не отвечает, правят его все. Когда его правят все, никто не защищает границу. Ясное владение делает изменения меньше, ревью быстрее, а релизы — менее запутанными.

Делите пакеты по частоте изменений, а не по типу папки

Команды обычно начинают с аккуратных папок вроде web, server и shared. На первый взгляд это выглядит чисто, но со временем часто превращается в хаос. Проблема не в том, где выполняется код. Проблема в том, как часто он меняется, кто за него отвечает и насколько рискованно каждое изменение.

Checkout UI может меняться каждую неделю. API-контракт для заказов — раз в несколько месяцев. Форматтер валюты или helper для дат может долго оставаться неизменным. Если всё это лежит в одном большом shared-пакете, каждая мелкая правка UI заставляет другие команды заново тестировать и публиковать код, к которому они вообще не прикасались.

Вот здесь TypeScript monorepo boundaries и начинают иметь значение. Хорошие границы позволяют командам делиться типами и небольшими утилитами, не связывая каждый релиз в один узел.

Простое правило работает хорошо: быстро меняющийся код должен лежать в пакетах, которые тоже могут быстро меняться, а стабильные контракты — в пакетах, которые меняются редко. Продуктовый код, экспериментальный код и UI-flows обычно должны быть рядом с тем приложением, которому они принадлежат. Типы, задающие форму запросов и ответов, могут жить в небольшом contract-пакете с более медленным ритмом релизов.

Большой shared-пакет часто служит предупреждением. Он обычно собирает всё подряд: API-типы, React hooks, код валидации, вспомогательные функции, тестовые фикстуры и иногда даже серверную логику. Потом одной команде нужен патч, она повышает версию пакета, и ещё три команды оказываются втянуты в тот же релиз.

Разделять стоит раньше, если вы видите такие признаки:

  • в одном пакете есть и UI-код, и backend-модели
  • одна команда меняет файлы каждый день, а другая почти никогда их не трогает
  • патч для одного приложения требует повышения версий в не связанных приложениях
  • люди говорят «давайте просто положим это в shared», не называя владельца

Небольшой пример делает это ещё понятнее. Если команда web выкатывает эксперименты с ценами каждые несколько дней, держите этот код в web-пакете. Если команда server отвечает за правила invoice и публикует типизированные контракты запросов и ответов, держите эти контракты в отдельном пакете. Если обеим командам нужен тип tax ID, он может жить в маленьком отдельном пакете, а не в общей корзине с разрозненными хелперами.

Названия папок помогают ориентироваться в репозитории. Время релизов помогает командам работать, не блокируя друг друга. Сначала выбирайте второе.

Делитесь типами, не делясь runtime-кодом

Когда web и server-пакеты импортируют runtime-код друг друга, с релизами быстро начинается путаница. Более безопасный подход для TypeScript monorepo boundaries — делиться не кодом, который выполняет бизнес-логику, а договорённостью между пакетами.

Обычно достаточно небольшого contracts-пакета. В нём хранятся формы запросов и ответов, общие IDs, значения статусов и форматы ошибок. Команда web может строить формы и вызовы API на основе этих типов, а команда server — менять внутреннюю логику, не ломая всех потребителей.

В простом contracts-пакете могут быть типы вроде CreateInvoiceRequest, InvoiceResponse, InvoiceId и InvoiceStatus. Эти названия описывают данные, которые проходят через границу пакета. Они не описывают, как сервер хранит invoices, считает налог или решает, кто может одобрить возврат.

Обычно туда стоит помещать только узкий набор вещей:

  • типы запросов и ответов
  • общие IDs и значения статусов
  • формы пагинации и ошибок
  • правила валидации — только если обе стороны действительно их выполняют

Последний пункт особенно важен. Команды часто кладут разбор схем, маппинг базы данных, хелперы форматирования и проверки прав в общий слой просто из удобства. Но маленьким он остаётся недолго. Вскоре web-пакет начинает зависеть от серверных решений, и безобидный рефакторинг превращается в согласованный релиз.

Сначала должны идти обычные типы. Они лёгкие, их просто проверять и недорого версионировать. Если обеим сторонам правда нужна runtime-валидация, добавляйте её аккуратно и держите рядом с самим контрактом. Схема, которая проверяет `

Настройте простую модель границ шаг за шагом

Добавьте защиту границ
Добавьте lint-, CI- и review-правила, которые рано ловят плохие импорты.

Начните с трёх пакетов и одного правила, которое не меняется. Поместите браузерный код в web, серверный код в server, а общие формы запросов и ответов — в contracts.

Держите contracts специально маленьким. В нём должны быть типы, по которым обе стороны должны договориться: API payloads, IDs, enums и, возможно, несколько общих схем. Если код работает только на сервере, оставьте его в server. Если он нужен только для рендеринга UI, держите его в web.

Так вы получите чистый граф импортов. Web-приложение может импортировать contracts, и сервер тоже может импортировать contracts, но web никогда не должен лезть в server. Сервер, в свою очередь, тоже должен оставаться вне web. Когда команды соблюдают это правило, одно фронтенд-изменение не тянет за собой серверный код в тот же релиз.

Для большинства команд схема выглядит так:

  1. Задайте понятные имена пакетов и TypeScript path aliases, чтобы импорты было легко заметить.
  2. Добавьте lint-правила, которые блокируют запрещённые импорты, например из web в server или наоборот.
  3. Запускайте пакетные type check и сборку в CI, чтобы нарушения границ падали как можно раньше.
  4. Держите contracts под более строгим ревью, потому что его изменения могут затронуть обе стороны.

Правила путей помогают людям писать правильные импорты. Lint-правила ловят ошибки до ревью. Проверки сборки не дают случайной связанности попасть в main. Нужны все три. Хорошие TypeScript monorepo boundaries зависят не столько от памяти команды, сколько от скучных автоматических проверок.

Независимые релизы особенно важны, когда команды начинают работать с разной скоростью. Если команда web выпускает изменения каждый день, а команда server — раз в неделю, дайте contracts собственную версию. Тогда обновление серверной части может остаться в server, а правка только для web — в web. Координация нужна только для изменений общих контрактов.

Небольшая команда может начать с одного репозитория и одного процесса релиза, а версии пакетов разделить позже. Часто это лучший путь. На старте не нужна сложная схема. Нужен граф пакетов, который очевиден, закреплён правилами и его трудно сломать случайно.

Реалистичный пример с тремя командами

Представьте монорепозиторий, где три команды работают над одним checkout flow. У них один пользовательский путь, но не обязательно один и тот же релиз.

Хорошее разделение может выглядеть так:

  • contracts-checkout владеет Cart, CartLine, Address и OrderInput
  • web-checkout владеет страницей, состоянием формы и поведением интерфейса
  • api-orders владеет обработкой запросов и созданием заказов, а billing-core — правилами оплаты и кодом провайдера

Команда web собирает checkout-страницу. Когда она читает корзину и отправляет заказ, она импортирует формы корзины и заказа из contracts-checkout. Это делает форму честной. Если корзине нужен coupon field или shipping method, команда сначала меняет контрактный пакет, а потом обновляет страницу под это изменение.

Команда API использует тот же тип OrderInput на границе запроса. Так обе стороны работают с одной общей формой payload. Команда web не угадывает, чего ждёт сервер, а API-команда не пишет вручную вторую версию того же объекта.

Billing-команда делает другое. Она не раскрывает внутренний код остальному репозиторию. Пакет экспортирует небольшой публичный интерфейс, например chargeOrder() или capturePayment(), и держит адаптеры к платёжным шлюзам, правила повторных попыток, проверки fraud и маппинг провайдера внутри. Web-приложение никогда не должно импортировать внутренности billing. API должен обращаться к billing только через этот узкий публичный вход.

Такое разделение окупается, когда billing меняется. Допустим, billing-команда добавляет второго платёжного провайдера, меняет тайминги retry или переписывает обработку налогов. Если публичный контракт остался тем же, выпускается только billing-core. Checkout-странице не нужен релиз. API заказов тоже не нужен релиз.

В этом и есть практический смысл TypeScript monorepo boundaries. Команды делятся структурами данных там, где это помогает, и прячут runtime-логику там, где изменения происходят часто. В итоге меньше неожиданных поломок, и одна команда может спокойно выпустить изменение во вторник, не втягивая ещё две команды в тот же deploy.

Ошибки, которые создают релизную связанность

Ускорьте релизы команд
Перестройте границы пакетов так, чтобы одна команда могла выпускать изменения без ожидания другой.

Релизная связанность обычно начинается с маленькой короткой дороги. Одна команда заимствует тип, хелпер или модель «пока просто так», а через полгода уже три пакета должны выпускаться вместе.

Именно так часто ломаются границы в TypeScript-монорепозитории. Код по-прежнему компилируется, но владение становится размытым, а простые изменения превращаются в работу сразу для нескольких команд.

В shared-пакете не должно быть моделей базы данных. Таблицы, ORM-сущности и формы запросов принадлежат серверной стороне, потому что меняются по серверным причинам. Если web-приложение импортирует эти модели, безобидный рефакторинг базы может неожиданно сломать фронтенд, даже если API не менялся.

Та же проблема возникает, когда пакет web импортирует server-утилиты ради удобства. Форматтер дат может казаться безобидным, но server-утилиты со временем часто начинают тянуть конфиг, auth-правила, файловую систему или допущения о базе данных. Тогда web-приложение начинает зависеть от времени релизов server.

Папка shared тоже может превратиться в ящик для всего подряд. Когда туда попадает каждый хелпер, никто уже не понимает, чем именно владеет пакет. Команды начинают считать shared-код публичным по умолчанию, и тогда для мелких правок нужно слишком много согласований.

Лучшее правило простое: помещайте код в shared-пакет только если он нужен двум командам, и обе могут объяснить, почему он стабилен.

Контрактным типам нужна особая осторожность. Если команда server переименовывает внутреннее поле, меняет ORM или перестраивает сервисный код, ей не нужно менять публичные типы запросов и ответов, если сам контракт не изменился. Если API по-прежнему возвращает те же данные, оставьте контракт стабильным и рефакторьте внутри.

Реалистичный провал выглядит так:

  • Команда server переходит с одной библиотеки для базы данных на другую.
  • Они обновляют shared-модели под новые helpers схемы.
  • Теперь команде web тоже нужны изменения, хотя ни один экран не менялся.
  • QA заново тестирует оба приложения.
  • Все называют это «одним релизом», хотя это должно было быть одно изменение только для server.

Та же путаница возникает из-за привычек ревью. Если одна команда по умолчанию проверяет каждый пакет, владение превращается в очередь. Для shared-пакетов нужны понятные владельцы, но web, server и внутренние инструменты не должны ждать одного и того же человека, если изменение на самом деле не пересекает границу.

Если пакет заставляет не связанные между собой команды координироваться каждую неделю, граница, скорее всего, выбрана неверно.

Быстрые проверки перед добавлением зависимости

Закрепите понятное владение
Назначьте владельца каждому пакету и уберите споры о границах из ревью.

Новый импорт может выглядеть безобидно. На практике он определяет, кто отвечает за изменения, кто ждёт в день релиза и кого будут разбирать баги, к которым он не имеет отношения. Хорошие TypeScript monorepo boundaries проявляются именно в таких маленьких решениях.

Остановитесь перед тем, как добавить зависимость. Двухминутная проверка сейчас может сэкономить дни уборки потом.

  • Спросите, кто владеет пакетом. Если у него нет понятного владельца, считайте это предупреждением. Общий код без владельца обычно превращается в медленные ревью и неудобные исправления.
  • Проверьте, нужен ли вам именно тип или настоящий runtime-код. Общий интерфейс, схема или enum намного легче, чем подтягивание хелперов, конфигурации или бизнес-логики.
  • Проверьте, заставит ли изменение другую команду выпускать релиз. Если команда web не может выкатить правку, пока команда server не перепубликует пакет, граница уже слишком тесная.
  • Проверьте, останется ли контракт стабильным после этого изменения. Если импорт зависит от полей или поведения, которые меняются каждый спринт, вы делитесь не повторным использованием, а постоянной текучкой.
  • Проверьте, сохраняется ли смысл направления зависимости. Shared-пакеты должны находиться низко в графе. Пакеты web и server могут зависеть от shared contracts, но shared contracts не должны зависеть от UI-кода, доступа к базе данных или обработчиков запросов.

Небольшой пример помогает это оценить. Допустим, команда web хочет импортировать server-пакет только ради типа UserRole. Обычно это выглядит эффективным примерно неделю. Потом команда server меняет helper валидации, публикует новую версию, и web-приложение вынуждено обновиться без собственного запроса на изменение.

Чистое решение — вынести только сам контракт в shared-пакет, если он действительно нужен обеим сторонам. Если тип всё ещё быстро меняется, иногда лучше просто скопировать десять строк. Небольшое дублирование часто дешевле, чем вечная релизная связанность.

Команды, которые умеют быстро работать, учатся защищать граф зависимостей. У каждого нового ребра должны быть понятные владелец, причина и путь релиза, который не тормозит всех остальных.

Что делать дальше, если монорепозиторий должен расти

Начинайте с фактов, а не с мнений. Возьмите из репозитория данные по импортам за неделю или две и отметьте места, где команды постоянно переходят через границы пакетов. Обычно там находятся одни и те же проблемные точки: UI-код импортирует серверные хелперы, shared-пакеты тащат в себя прикладную логику, или одна команда ждёт релиз другой ради крошечного изменения типа.

Потом сначала сделайте один маленький шаг. Создайте узкий shared-пакет только для контрактов: типы запросов и ответов, доменные enum'ы и, возможно, пару схем, если они действительно нужны обеим сторонам. Не тащите в этот пакет runtime-код. Один такой раздел часто убирает много релизной связанности без большого переписывания.

Краткое обсуждение с каждой командой помогает больше, чем длинный архитектурный документ. Спросите несколько простых вещей:

  • Какой пакет команда может менять без разрешения?
  • Какие импорты замедляют другую команду?
  • Какие общие типы меняются каждый спринт, и почему?
  • Какому пакету нужна своя версия и свои release notes?
  • Какие новые импорты CI должен блокировать сразу?

После этого добавьте защитные меры. Используйте lint-правила, настройки tsconfig и простую CI-проверку, чтобы блокировать новые нарушения границ до того, как они расползутся. Для большинства команд лучшие TypeScript monorepo boundaries держатся на скучных правилах, которым все могут следовать: web импортирует только безопасные для web пакеты, server импортирует server-код и contracts, а shared-пакеты остаются маленькими.

Владению тоже нужен план релизов. Пропишите, кто утверждает изменения в каждом пакете, кто публикует его и что происходит, когда меняется контракт. Если обновление типа заставляет три команды остановиться и согласовывать действия, пакет всё ещё слишком широкий.

Сторонний взгляд помогает, когда в репозитории уже накопились годы смешанных решений. Oleg Sotnikov, fractional CTO и startup advisor, работает с практической архитектурой и lean delivery и может помочь командам перестроить границы монорепозитория без того, чтобы уборка превращалась в долгую паузу в разработке.

Хороший результат выглядит просто. Три команды работают в одном репозитории, делятся типами там, где это имеет смысл, и выпускают обычные изменения, не ожидая друг друга.