22 KiB
Модули
Модуль является владельцем самостоятельной ответственности. Публичный API, зависимости, состояние, жизненный цикл и внутренняя реализация ответственности относятся к модулю независимо от того, в каком файле или механизме выполняется код.
Ответственность и владелец
Перед созданием модуля ответственность формулируется без упоминания желаемой папки, файла или библиотеки.
Самостоятельность ответственности определяется вопросами:
- какой один результат или поведение она обеспечивает;
- что модуль должен делать сам для получения этого результата;
- какие возможности ему нужны от других модулей;
- нужен ли внешним потребителям собственный контракт;
- требуют ли зависимости отдельного архитектурного владения;
- владеет ли она смыслом данных или изменяемого состояния;
- нужна ли ей собственная область жизни;
- можно ли назвать её независимо от внутренней реализации.
Props, импорты, Context, локальное состояние, lifecycle-код или количество файлов сами по себе не доказывают самостоятельность ответственности. Если ответственность самостоятельна, она получает ровно один модуль-владелец.
Для доменного сценария выбор владельца дополнительно ограничен ролью слоя: такой сценарий принадлежит модулю domains. Этот модуль является доменом и дополнительно владеет предметным контрактом, ожидаемыми неуспешными исходами и адаптацией источников. Модуль compositions может владеть представлением страницы или экрана и использовать готовый доменный API, но не становится владельцем сценария из-за места вызова, единственного потребителя или отсутствия уже созданного доменного модуля. Подробная граница описана в разделе Слои.
Размер не определяет модуль. Модуль может состоять из одного файла реализации, а большая папка может оставаться частью другого владельца.
Ближайшая граница
Каждый файл принадлежит ближайшей модульной границе и реализует ответственность этого модуля. Вид кода не меняет владельца: framework-компонент, Provider, Guard, hook, store, service или utility остаются внутренней реализацией ближайшего модуля.
Вложенный модуль начинает новую границу. Его содержимое реализует выделенную подответственность, а сам вложенный модуль как единица участвует в реализации общего результата родителя:
checkout/ # Владеет ответственностью checkout
├── checkout.tsx # Реализует checkout
├── components/ # Реализуют checkout
└── modules/
└── form-session/ # Владеет подответственностью form session
├── form-session.provider.tsx
└── hooks/ # Реализуют form session
Родитель и вложенный модуль не владеют одной ответственностью одновременно. Родитель определяет общий результат, а вложенный модуль — отдельно сформулированную связную часть этого результата.
Граница владения
Модуль определяет:
- публичные возможности ответственности;
- допустимые внешние зависимости;
- модели и правила, принадлежащие ответственности;
- состояние и источник истины;
- создание и очистку долгоживущих ресурсов;
- устройство внутренней реализации.
Внутренний файл может технически выполнять часть или всю эту работу. Архитектурное владение остаётся у модуля и не переносится в компонент, Provider, store или другой механизм реализации.
Публичный API
У модуля один логический публичный API. Он является контрактом между владельцем и внешними потребителями, а не перечнем всех внутренних файлов.
Публичный API:
- открывает только возможности, необходимые реальным внешним потребителям;
- скрывает детали реализации и изменяемые внутренние механизмы;
- не раскрывает внутренние сегменты;
- представлен объявленными публичными фасетами;
- является единственным способом доступа к модулю извне.
Внутри своей границы модуль может обращаться к собственным файлам напрямую. Требование публичного API действует при пересечении модульной границы.
Фасеты
Фасет — публичная точка входа, открывающая часть единого API для определённой среды выполнения.
| Фасет | Назначение |
|---|---|
index |
Универсальные типы и исполняемый код, совместимый с серверным рендерингом, включая RSC, и клиентским выполнением |
client |
Клиентская граница фреймворка, участвующая в серверной предварительной отрисовке и выполняющаяся при гидратации и в браузере |
browser |
Browser-only и динамически подключаемый клиентский код; фасет всегда импортируется динамически с отключённым SSR |
server |
Код только для сервера, недоступный универсальной и клиентской среде выполнения |
index обязателен. Остальные фасеты создаются только при наличии реального потребителя и несовместимых требований к среде.
Ограничение среды распространяется на все исполняемые импорты и реэкспорты фасета, включая транзитивные. Имя файла, директива use client, tree shaking или проверка typeof window сами по себе не доказывают совместимость.
Один исполняемый экспорт размещается в минимально подходящем фасете и не дублируется между ними. Универсальный фасет не реэкспортирует специализированные фасеты.
auth/
├── index.ts # Обязательный универсальный фасет
├── client.ts # При необходимости
├── browser.ts # При необходимости
├── server.ts # При необходимости
└── ... # Внутренняя реализация
Эта файловая форма представляет уже определённую публичную границу. Наличие index.ts само по себе не создаёт модуль.
Зависимости
Модули одного слоя могут зависеть друг от друга. При пересечении любой модульной границы код использует публичный фасет целевого модуля, а общий граф модулей должен оставаться ацикличным.
// Допустимо
import { Button } from '@/ui/button'
// Недопустимый глубокий импорт
import { Button } from '@/ui/button/button'
Направления между слоями, same-layer импорты, роль групп и требования к lint-проверке описаны в разделе Зависимости.
Корень модуля
Корень модуля не используется как плоский каталог реализации. В нём находятся:
- объявленные публичные фасеты;
- не более одного опционального главного implementation- или assembly-файла.
Главный файл непосредственно реализует или собирает ответственность модуля и однозначно соответствует ей по имени и роли. Возможные примеры:
header/header.tsx
footer/footer.tsx
auth-guard/auth-guard.provider.tsx
Архитектурным владельцем остаётся модуль, а главный файл является основной реализацией или точкой её сборки. Если проблемно доказать, что файл является главным, его не выносят в корень. Вся остальная реализация размещается в сегментах.
Главный framework-файл не обязан экспортироваться через index. Модуль открывает его через минимально подходящий фасет среды выполнения:
theme/
├── index.ts # Универсальные публичные типы
├── client.ts # Экспортирует ThemeProvider и useTheme
├── theme.provider.tsx # Главная framework-реализация
├── context/
│ └── theme-context.ts
├── hooks/
│ └── use-theme.ts
├── types/
└── styles/
ThemeProvider может хранить состояние, синхронизироваться с платформой, предоставлять Context и очищать ресурсы при размонтировании. Он технически реализует ответственность, но её смысл, контракт, зависимости и область жизни определяет модуль theme.
Framework-компоненты
SLM не вводит собственный вид компонента. Модуль может содержать любые framework-компоненты: визуальные элементы, Providers, Guards, Error Boundaries и другие сущности, которые используемый фреймворк считает компонентами.
Помимо опционального главного framework-файла в корне, остальные framework-компоненты организуются на одном внутреннем уровне относительно модуля. Они могут быть одиночными файлами или каталогами с локальными стилями, типами, hooks, тестами и внутренним index.ts, но каталог компонентной единицы не содержит другие компонентные единицы или вложенные модули.
main-layout/ # Модуль
├── index.ts # Публичный фасет
├── main-layout.tsx # Главная реализация
├── components/ # Сегмент
│ ├── header/
│ │ ├── index.ts # Локальная точка входа
│ │ ├── header.tsx
│ │ ├── styles/
│ │ └── types/
│ ├── navigation-item.tsx
│ └── footer.tsx
└── providers/ # Сегмент
└── layout-state/
├── layout-state.provider.tsx
├── hooks/
└── types/
Header может рендерить NavigationItem, но их файловые области остаются соседними относительно main-layout. Ограничение относится к организации файлов, а не к runtime-дереву фреймворка.
Локальный index.ts компонентной единицы упрощает импорты внутри модуля, но не создаёт публичный API, владельца или узел графа зависимостей. Если компонент нужен внешнему потребителю, фасет модуля явно реэкспортирует его, а внешний код по-прежнему импортирует модуль.
Названия components, providers, styles, types и hooks являются примерами локального стайлгайда, а не обязательными путями SLM.
Вложенные модули
Вложенный модуль — обычный модуль, физически размещённый внутри родительского. Он владеет отдельно сформулированной связной подответственностью, имеет публичный API, собственные зависимости и область жизни и подчиняется всем правилам модулей.
checkout/ # Родительский модуль
├── index.ts
├── checkout.tsx # Главная реализация checkout
├── components/
│ ├── order-summary.tsx
│ └── submit-order.tsx
└── modules/
└── form-session/ # Вложенный модуль
├── index.ts # Универсальные публичные типы
├── client.ts # Экспортирует Provider и hook
├── form-session.provider.tsx # Главная реализация form session
├── hooks/
│ └── use-form-session.ts
└── types/
Framework-компонент не превращается в архитектурную сущность. Если окружающему его коду требуется самостоятельная ответственность, вокруг кода создаётся вложенный модуль, а компонент остаётся его обычной framework-реализацией.
Код родительского модуля использует вложенный модуль через его публичный API. Код за пределами родительской границы не импортирует вложенный модуль напрямую и получает необходимые возможности через API родителя.
Вложенный модуль может рекурсивно содержать другие вложенные модули. Именно модульная граница, а не каталог компонента, создаёт каждый следующий структурный уровень. Если вложенный модуль становится нужен нескольким внешним владельцам, его переносят в минимальную общую область.
Колокация и рост
Код создаётся в самой узкой допустимой области внутри своего архитектурного владельца:
- Вспомогательный код, нужный одной компонентной единице, колоцируется в её файле или каталоге.
- Каждая выделенная framework-компонентная единица размещается на общем внутреннем уровне модуля, даже если используется только одним другим компонентом.
- Код, нужный нескольким внутренним единицам, поднимается в их ближайший общий сегмент.
- При появлении самостоятельной подответственности вокруг кода создаётся вложенный модуль.
- Вложенный модуль переносится в общую область, когда прямой доступ к нему требуется внешним владельцам.
Расширение числа потребителей меняет колокацию внутри владельца. Появление самостоятельной ответственности меняет модульный граф.
Состояние и жизненный цикл
Состояние принадлежит модулю, ответственность которого определяет его смысл, допустимые изменения и область жизни. Место хранения состояния не переносит владение в store, Provider или Context.
Для каждого долгоживущего ресурса модуль-владелец определяет:
- место создания;
- момент запуска;
- область жизни;
- допустимое число экземпляров;
- способ остановки, отмены или освобождения.
Подписка, обработчик событий, таймер, наблюдатель, запрос или соединение не остаются активными после завершения своей области жизни. Очистку может технически выполнить framework-компонент или сам фреймворк, но ответственность за корректную границу остаётся у модуля.
Размещение экземпляра на уровне файла не доказывает область жизни всего приложения. Точка входа app может запустить ресурс через публичный API модуля, но не становится его владельцем.
Связанные правила
SLM-MODULE-A004SLM-MODULE-A014SLM-DOMAIN-R022SLM-DOMAIN-R023SLM-DOMAIN-R024SLM-DOMAIN-R025SLM-DOMAIN-R026SLM-MODULE-R006SLM-MODULE-R011SLM-MODULE-R012SLM-MODULE-R020SLM-MODULE-R021SLM-DEPENDENCY-A005SLM-NESTED_MODULE-A010SLM-LIFECYCLE-R013SLM-ENVIRONMENT-R016SLM-ENVIRONMENT-R017SLM-ENVIRONMENT-R018SLM-ENVIRONMENT-R019