О способах разбиения проекта по модулям и позиции методологии
-
Методология рекомендует разбивать проект следующим образом:
- Сначала по слайсам - согласно скоупу влияния модуля
app
,processes
,pages
,features
,entities
,shared
- Затем по доменам - согласно конкретной функциональности БЛ
Нейминг очень зависит от конкретного проекта
- И наконец по типам - согласно назначению модуля в коде и реализации
ui
,model
,api
,lib
- Сначала по слайсам - согласно скоупу влияния модуля
-
Структура приложения должна помогать внедрять новых людей, предсказуемо вносить изменения и развивать проект, в условиях постоянно меняющихся требований
-
Разбиение согласно методологии - в конце статьи
Каждый из нас застал на своем пути проекты примерно такой структуры
└── src/
├── api/
├── components/
├── containers/
├── helpers/
├── pages/
├── store/
└── index.tsx/
Ведь на первый взгляд - такой подход кажется простым
- Все лежит на верхнем уровне
Нет глубокой вложенности и длинным импортам!
- В каждом модуле конкретно понятно что лежит, по его прямому назначению
В store - все экшены/редьюсеры/эффекты приложения, в хелперах - все хелперы и т.д.
И более того - каждый из нас, поначалу, создавал проекты с такой структурой!
Особенно если мы говорим про React
(*) Запомним эту конструкцию, и вернемся к ней позднее
Проекты не живут в статике. Они постоянно развиваются и дополняются требованиями, новым функционалом.
При этом мы не знаем новые требования наперед - и это тоже вызывает проблемы, при структурировании модулей
Потому хочется, чтобы архитектура и структура приложения - позволяла масштабировать кодовую базу и комманду - с наименьшими негативными последствиями для проекта
-
Обычно изначально проект разрабатывается под изначальный слепок требований с определенным контекстом
Если мы попытаемся добавить новые/изменить прежние - поначалу еще как-то удается их вписать в существующую архитектуру
Но скорей всего - если архитектура приложения не заточена под изменения в функционале - кодобаза превратится в спагетти-код, который будут понимать только те, кто стояли у истоков
-
У нас, разработчиков, нет "шара предвидения". Тем не менее мы постоянно пытаемся что-то предсказать
- какой модуль будет переиспользован в дальнейшем (а может вынести все в shared?),
- где добавить прослойку для уменьшения прямой связности модулей
- и т.д.
-
И из-за этого, обычно, проект - это большой набор костылей, подпирающих динамически меняющийся функционал
Будь то A/B тесты, или же просто новый пласт требований - который "плохо кладется" под имеющуюся архитектуру
-
Ситуация становится еще "веселее", когда старый код уже настолько покрылся мхом - что начинают сыпаться баги из-за регрессий и высокой связности
А поскольку о модуляризации и изолированности изначально чаще всего никто не хотел/не успел подумать - нет возможности взять и отрефакторить начисто только один модуль
В некоторых случаях это может даже загубить проект, если не соблюдаются практики код-ревью, рефакторинга и покрытия тестами
-
По итогу, чаще всего, получаем то, чего хотели избежать в проекте
- Оверсложность
- Непредсказуемость
- Конфликты при добавлении изменений
- Cложность в освоении новыми людьми
- и т.д.
Я думаю мало кто из нас может похвастаться талантом "провидца", и разделять модули в приложении под будущие изменения корректно
А потому, нам - разработчикам - стоит принять следующее:
- Контекст разработки и доменной области каждого конкретного проекта - равен нулю в начале, и увеличивается только с развитием проекта
Т.е. мы в подавляющем большинстве случаев не знаем куда положить определенную фичу, какой модуль может быть переиспользован в будущем для приложения и т.д.
- Проект постоянно будет меняться, а потому - лучше изначально готовить архитектуру к возможным изменениям
При этом важен баланс: возможно, не стоит добавлять прослойку для работу с API "на будущее"
Однако логика должна быть распределена так - чтобы мы могли завтра ее относительно легко дополнить/переиспользовать или же вообще удалить и написать именно эту часть по-новой
Вернемся к структуризации.
Очень хочется, чтобы структура и архитектура проекта позволяла бы снизить негативное влияние перечисленных выше проблем
Но увы - на данный момент в мире фронтенда нет каких-то устоявшихся подходов, которые бы встречались из проекта в проект
Да, у нас есть принципы
Separation of Concerns
, естьDDD
, естьSOLID
и т.д.И даже их каждый интерпретирует по-своему - и это еще одна проблема
Однако если отбросить детали - можно условно выделить два базисных подхода к структуризации
FIRST by-types, THEN by-domains
Помните изначальную структуру (*)? Обычно ее называют "плоской"
- Сначала - идет разделение по типам
- Затем - по доменам
Пример структуры
└── src/
├── api/ # API приложения для конкретных доменов
| ├── users/
| └── posts/
├── ui/ # UIKit
├── containers/ # Контейнеры (упаси бог) для конкретных доменов
| ├── users/
| └── posts/
├── helpers/ # Хелперы для конкретных доменов
| ├── users/
| └── posts/
├── pages/ # Страницы для конкретных доменов
| ├── users/
| └── posts/
├── store/ # Store-логика доменов
| ├── users/
| └── posts/
└── index.tsx/
Заметьте, что чаще всего одни и те же сущности/фичи - повторяются на внутреннем уровне верхних модулей
Плюсы/Минусы
- 😊 Низкая вложенность, облегчает импорты
- 😊 Легко реализовать для простых проектов
- 😡 Сложно "прочитать" проект, при добавлении новых людей в команду
Так называемые
discoverability
иnavigation-in-project
- 😡 Идет упор на "технологичное применение" модуля, а не на "функциональное"
Из-за чего сложно "прочитать" проект и поддерживать связи меж модулями в желаемом состоянии
- 😡 Логика размыта по всему проекту
Из-за чего при внесении изменений в проект - она разбивается сразу на все директории на верхнем уровне
Помимо того, что это может привести к конфликтам - это гораздо сложней вычитывать и дебажить
- 😡 Сложно избавиться от сильной связности модулей
Что ведет к непредсказуемому взаимодействию модулей друг на друга
- 😡 Сложно поддерживать модули, при росте кодовой базы
Особенно увидеть и гарантировать - где используются, например контейнеры для постов
FIRST by-domains, THEN by-types
А теперь попробуем наоборот
- Сначала - идет разделение по доменам
- Затем - по типам
Пример структуры
└── src/
├── ui/ # Общий UIKit, без привязки к БЛ
├── helpers/ # Общий набор хелперов, без привязки к БЛ
├── posts/ # Функционал постов (со всеми необходимыми ресурсами)
| ├── (api/)
| ├── ui/
| ├── store/
| ├── helpers/
| ├── (pages/)
| └── posts/
├── users/ # Функционал пользователей (со всеми необходимыми ресурсами)
| ├── (api/)
| ├── ui/
| ├── store/
| ├── helpers/
| ├── (pages/)
| └── posts/
└── index.tsx/
Заметьте, что да - теперь у нас все повторяется внутри сущностей, однако теперь каждая "фича" содержит в себе все что нужно
И исправляя что-то в функционале
users
, вы затрагиваете только эту директорию и не больше!
Плюсы/Минусы
- 😊 Легко добавлять/изменять функциональность
Просто добавить/подправить отдельную папку - без влияния на другие
- 😊 Каждая фича содержит в себе только то что нужно
Нет ничего лишнего, сразу видно все используемое в фиче
В том числе - это упрощает модель (*store) самой фичи
- 😊 Модули изолированы друг от друга
Что значительно помогает в разработке и исправлении ошибок
- 😊 Логика распределена по фичам(доменам), а не по всему проекту
Что упрощает изучение проекта и навигацию по нему
И разработку - в том числе
- 😊 Легко расширять команду
Можно выделить команду/конкретных людей на отдельные фичи(домены) - и они смогут вести разработку независимо друг от друга, с наименьшим числом конфликтов
А также - гораздо легче изучать сам проект (например не весь сразу - а только конкретную функциональность)
- 😊 Проект становится масштабируемым, поддерживаемым и читабельным
- 😡 Требуется много усилий, чтобы оставлять фичи изолированными
Это в целом, достаточно давний вопрос
Но обычно - дело решается добавлением новых абстракций (processes/entities)
Хоть и требуется доп. код, для поддержания изолированности - бенефиты от этого все равно перевешивают
- 😡 Структура чаще всего - становится слишком вложенной
- 😡 Иногда сложно выделить - куда положить логику кода для pages, для виджета, для конкретной сущности и т.д.
Да, как ни странно - мы склоняемся больше к domain-based подходу 😄
Однако понятно, что у него есть свои проблемы
А потому, методология вводит дополнительную группу slices
- для группировки доменов определенного скоупа влияния
app
>processes
>pages
>features
>entities
>shared
- Группа абстракций
slices
: на уровне распределенных скоупов логики приложенияapp
,*processes
,pages
,features
,*entities
,shared
- Группа абстракций
domains
: на уровне конкретной функциональности БЛВсе, что связно с бизнес-логикой вашего приложения для данной абракции фичи:
- (processes)
auth
,payment
, ... - (features)
auth-by-phone
,pay-by-card
,inline-post
, ... - (entities)
user
,post
,task
, ... - ...
- (processes)
- Группа абстракций
types
: на уровне конкретного назначения модуля в кодеui
,model
,lib
,*api
По структуре можно отследить...
- Сначала идет разделение по слайсам
это код для приложения/процесса/страницы/фичи/... ?
- Затем подбирается конкретный домен
в зависимости от БЛ приложения
- И наконец - выбирается тип модуля
в зависимости от технического назначения - UI/BL/Utils/...
Иными словами...
Разделение по доменам(фичам) - замечательно
Но когда все домены лежат в куче в руте - среди этого сложно ориентироваться
Еще сложнее, управлять грамотно скоупом "знания" и "опасности изменений"
А так, мы явно задаем правила из структуры:
- Модуль "знает" только про себя и нижележащие модули, но не выше лежащие
По уровню знания/ответственности
app > *processes > ... > entities > shared
- Чем ниже расположен модуль - тем опаснее вносить в него изменения
Т.к. скорее всего он заиспользован во многих вышележащих местах
По уровню опасности изменений
shared > entities > ... > *processes > app
Таким образом - каждая группа абстракций служит для своих целей, и при этом позволяет на уровне конвенций распределять код
└── src/
├── app/ # Slice: Приложение
| #
├── (processes/) # Slice: Процесс (опционален)
| ├── {some-process}/ # Domain: (н-р процесс CartPayment)
| | ├── model/ # Type: Бизнес-логика
| | └── lib/ # Type: Инфраструктурная-логика (хелперы)
| ... #
| #
├── pages/ # Slice: Страница
| ├── {some-page}/ # Domain: (н-р страница ProfilePage)
| | ├── ui/ # Type: UI-логика
| | ├── model/ # Type: Бизнес-логика
| | └── lib/ # Type: Инфраструктурная-логика (хелперы)
| ... #
| #
├── features/ # Slice: Фича
| ├── {some-feature}/ # Domain: (н-р фича AuthByPhone)
| | ├── ui/ # Type: UI-логика
| | ├── model/ # Type: Бизнес-логика
| | └── lib/ # Type: Инфраструктурная-логика (хелперы)
| ... #
| #
├── entities/ # Slice: Бизнес-сущность
| ├── {some-entity}/ # Domain: (н-р сущность User)
| | ├── ui/ # Type: UI-логика
| | ├── model/ # Type: Бизнес-логика
| | └── lib/ # Type: Инфраструктурная-логика (хелперы)
| ... #
| #
├── shared/ # Slice: Переиспользуемые ресурсы
| ├── api/ # Type: Логика запросов к API
| ├── ui/ # Type: UI-логика
| └── lib/ # Type: Инфраструктурная-логика (хелперы)
| ... #
| #
└── index.tsx/ #
Учитывая вышесказанное - разумно ограничить использование каждой группы в пути файла до максимум одного раза
# Dirname:
{slice}/{domain}/{type}/...
# Для shared нет доменов - т.к. там располагаем переиспользуемую логику
# Без привязки к БЛ
- {shared}/{-}/{ui}/button
- {entities}/{viewer}/{lib}/use-auth
- {features}/{auth-by-phone}/{ui}/...
- {pages}/{profile}/{model}
- ...
Так пропадают все вопросы типа
А могут ли быть вложенные фичи?
А может ли быть entity внутри page?
- и т.д.