Modules
Modules — сущность, определяющая существующие модули. Считаем их микрозависимостями в нашем коде. Модули на одном уровне могут подключать другие модули, и все могут подключаться к ним. Могут как иметь конкретную бизнес-ценность, так и быть объёмными утилитами. Модули могут иметь подмодули для решения конкретных задач. Никто не может напрямую влезать в сущности модуля, кроме index.ts.
Важность уровня
Модули — сердце всего приложения. Можно сказать, что все остальные уровни созданы только ради этого. И в целом в FEOD именно модулям отдаётся наибольшее внимание. Поэтому важно уделить им особое внимание. Здесь решаются многие задачи: разделение ответственности на проекте между командами и их членами, разделение логики, связывание частей, которые должны быть связаны друг с другом, разграничение зон ответственности самой логики и многое другое.
Несмотря на простоту идеи модулей, они имеют больше всего нюансов в работе, и именно от вашего отношения к соблюдению правил именно данного уровня будет зависеть, получите ли вы big ball of mud или же хорошо организованный и понятный проект.
В работе модулей есть много различных нюансов, поэтому можете постепенно изучать его по мере возникновения вопросов и противоречий.
Как понять, что это часть Modules
Реализует бизнес-фичи комплексно:
- Реализует переиспользуемую комплексную логику
- Это подмодуль другого модуля. Тоже находится в Module, но в рамках другого модуля
- Это реализует интеграцию, требующую взаимодействия с другими модулями
Когда это не common:
- Если это сложный модуль, в который протекает бизнес-логика
- Если это должно взаимодействовать с другими модулями
- Если требуется несколько файлов для реализации функциональности
Изоляция модуля
Модули должны быть изолированы друг от друга настолько, насколько это возможно. Доступ к внутренностям модуля возможен только через публичный API, экспортируемый через index.ts.
Кросс-модульное взаимодействие
Наибольшая опасность в модульной архитектуре — это кросс-модульное взаимодействие. Для этого есть несколько причин:
- Сильная связанность между модулями
- Сложность в понимании структуры проекта
- Неопределённые зависимости у модуля
Как это решается:
- Модули должны быть изолированы друг от друга настолько, насколько это возможно
- Ограничения зигзагообразных импортов (когда модуль А или любой подмодуль модуля А импортирует любой подмодуль модуля Б)
- Применяйте IoC (Inversion of Control) для управления зависимостями
Структура модуля
Важно
FEOD не настаивает на внутренней структуре модуля, а только на его публичном API и папке /modules/. Это позволяет свободно организовывать код внутри модуля, так как он не ограничен правилами FEOD.
Полный пример модуля
Выберите файл в дереве слева
Документация
Также хорошим тоном является документировать каждый модуль и его подмодули. Это помогает разработчикам быстрее понять, как использовать модуль, а AI-агентам — его назначение и поведение. Не обязательно писать полную документацию, но хотя бы основные моменты и примеры использования.
Вполне хорошо подойдёт использовать это как README.md-файл в модуле.
Подмодули
Модули могут содержать подмодули для организации сложной функциональности. Подмодули изолированы и не могут использоваться вне родительского модуля.
Пример структуры с подмодулем
export { UserProfile } from './modules/UserProfile'Благодаря фрактальности подмодули можно выстраивать в сколь угодно глубокие структуры. Это может привести к сложности в понимании структуры проекта и к увеличению количества файлов. Старайтесь избегать создания слишком глубоких структур и используйте подмодули только тогда, когда это действительно необходимо.
Не стоит бояться и обратного процесса, когда вы разбиваете разросшийся модуль на подмодули. Это поможет в будущем вынести подмодуль за пределы модуля и использовать его в других проектах.
Важно
По техническим причинам в JavaScript мы не можем заставлять подмодуль импортировать публичный интерфейс модуля (иначе появляется высокий риск циклического импорта). Поэтому де-факто подмодули воспринимаются как независимая часть модуля. Таким образом, из подмодуля можно импортировать любые части родительского модуля, но не наоборот. Однако импорт частей родительского нужно считать исключением и стараться избегать его. Лучше, если подмодуль является полностью автономным модулем без зависимостей от родительского модуля. В этом случае его будет легко перенести в другое место, если он понадобится где-то ещё.
Взаимодействие между модулями
Модули могут взаимодействовать друг с другом, но с ограничениями:
- Модули не могут обращаться к подмодулям своего подмодуля
- Модули не могут обращаться к подмодулям других модулей
- Модули могут обращаться к нижележащим модулям и подмодулям
- Лучше минимизировать взаимодействие между модулями и использовать IoC (Inversion of Control)
Эти ограничения могут давать сложные сценарии, которые всё равно возможно разрешить.
Пример взаимодействия через IoC
Для многих решений на фронтенде использование DI-контейнеров является избыточным решением, так как оно неявно всё объединяет (tree-shaking не умеет в динамическую композицию) и усложняет вход начинающих разработчиков. Однако с архитектурной точки зрения они не так уж и плохи, например, с FEOD на бэкенде могут оказаться хорошим решением.
Однако мы всё ещё можем уменьшать связанность между модулями, используя IoC как подход для управления зависимостями между модулями.
interface NotificationService {
notify(message: string): void
}
export function useUsers(notificationService?: NotificationService) {
// Использование сервиса
const notify = notificationService?.notify || console.log
// ... остальная логика
}Таким образом, вместо импорта сервиса напрямую, мы импортируем интерфейс и используем его в своём коде, давая возможность внедрять различные реализации сервиса в зависимости от ситуации из app.
Сквозные модули
Это расширенная идея модулей, которая решает проблему, когда несколько модулей используют один и тот же модуль, но не хотят делать его публичным. Например, это может быть закрытым ядром, а публичные модули — лишь адаптеры к нему для различных ситуаций. Такой тип модулей необходим, так как есть правило, запрещающее импортировать подмодули модуля. В случае сквозного модуля это правило не применяется, так как он не имеет своей логики, а вместо этого является контейнером для других модулей.
Звучит сложно, но на практике это выглядит как:
Таким образом вы можете импортировать
import { npm } from '@/modules/package-manager/npm'
import { yarn } from '@/modules/package-manager/yarn'
import { pnpm } from '@/modules/package-manager/pnpm'И только подмодули сквозного модуля могут импортировать
import { core } from '@/modules/package-manager/_/'
import { somePrivate } from '@/modules/package-manager/_/some-private'Однако рекомендуется использовать относительный путь
import { core } from '../_/'
import { somePrivate } from '../_/some-private'Однофайловые модули
Это вырожденный случай модуля, когда в одном файле находится вся логика модуля. Это может быть удобно для маленьких модулей, которым создание полноценного модуля нецелесообразно. Либо когда вы выбрали путь commonless и необходимость в утилитарных модулях кратно возрастает.
В целом не представляют из себя ничего особенного. Однако стоит предостеречь от злоупотребления ими, так как это может вылиться в захламление списка модулей.
Полезной рекомендацией будет хранить альтернативу README.md как комментарий в начале файла модуля. В случае однофайлового модуля вы вольны давать ему произвольное имя.
Что касается однофайловых подмодулей, то они выглядят сильно сомнительнее и не рекомендуются. У вас нет ограничения на строгую структуру модуля, поэтому вы можете организовывать код как угодно и поместить такой модуль просто как содержимое данного модуля.
Могут ли модули создавать страницы и роуты?
Да, могут. Но только если это важно для модуля. Например, это позволит сохранить большую часть API приватной в модуле. Вместо того чтобы делать это API публичным для всех.
Выберите файл в дереве слева
Тестирование модулей
Каждый модуль хорош тем, что он считается самостоятельной единицей. Соответственно тестирование модуля должно происходить независимо от других модулей. Это позволяет тестировать модуль в изоляции и прямо внутри модуля. Если вы тестируете несколько модулей за раз, то это следует считать уже интеграционным тестом, который должен происходить в app-уровне или, по крайней мере, в родительском (если тестируются связи между подмодулями).
Лучшие практики
1. Не увлекайтесь излишним делением на модули
Эта идея может привлекать, однако это не всегда хорошая идея. Старайтесь избегать излишнего деления на модули, так как это может привести к сложности в понимании структуры проекта и к увеличению количества файлов. Вам будет сложнее делить зоны ответственности и контролировать, кто что импортирует.
Главная проблема связана с тем, что одна из главных проблем модульных подходов, к которым FEOD тоже относится, это межмодульное взаимодействие. Чем больше модулей, тем больше связей между ними, тем сложнее контроллировать связи между модулями, держать их изолированными насколько возможно.
Поэтому создание нового модуля должно быть крайне осторожным и обоснованным. Вначале обдумайте не является ли это подмодулем? Или нельзя ли просто объединить в один модуль с уже существующим? Иногда даже это может быть разложение на 2 сущности в разных модуля, вместо вынесения этого функционала в отдельный модуль.
2. Не суйтесь в модули то, что не относится к ним
Если какой-то элемент не относится к модулю, то лучше его вынести в другое место. Например, если у вас есть компонент, который не относится напрямую ни к какому модулю, то лучше вынести его в common. Иначе вы рискуете оказаться в ситуации, когда границы модуля размываются, а также повышается риск реализовать одну и ту же функциональность в разных местах. Хотя если предполагается, что нигде больше данная функциональность не используется, то можно оставить её в модуле.
Если нечто начинает вырисовываться как самостоятельная сущность внутри модуля, то стоит рассмотреть его вынесения в подмодуль, но с этим не следует торопиться.
В целом главный совет относиться к делу разумно и не впадать в крайности. Вынесение любых частей может быстро превратить список модулей и common в хаос.
3. Воспринимайте модули как самостоятельные npm пакеты
Философия разработки с модулями основана на том, что модуль является самостоятельной единицей, которая может быть использована в других проектах. Это позволяет использовать модули как самостоятельные npm-пакеты и решать проблемы с зависимостями между модулями.
Соответственно, чем менее зависим один модуль от других, тем проще его переиспользовать и тестировать (независимо). Но тут тоже следует сохранять баланс. Не стоит многократно усложнять разработку ради следования правилам, особенно если в этом нет реальной необходимости. Вы вольны начать с более простых имплементаций и по мере роста компетенций добавлять более хитрые приёмы и паттерны, но они ни в коем случае не должны быть самоцелью.
4. Распределяйте ответственность
Модули хороши тем, что они позволяют делить ответственность между членами команды. Каждый модуль должен иметь чёткое назначение и ответственных за него. Это позволит проще контролировать проект с точки зрения кодовой базы и добиться меньшей зависимости между разработчиками, не прибегая к таким сложным техническим решениям, как микрофронтенды или разнесение на отдельные репозитории.
Если вам нравится идея чёткого разграничения ответственности, то можете записать ответственных за модуль в файле README.md или даже MAINTAINERS.md модуля. Также стоит задуматься о переносе в модуль роутов, связанных с этим модулем (решает схожую задачу с контролем ответственности).