Перейти к содержанию

Модули ​

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

Ответственность и владелец ​

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

Самостоятельность ответственности определяется вопросами:

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

Props, импорты, Context, локальное состояние, lifecycle-код или количество файлов сами по себе не доказывают самостоятельность ответственности. Если ответственность самостоятельна, она получает ровно один модуль-владелец.

Для доменного сценария выбор владельца дополнительно ограничен ролью слоя: такой сценарий принадлежит модулю domains. Этот модуль является доменом и дополнительно владеет предметным контрактом, ожидаемыми неуспешными исходами и адаптацией источников. Модуль compositions может владеть представлением страницы или экрана и использовать готовый доменный API, но не становится владельцем сценария из-за места вызова, единственного потребителя или отсутствия уже созданного доменного модуля. Подробная граница описана в разделе Слои.

Размер не определяет модуль. Модуль может состоять из одного файла реализации, а большая папка может оставаться частью другого владельца.

Ближайшая граница ​

Каждый файл принадлежит ближайшей модульной границе и реализует ответственность этого модуля. Вид кода не меняет владельца: framework-компонент, Provider, Guard, hook, store, service или utility остаются внутренней реализацией ближайшего модуля.

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

text
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Клиентская граница фреймворка, участвующая в серверной предварительной отрисовке и выполняющаяся при гидратации и в браузере
browserBrowser-only и динамически подключаемый клиентский код; фасет всегда импортируется динамически с отключённым SSR
serverКод только для сервера, недоступный универсальной и клиентской среде выполнения

index обязателен. Остальные фасеты создаются только при наличии реального потребителя и несовместимых требований к среде.

Ограничение среды распространяется на все исполняемые импорты и реэкспорты фасета, включая транзитивные. Имя файла, директива use client, tree shaking или проверка typeof window сами по себе не доказывают совместимость.

Один исполняемый экспорт размещается в минимально подходящем фасете и не дублируется между ними. Универсальный фасет не реэкспортирует специализированные фасеты.

text
auth/
├── index.ts       # Обязательный универсальный фасет
├── client.ts      # При необходимости
├── browser.ts     # При необходимости
├── server.ts      # При необходимости
└── ...            # Внутренняя реализация

Эта файловая форма представляет уже определённую публичную границу. Наличие index.ts само по себе не создаёт модуль.

Зависимости ​

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

ts
// Допустимо
import { Button } from '@/ui/button'

// Недопустимый глубокий импорт
import { Button } from '@/ui/button/button'

Направления между слоями, same-layer импорты, роль групп и требования к lint-проверке описаны в разделе Зависимости.

Корень модуля ​

Корень модуля не используется как плоский каталог реализации. В нём находятся:

  • объявленные публичные фасеты;
  • не более одного опционального главного implementation- или assembly-файла.

Главный файл непосредственно реализует или собирает ответственность модуля и однозначно соответствует ей по имени и роли. Возможные примеры:

text
header/header.tsx
footer/footer.tsx
auth-guard/auth-guard.provider.tsx

Архитектурным владельцем остаётся модуль, а главный файл является основной реализацией или точкой её сборки. Если проблемно доказать, что файл является главным, его не выносят в корень. Вся остальная реализация размещается в сегментах.

Главный framework-файл не обязан экспортироваться через index. Модуль открывает его через минимально подходящий фасет среды выполнения:

text
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, но каталог компонентной единицы не содержит другие компонентные единицы или вложенные модули.

text
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, собственные зависимости и область жизни и подчиняется всем правилам модулей.

text
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 родителя.

Вложенный модуль может рекурсивно содержать другие вложенные модули. Именно модульная граница, а не каталог компонента, создаёт каждый следующий структурный уровень. Если вложенный модуль становится нужен нескольким внешним владельцам, его переносят в минимальную общую область.

Колокация и рост ​

Код создаётся в самой узкой допустимой области внутри своего архитектурного владельца:

  1. Вспомогательный код, нужный одной компонентной единице, колоцируется в её файле или каталоге.
  2. Каждая выделенная framework-компонентная единица размещается на общем внутреннем уровне модуля, даже если используется только одним другим компонентом.
  3. Код, нужный нескольким внутренним единицам, поднимается в их ближайший общий сегмент.
  4. При появлении самостоятельной подответственности вокруг кода создаётся вложенный модуль.
  5. Вложенный модуль переносится в общую область, когда прямой доступ к нему требуется внешним владельцам.

Расширение числа потребителей меняет колокацию внутри владельца. Появление самостоятельной ответственности меняет модульный граф.

Состояние и жизненный цикл ​

Состояние принадлежит модулю, ответственность которого определяет его смысл, допустимые изменения и область жизни. Место хранения состояния не переносит владение в store, Provider или Context.

Для каждого долгоживущего ресурса модуль-владелец определяет:

  • место создания;
  • момент запуска;
  • область жизни;
  • допустимое число экземпляров;
  • способ остановки, отмены или освобождения.

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

Размещение экземпляра на уровне файла не доказывает область жизни всего приложения. Точка входа app может запустить ресурс через публичный API модуля, но не становится его владельцем.

Связанные правила ​

Документация SLM Design