ENGINEERING NOTES / Архитектура
Масштабирование frontend за пределами компонентов
Почему компонентного подхода недостаточно и как сохранить скорость разработки при росте продукта.
Статья на русском языкеКомпоненты — начало, а не архитектура
React, Angular и Vue научили нас мыслить интерфейсами как деревом компонентов. Но дерево отображения не описывает границы бизнес-логики, владение данными, контракты API и ответственность команд. На небольшом проекте эти решения можно принимать по ситуации; на большом каждое неявное соглашение превращается в дополнительную стоимость изменения.
Представьте карточку товара, которая одновременно делает запрос, обновляет корзину, отправляет аналитику, проверяет авторизацию и открывает модальное окно. Разбить её JSX на пять подкомпонентов полезно, но проблема связанности от этого не исчезнет. Масштабирование начинается с вопроса: какие изменения должны оставаться локальными и какие зависимости действительно допустимы?
Хорошая архитектура не обязана быть сложной. Она делает изменения предсказуемыми, а границы ответственности — видимыми. Не число директорий, а возможность заменить внутреннюю реализацию модуля без каскадных правок показывает качество декомпозиции.
Признаки того, что архитектура не успевает за продуктом
Одна задача требует правок в несвязанных разделах; разработчики не знают, куда положить новую логику; общие компоненты обрастают флагами; все данные попадают в один store; небольшой PR вызывает большой регрессионный цикл. Другой сигнал — постоянные конфликты в одних и тех же файлах у нескольких команд.
Не стоит автоматически отвечать на эти симптомы переписыванием. Сначала соберите конкретные случаи: какие изменения вышли за пределы предполагаемого модуля, сколько мест пришлось затронуть, где возникли циклические зависимости. Диагноз должен опираться на историю реальных задач, а не на эстетические предпочтения относительно структуры папок.
**Проверьте:**
- Можно ли объяснить владельца каждого состояния?
- Может ли модуль изменить внутреннее устройство без правок у потребителей?
- Проверяются ли ограничения импортов автоматически?
- Есть ли повторяющиеся исключения, которые команда копирует вручную?
Границы по предметной области
Структура `components/`, `hooks/`, `services/`, `utils/` группирует код по технической роли, но разносит одну пользовательскую возможность по всему репозиторию. При росте продукта полезнее группировать связанные части вокруг каталога, поиска, корзины, авторизации и оформления заказа. Внутри такой области могут жить UI, модель, API и тесты.
Это один из вариантов, а не обязательная схема. Feature-Sliced Design формализует слои, срезы и направление зависимостей, но даже его документация рекомендует начинать с простого разделения и вводить новые уровни по мере необходимости. Модуль должен возникать из реальной границы изменений, а не ради заполнения архитектурного шаблона.
src/
app/ # запуск, маршруты, провайдеры
pages/ # композиция страниц
features/ # пользовательские действия
add-to-cart/
ui/
model/
api/
index.ts
entities/ # предметные сущности
product/
ui/
model/
api/
index.ts
shared/ # инфраструктура и универсальный UIПубличный API модуля
Граница существует только тогда, когда потребители не импортируют произвольные внутренние файлы. Определите публичный вход и экспортируйте из него только то, что действительно требуется снаружи.
Сам `index.ts` не обеспечивает защиту: обходные импорты нужно ограничивать ESLint, правилами пакетов или инструментами анализа зависимостей. Особенно опасны циклы: если корзина зависит от каталога, каталог от профиля, а профиль от корзины, отдельные области уже нельзя безопасно развивать независимо. Общие контракты иногда следует вынести ниже, а координацию нескольких возможностей — поднять в композиционный слой.
**Практика:** фиксируйте публичные экспорты, проверяйте циклы в CI и обсуждайте изменение контрактов как изменение API, а не как локальный рефакторинг.
// entities/product/index.ts
export type { Product } from './model/types';
export { ProductCard } from './ui/ProductCard';
export { getProduct } from './api/getProduct';
// Потребитель
import { ProductCard, type Product } from '@/entities/product';Состояние: найдите владельца данных
Состояние бывает разным. Открытый dropdown — локальное UI-состояние. Результаты API — серверное состояние с кэшированием и актуальностью. Фильтры каталога часто должны жить в URL, чтобы поддерживать ссылки и историю браузера. Данные многошаговой формы могут принадлежать одному пользовательскому сценарию. Смешивание этих категорий в одном глобальном store создаёт ненужные связи.
Не копируйте серверные данные в глобальное хранилище автоматически: библиотеки вроде TanStack Query решают задачи кэширования, повторной загрузки и синхронизации. Если данные дублируются в двух независимых местах, команде придётся отдельно решать, какое из них является источником истины. Поднимайте состояние на минимальный уровень, который действительно нужен потребителям.
type CatalogFilters = {
query: string;
sort: 'popular' | 'price_asc' | 'price_desc';
page: number;
};
function readFilters(params: URLSearchParams): CatalogFilters {
const requestedSort = params.get('sort');
const sort = requestedSort === 'price_asc' || requestedSort === 'price_desc'
? requestedSort
: 'popular';
return {
query: params.get('query') ?? '',
sort,
page: Math.max(1, Number(params.get('page')) || 1),
};
}API и защита предметной модели
Ответ backend — внешний контракт, а не обязательно идеальная модель интерфейса. Если компонентам напрямую передавать DTO с нестабильными полями, изменение API затронет множество экранов. Адаптер позволяет нормализовать ответ на границе.
TypeScript проверяет типы во время разработки, но не валидирует произвольный JSON в runtime. Для важных границ применяйте схемы проверки или генерацию типов из OpenAPI с необходимой runtime-валидацией. Не создавайте адаптер для каждого тривиального ответа: дополнительный слой оправдан, если он изолирует реальную нестабильность или переводит внешнюю модель в понятную внутреннюю.
type ProductDTO = {
product_id: number;
product_name: string;
price_amount: string;
};
type Product = { id: string; title: string; price: number };
function mapProduct(dto: ProductDTO): Product {
const price = Number(dto.price_amount);
if (!Number.isFinite(price)) throw new Error('Invalid price');
return { id: String(dto.product_id), title: dto.product_name, price };
}Производительность — архитектурное свойство
Если стартовая страница загружает код графиков, редактора и всех остальных маршрутов, локальная оптимизация рендеринга не устранит главный источник задержки. Выбирайте границы динамической загрузки по пользовательским сценариям, а не по размеру каждого файла. Излишне мелкое разбиение тоже вредно: оно способно создать цепочки запросов.
Ищите сетевые водопады, параллельно запрашивайте независимые данные, оптимизируйте hero-изображения и шрифты. Проверяйте Core Web Vitals и реальные пользовательские данные, а не только локальный Lighthouse. Оптимизация имеет смысл, когда измерение показывает улучшение важного сценария.
import { lazy, Suspense } from 'react';
const AnalyticsPage = lazy(() => import('./pages/AnalyticsPage'));
export function AnalyticsRoute() {
return (
<Suspense fallback={<div>Загрузка аналитики…</div>}>
<AnalyticsPage />
</Suspense>
);
}Архитектура для нескольких команд
По мере роста организации появляется потребность в явном владении областями и понятном процессе изменения контрактов. ADR — короткие записи об архитектурных решениях — сохраняют контекст: проблему, альтернативы, выбор и последствия. Монорепозиторий может упростить совместные изменения и общие пакеты, но не заменяет контроль импортов.
Микрофронтенды могут обеспечить независимую поставку отдельных частей продукта, однако добавляют стоимость интеграции, согласования версий, единого UX и наблюдаемости. Если независимость можно получить через модульный монолит и пакеты, усложнение инфраструктуры необязательно. Архитектура должна соответствовать реальным границам команд и релизов.
Как развивать архитектуру без большого переписывания
Начните с области, где высокая частота изменений сочетается с высокой стоимостью сопровождения. Определите публичный контракт, изолируйте внешние зависимости, добавьте тесты критичных сценариев и перенесите внутреннюю реализацию небольшими шагами. Стабильный старый модуль не обязательно требует срочного рефакторинга только потому, что выглядит несовременно.
Оценивайте результат не по красоте дерева директорий, а по стоимости изменений: стало ли меньше затрагиваемых модулей, регрессий и согласований? Может ли новый разработчик разобраться в границах ответственности? Устойчивое масштабирование — это способность продукта продолжать меняться без непрерывного увеличения цены каждой новой функции.