javascript

FSD, Clean или modular? Шесть вариантов React-магазина под четырьмя изменениями

  • воскресенье, 19 июля 2026 г. в 00:00:05
https://habr.com/ru/articles/1060256/
Интерактивный визуализатор стенда: слои, связи и результаты запросов на изменение.
Интерактивный визуализатор стенда: слои, связи и результаты запросов на изменение.

Это экран визуализатора из репозитория. Данные для него и таблиц собирает один прогон.

Это четвёртая статья цикла про TypeScript и React глазами code review. Предыдущие — про useEffect, про any и про перенос TypeScript на Go.

Мы вынесли компактную карточку банковской карты в entities/card/ui: маска номера, баланс и статус — решение казалось очевидным.

Через два спринта в карточке появились действия «заблокировать карту» и «изменить лимит». В итоге entity начала импортировать features/block-card, features/change-card-limit и проверку прав доступа мимо public API. Когда ту же карточку понадобилось показать в выборе счёта списания, вместе с ней приехали ненужные зависимости и проверки ролей.

Пользовательских инцидентов не случилось, но исправление отняло время. В entity оставили чистый CardPreview, сценарии подключили выше, композицию дашборда перенесли в widgets/card-summary, затем заново прогнали ролевые тесты.

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

Какие будущие изменения выбранная граница позволит оставить локальными?

В FSD 2.1 для подобных ситуаций рекомендуют начинать со страниц и извлекать код ниже по мере появления переиспользования. Это хороший вариант по умолчанию, хотя самостоятельный сценарий с близким вторым потребителем иногда оправдывает границу раньше.

Как устроен эксперимент

Я собрал один React-магазин в шести вариантах. Во всех вариантах одинаково работают каталог, корзина, тарифная скидка и промокоды, меняется доминирующий принцип организации кода.

Эти варианты не являются взаимоисключающими архитектурными школами. В реальном проекте продуктовые модули могут использовать элементы Clean Architecture, а внутри модулей может применяться FSD. В стенде я намеренно выделяю один ведущий принцип, чтобы посмотреть, как именно он влияет на локальность изменений.

Вариант

Основной принцип

Что проверяет

Слои по типам файлов

Группировка по типу файлов

Достаточна ли простая структура без направленных границ

Modular

Группировка по продуктовым областям

Остаётся ли удаление функции внутри модуля

Clean

Порты и направление зависимостей

Что даёт замена источника данных

FSD pages-first

Извлечение после появления потребителя

Цена отложенной декомпозиции

FSD pre-sliced

Граница создаётся заранее

Цена и выгода раннего извлечения

FSD cargo

Намеренно нарушенные границы

Находит ли автоматический контроль проблемы

Коротко о методике

  • Testing Library работает через публичный <App/>. Восемь одинаковых сценариев на вариант дают 6 × 8 = 48 базовых тестов.

  • Цена карточки входит в контракт и хранится в data-amount в копейках. Визуально одинаковый UI не может скрыть разный расчёт.

  • dependency-cruiser строит граф. Runtime-рёбра, существующие в исполняемом коде, и type-only-рёбра, нужные только TypeScript, считаются отдельно. Для входящих зависимостей отчёт показывает и число импортов, и число уникальных модулей-источников.

  • Четыре запроса на изменение лежат полными версиями затронутых файлов «после». Каждый патч проходит общий и специальный пользовательские сценарии, затем tsc --noEmit.

Исходное состояние и файлы после изменения форматируются одним конфигом. При сбое причина попадает в JSON-отчёт, процесс завершается с ненулевым кодом, а исходники восстанавливаются с диска.

Статический анализ отвечает за направление импортов, циклы и обход public API. Удаляемость проверяется продуктовым изменением. Владение кодом стенд не измеряет — этот вопрос остаётся за командой и CODEOWNERS.

Три свойства границы: направление зависимости, стоимость удаления, владелец.
Три свойства границы: направление зависимости, стоимость удаления, владелец.

В стенде измеряются первые два свойства. Владение остаётся организационным решением.

Что видно в исходном графе

Слои по типам файлов components/ store/ api/ utils/ types/ дают короткий путь к знакомым файлам. В графе 14 модулей, 23 runtime- и 8 type-only-рёбер. Циклов нет, однако направление между каталогами не задано. Пять импортов в utils приходят из трёх уникальных модулей.

В Clean-варианте есть domain/ application/ adapters/ ui/ и точка композиции (composition root). Домен не импортирует React или транспорт, а HTTP-репозитории реализуют порты. Получилось 14 модулей, 17 runtime- и 10 type-only-рёбер. В домен ведут десять межслойных импортов из пяти модулей.

Modular делится на modules/catalog, modules/cart, modules/account и общий platform. Корзина хранит снимок товара и не обращается обратно в каталог. Здесь 19 модулей, 26 runtime- и 10 type-only-рёбер; в platform ведут 12 импортов из девяти модулей.

У FSD pages-first 36 модулей и 61 ребро: 40 runtime, 21 type-only. Pre-sliced содержит 31 модуль и 53 ребра: 37 runtime, 16 type-only. Оба проходят один набор правил dependency-cruiser без циклов, импортов вверх и обходов @x.

FSD cargo сохраняет знакомые названия каталогов, но shared импортирует entities, карточка сущности тянет feature, а cross-import между сущностями идёт мимо @x. Анализатор находит пять циклов и шесть нарушений границ. Steiger дополнительно сообщает 13 ошибок и одно предупреждение.

Сравнение FSD 2.1 и карго-FSD под одним набором правил.
Сравнение FSD 2.1 и карго-FSD под одним набором правил.

На графике только положительный и отрицательный контроль одного набора правил. Clean, modular и вариант «Слои по типам файлов» используют другие запреты, поэтому их нули на общей шкале вводили бы в заблуждение. Pre-sliced участвует в отдельном сравнении момента извлечения.

Четыре изменения

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

CR-02: карточка появляется в Избранном

Добавляются экран, маршрут и пункт навигации. Pages-first в этот момент извлекает карточку из каталога в widget; pre-sliced уже сделал это раньше.

Реализация

Файлов

Зон верхнего уровня

Проверка

Слои по типам файлов

3

2

Clean

3

1

FSD cargo

4

3

Modular

4

2

FSD pre-sliced

4

3

FSD pages-first

8

3

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

Специальный сценарий открывает Избранное, проверяет тарифную цену и кладёт товар в общую корзину.

Первое переиспользование затронуло восемь файлов в pages-first и четыре в pre-sliced. Однако эти 4 против 8 показывают только стоимость CR-02. Часть работы pre-sliced оплатил заранее.

Вот стоимость исходного состояния двух FSD-вариантов до запросов на изменение:

Показатель

Pages-first

Pre-sliced

TS/TSX-файлов в src, без теста

37

32

FSD-слайсов

9

8

Файлов index.ts, выполняющих роль public API

13

12

Межслойных рёбер

26

24

Замечаний Steiger во всём варианте

2

1

Файлов, образующих границу карточки

1

2

Отдельный слайс и public API карточки

нет

есть

Первые четыре строки воспроизводятся в report/metrics.json, замечания — командой pnpm steiger. Общие числа нельзя трактовать как чистую цену карточки: в исходном состоянии по-разному смоделированы промокод и add-to-cart. Поэтому суммарное число файлов у pages-first оказалось выше, несмотря на отсутствие отдельного widget карточки. Прямая предоплата pre-sliced видна в последних двух строках: дополнительный widget-слайс, файл public API и замечание Steiger insignificant-slice, пока у карточки только один потребитель. Таблица не делает раннее выделение бесплатным, зато показывает, за что именно платит команда.

CR-04: промокоды удаляются из продукта

Набор файлов «после» действительно удаляет API, типы, состояние, расчёт и UI. Специальный сценарий проверяет отсутствие поля промокода и прежний результат тарифной скидки.

Реализация

Изменено или удалено файлов

Зон верхнего уровня

Проверка

FSD pre-sliced

4

3

Modular

6

1

Слои по типам файлов

6

5

FSD cargo

8

3

Clean

8

5

FSD pages-first

9

2

Реальный CR-04: файлы, изменённые или удалённые вместе с промокодами.
Реальный CR-04: файлы, изменённые или удалённые вместе с промокодами.

Размер работающего патча после форматирования исходного состояния и версий «после» одним конфигом.

Modular затрагивает шесть файлов, и весь патч остаётся внутри одной продуктовой зоны. Вариант «Слои по типам файлов» даёт те же шесть файлов, разбросанные по пяти каталогам. В Clean удаление проходит через домен, интерфейс порта, адаптер доступа к данным, прикладной сценарий и UI.

Pre-sliced меняет четыре файла, потому что промокод в нём остался локальным состоянием страницы и не получил отдельный entity-слайс. Поэтому сравнение pages-first и pre-sliced как политик извлечения корректно прежде всего для CR-02. В CR-04 на результат дополнительно влияет разное размещение промокода в исходном состоянии.

CR-03: каталог переживает отказ сети

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

Реализация

Файлов

Зон верхнего уровня

Проверка

Слои по типам файлов

1

1

FSD pages-first

1

1

FSD cargo

1

1

Modular

1

1

FSD pre-sliced

1

1

Clean

2

2

По числу файлов Clean проигрывает: там появился декоратор withProductCache, а точка композиции подключила его к HTTP-репозиторию. Остальные варианты добавили кэш в существующую функцию загрузки.

При этом у Clean не менялись domain и application. Порт дал предусмотренное место для политики доступа, но на маленьком сценарии не сократил патч. Его преимущество может стать заметнее при IndexedDB, правилах устаревания или нескольких источниках, но текущий стенд этого не проверяет.

CR-01: бесплатная доставка от 5000 ₽

Доставка считается от суммы после скидки. Ниже порога она стоит 300 ₽, выше становится бесплатной; тест проходит обе ветки и проверяет подсказку в UI.

Реализация

Файлов

Зон верхнего уровня

Проверка

FSD cargo

1

1

Слои по типам файлов

2

2

Clean

2

2

Modular

2

1

FSD pages-first

3

2

FSD pre-sliced

3

2

Результат почти нейтральный. В cargo расчёт уже находится на странице, поэтому изменение умещается в один файл. У остальных меняются функция расчёта и UI, иногда public API. Этот запрос нужен как контроль: реальное изменение не обязано подтверждать архитектурную гипотезу.

Общая картина

Изменение

Главный результат

Переиспользование карточки

Pre-sliced — 4 файла, pages-first — 8; ранняя граница имела предварительную цену

Удаление промокодов

Modular удержал изменение в одной зоне; результат pre-sliced зависит от модели промокода

Каталог без сети

Простые API-модули дали меньший патч, Clean сохранил domain и application

Бесплатная доставка

Почти нейтральный запрос, архитектуры различаются слабо

Тепловая карта четырёх запросов на изменение для шести реализаций.
Тепловая карта четырёх запросов на изменение для шести реализаций.

Крупное число — затронутые файлы. Каждая точка внизу ячейки обозначает одну зону верхнего уровня.

Как стенд защищается от ложных результатов

Первая версия стенда давала красивые, но слабые числа. Цена карточки не входила в общий контракт, а удаление промокодов оценивалось поиском слова promo. Такой поиск показывает предполагаемый радиус, но не доказывает, что приложение после удаления собирается и сохраняет тарифную скидку. Я заменил его полными рабочими патчами и отдельным сценарием CR-04.

Подготовка файлов «после» тоже сначала выглядела механической. Часть патчей была сжата в одну строку; в одном файле она растянулась до 557 символов. Сравнение строк в таком виде измеряло форматирование. Исходники и все CR были прогнаны одним Prettier-конфигом с ограничением 100 символов, после чего замеры выполнены заново. В выводах я опираюсь на число файлов и зон: оно тоже грубое, но меньше зависит от переносов JSX.

Один промежуточный патч прошёл пользовательский сценарий и упал на tsc из-за забытого экспорта. Более неприятный сбой произошёл, когда долгий CR-04 остановили во время временной подмены файлов modular-варианта. Блок finally не выполнился, следующий запуск получил наполовину изменённое исходное состояние, а цифры выглядели правдоподобно. После ручной сверки появился дисковый журнал: перед новым замером он восстанавливает файлы. Проверочный контур сначала записывает причину ошибки в отчёт и только затем завершается с ненулевым кодом.

Что показал эксперимент

FSD. Pages-first дисциплинирует раннее извлечение: официальная миграция v2.1 рекомендует оставить логику на странице при одном потребителе, Steiger закрепляет это правилом insignificant-slice. В учебном pages-first Steiger видит два таких слайса — entities/promo и features/add-to-cart; у pre-sliced одно замечание получает widgets/product-card. CR-02 показывает обмен между дополнительной ранней границей и более крупным первым переиспользованием. Связи через @x при этом делают зависимость адресной: entities/product/@x/order.ts публикует только контракт для order.

Clean. ProductRepository отделяет сценарий загрузки от HTTP. CR-03 подтвердил точку расширения, но дал два файла вместо одного. Поэтому порт я считаю оправданным, когда он изолирует конкретную нестабильность или защищает важную доменную границу. Количество реализаций само по себе ничего не решает. Repository поверх TanStack Query также нужен только тогда, когда скрывает транспортный формат, объединяет источники или удерживает доменные типы.

Modular и «Слои по типам файлов». Modular лучше остальных удержал удаление промокодов внутри одной зоны. Его риск — постепенный рост platform и слишком широкие public API. Помимо измеряемых CR, статический и сборочный анализ показал ещё один практический риск modular-варианта. В стенде barrel-файл экспортирует provider и страницу вместе: статический импорт provider подтягивает страницу, которую одновременно пытаются загрузить динамически. Vite сообщает INEFFECTIVE_DYNAMIC_IMPORT, и четыре JS-чанка превращаются в один. Слои по типам файлов проще на старте, но не задают направление между каталогами.

Автоматические правила. FSD cargo проходит все восемь пользовательских сценариев, хотя анализатор находит 11 срабатываний: пять циклов и шесть нарушений границ. Это хороший отрицательный контроль. Знакомое дерево папок не заменяет проверку импортов в CI.

Как выбирать структуру в рабочем проекте

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

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

FSD даёт общий словарь растущей frontend-команде. Pages-first снижает число ранних сущностей и фич; pre-sliced допускает осознанную предоплату за близкое переиспользование. В обоих случаях правила импортов должны жить в CI.

Clean имеет смысл при богатом домене, нескольких каналах данных, работе без подключения к сети или долгоживущих правилах. На тонком CRUD цена портов и mapper-ов обычно проявится раньше выгоды.

Дерево выбора через ожидаемую цену изменений.
Дерево выбора через ожидаемую цену изменений.

Это подсказка для начала разговора, а не алгоритм выбора архитектуры.

Договорённость о структуре я бы закрепил четырьмя действиями:

  1. Записать проверяемые правила рядом с кодом. В стенде это rules/<variant>.cjs.

  2. Зафиксировать текущие нарушения и запретить добавлять новые. Для dependency-cruiser есть depcruise-baseline.

  3. Назначить владельцев каталогов или модулей через CODEOWNERS.

  4. Оставить короткий ADR с причиной нарезки и примерами разрешённых импортов.

Ограничения и вывод

  • Магазин состоит из двух экранов. Абсолютные числа не переносятся на миллион строк.

  • Все реализации написаны одним человеком. Другой инженер может получить иной размер патча.

  • Количество затронутых файлов не равно трудозатратам. Большой UI-файл и маленький index.ts считаются одинаково.

  • Владение кодом, скорость вхождения нового разработчика и удобство поиска файла не измерялись.

  • Кэш для работы без сети хранится в памяти. Здесь нет IndexedDB, правил устаревания, синхронизации вкладок и конфликтов версий.

  • Разница размера бандла находится в пределах шума. Значим только воспроизведённый случай со схлопнувшимся разбиением на чанки.

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

Pages-first и pre-sliced показали понятный обмен: первый вариант отложил поддержку отдельной границы и затронул восемь файлов при первом переиспользовании; второй заранее добавил widget и public API, после чего уложился в четыре. Clean подготовил точку замены источника, но запрос на кэш оказался длиннее простого изменения API-модуля. Modular удержал удаление промокода в одной продуктовой зоне.

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

Репозиторий и источники

Какое изменение чаще всего ломает границы в вашем frontend-проекте: переиспользование, удаление функциональности или смена источника данных?

Об авторе

Меня зовут Виктор Горбачёв. Я Senior Frontend-разработчик и Team Lead, более семи лет работаю с React и TypeScript. Занимаюсь архитектурой frontend-приложений, микрофронтендами, развитием enterprise-систем и постепенным разбиением монолитов.

На Хабре пишу о frontend-архитектуре, code review и инженерных процессах. Telegram — @team_viktoriko.