Мой опыт работ c архитектурой FSD

В этой статье я хочу поделиться своим опытом разработки приложений с применением подхода FSD (Feature-Sliced Design). Здесь не будут рассматривать ее детально, так как на этот счет есть достаточно хороших материалов, начиная с официального сайта (изображения в этой записи взяты именно с него), и заканчивая статьями на Хабре.

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

Базовая идея архитектуры

Весь код разбивается на слои:

  • app - верхний уровень приложения;
  • pages - отдельные страницы или экраны приложения;
  • widgets - блоки интерфейса, из которых состоит страница. Например: верхнее меню, корзина с товарами и т.д. В идеале, страница (page) должна быть максимально "тонкой" и просто располагать внутри себя виджеты, каждый из которых работает независимо от других;
  • features - бизнес-фича, представляющая какую-то ценность для пользователя. Например добавление и удаление товаров из корзины, расчет итоговой суммы и скидки.
  • entities - если упрощенно, то это данные проекта: товар, пользователь, запись в блог и т.д.
  • shared - ресурсы, которые используются внутри приложения всеми остальными слоями. Сюда могут выходить какие-нибудь утилиты, интерфейсы, конфиги сторонних сервисов (подключение к БД, Twilio CLI и т.п.)
Мой опыт работ c архитектурой FSD

Каждый элемент замкнут внутрь себя и содержит все необходимое для работы: ui-компоненты, типы и интерфейсы, утилиты и т.п. Наружу экспортируется только то, что должно быть доступно извне, через публичный API.

Здесь важна иерархия. Элементы могут импортировать и использовать внутри себя только элементы, находящиеся в слое более низкого уровня. Так элемент, находящийся в слое features может использовать элементы из слоев entities и shared, но из своего же слоя или более высоких слоев он ничего применять уже не может. Благодаря этому правилу проект приобретает понятную и четкую структуру.

Например, внося правки в виджет "Корзина" мы можем не опасаться, что они как-то затронут виджет "Список товаров" или изменят логику фичи (feature) ответственной за расчет размера скидки. Изменения коснутся только самого виджета и страниц, на которых он располагается. Соответственно, чем на более низкий уровень мы опускаемся, тем более глобальными и опасными становятся правки.

Итак, опишу подход, который помогает мне придавать проектам удобную для работы структуру.

Шаг 1. Слой Shared: сторонние сервисы

Настройка подключения к базе данных, аутентификации, служб для отправки SMS и т.п. Для этих целей у меня создана отдельная папка "shared/services".

shared ├── services │ ├── pinata │ ├── prisma │ │ ├── config │ │ │ ├── prisma.ts │ │ ├── index.ts │ ├── twilio

Также можно сразу прописать основные типы, связанные с работой REST API и т.п.

shared ├── types │ ├── api │ ├── result

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

Шаг 2. Определение Entities

Этот шаг также не требует каких-то сложных оценок проекта. Здесь прописываются бизнес-сущности, а также интерфейсы взаимодействия с ними. Цель этапа - максимально абстрагировать работу с entiites для всех элементов, находящихся в более верхних слоях.

Например, бизнес-сущность NFT (в моем примере использовалась Prisma)

entities ├── collection ├── drop ├── nft │ ├── db │ │ ├── dbGetNftBasic.ts │ │ ├── dbGetNftBasicList.ts │ │ ├── dbGetNftMint.ts │ │ ├── ... │ ├── selectors │ ├── types │ ├── ui │ │ ├── BuyButton │ ├── utils │ ├── index.ts
  • db - здесь прописываются все обращения к базе данных, которые будут использоваться в приложении. Это позволяет как упростить тестирование, так и проводить оптимизацию запросов, если потребуется
  • selectors - селекторы, используемые для запросов к БД через Prisma
  • types - типы, сгенерированные из селекторов. В принципе, можно объединить selectors и types в одну папку, при желании.
  • ui - если есть какие-то общие UI-компоненты. В моем случае это была кнопка, переводящая посетителя на страницу для оформление покупки NFT. Компонент кнопки расположен именно здесь так как используется в нескольких фичах, а обмен кода между фичами запрещен (так как находятся в одном слое features).
  • utils - вспомогательные функции
  • index.ts - здесь определяется, что из всего вышеперечисленного будет доступно для остального кода.

Шаг 3. Widgets и Features

Эти два слоя содержат в себе уже бизнес-логику. Я объединил эти два слоя в один шаг, так как граница между ними не всегда очевидна. Формально, разграничение должно быть следующим:

Feature: какое-то ценное действие вроде регистрации пользователя или формы редактирования товара.

Widget: выводит компоненты, создавая из них единый изолированный блок, который уже можно размещать на страницах.

На практике здесь много пограничных ситуаций. Поэтому я рекомендую по умолчанию любой элемент рассматривать как Widget и размещать весь связанный с ним код в его папке. Если какая-то часть виджета окажется восстребованной в другом виджете, то она выводиться в отдельную feature - опускается на слой ниже, чтобы стать доступной для всех widgets.

Порядок действий выходит следующий:

  1. После того, как прописали все связи со сторонними сервисами и бизнес-сущности, начинаем создавать виджет в отдельной папке, помещая в нее абсолютно весь необходимый код, не стараясь выделать его части в отдельные элементы, размещенные вне этой папки.
  2. В идеальном варианте виджет целиком сможет замкнуться внутри себя, взаимодействовать только с нашими entites и свободно размещаться на любой странице. Весь код, связанный с ним, будет размещен в одном месте и гарантированно не влиять на другие части приложения.
  3. Если, при добавлении других виджетов выясняется, что ему требуется использовать код нашего виджета, например, форма для ввода данных банковской карты и обработка запроса на списание денежных средств, то эта форма выносится как отдельный элемент в слой feature. Теперь разные виджеты могут использовать ее в своих целях, а виджет, от куда ее извлекли, сохранился таким же изолированным, как и был.
  4. В случае, когда по какой-то причине, отправка запроса на списанеи денежных средств с карты потребуется в другой feature, мы перенесем его на самый низкий уровень - shared. Получится что-то вроде (за работу с картами отвечает сервис authorizenet):
shared ├── services │ ├── authorizenet │ │ ├── config │ │ ├── utils │ │ │ ├── chargeCreditCard.ts │ │ ├── index.ts │ ├── twilio

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

Шаг 4. Pages

По сути - самый верхний уровень. Страницы должны быть максимально "тонкими" и отвечать только за загрузку связанных с ними данных(загрузка с сервера информации о товаре, на основе productSlug в url страницы, например), а также порядок размещения виджетов.

Итого

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

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

Код, составленный таким образом, довольно легко поддерживать. Вы сразу можете определить "опасные" и "безопасные" для правки части, а также понять, на что именно повлияют изменения. Изменяя форму для ввода данных банковской карты, расположенную в слое features, вы можете совершенно не переживать за непредвиденные эффекты в других features и, тем более, коде, расположенным в более низких слоях. Достаточно лишь будет проверить те несколько виджетов, где форма используется. Если утилита размещена в папке какого-то виджета, это значит, что она используется только для этого виджета и нигде более.

Сравните, насколько это удобнее, чем иметь дело с переполненными папками components и lib.

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

22
5 комментариев

При работе с FSD я столкнулся с проблемой на уровне entities. Одна entity должна ссылаться на другую, но связей на одном уровне по FSD быть не дложно. Как вы решили эту проблему?

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

По возможности связь лучше поднять на уровень выше. Зависит от деталей связи.