docs: обновить архитектуру SLM compositions
All checks were successful
CI/CD Pipeline / build (push) Successful in 44s
CI/CD Pipeline / docker (push) Successful in 1m17s
CI/CD Pipeline / deploy (push) Successful in 8s

- обновлена модель слоёв на app → compositions → business → infra → ui → shared
- добавлены правила composition modules и providers-сегмента
- обновлены правила монорепозитория для слоя compositions
- переписаны React-примеры под page-level композицию
- добавлен пример вариантов структуры compositions
This commit is contained in:
2026-05-26 23:46:11 +03:00
parent 9a962f37b5
commit 89cc873c19
10 changed files with 503 additions and 329 deletions

View File

@@ -1,249 +1,286 @@
---
title: Композиция через Provider
description: Пример композиции бизнес-фабрик screen-модуля через React Provider
description: Пример page-level Provider для composition modules в React-проекте
---
# Композиция через Provider
Раздел показывает, как screen-модуль может получить готовую композицию бизнес-доменов через React Context, не вызывая фабрики внутри себя.
Раздел показывает, как page composition может владеть provider, store и business composition, которые нужны layout, screen и другим composition modules.
## Идея
Screen получает готовый API бизнес-доменов через React Context. Граф фабрик собирается снаружи, например в роутере, а внутренние `parts/` достают нужные домены через хук без пропс-дриллинга.
Page composition хранит состояние и композицию бизнес-доменов на уровне страницы. Layout и screen не импортируют друг друга: они получают доступ к page-level данным через публичный API page composition.
В примере page composition владеет scope-контрактом страницы, но не экспортирует готовый `ProfilePage`, потому что layout и screen импортируют hooks из `pages/profile`. Дерево страницы собирается в `app` или в отдельном entry-point composition module.
## Принципы
1. **Принадлежность.** Provider, Context и хук принадлежат конкретному screen-модулю и лежат в его сегментах.
2. **Внутренний тип.** Тип композиции не экспортируется наружу — это закрывает доступ из бизнес-модулей.
3. **Внутренний хук.** Хук доступа не экспортируется — доступен только внутри screen и его `parts/`.
4. **Публичный Provider.** Только Provider экспортируется через `index.ts`, чтобы роутер мог обернуть screen.
5. **Сборка снаружи.** Граф фабрик собирается в роутере или другом композиторе, screen фабрики не вызывает.
6. **Запрет для бизнеса.** Бизнес-модули не используют провайдеры композиции. Cross-domain зависимости передаются только через аргументы фабрики.
1. **Владение.** Page-level store, provider и business composition принадлежат page composition module.
2. **Обычные сегменты.** Provider, hooks, stores и types лежат в обычных сегментах модуля: `providers/`, `hooks/`, `stores/`, `types/`.
3. **Публичный контракт.** Page composition экспортирует только безопасные hooks, provider и типы, которые нужны другим composition modules или `app`.
4. **Сборка снаружи business.** Business-модули не используют page-level providers. Cross-domain зависимости передаются только через аргументы фабрики.
5. **Без deep imports.** Layout и screen импортируют hooks только из public API page composition.
## Структура модуля
## Структура модулей
```text
screens/main/
├── main.screen.tsx
compositions/pages/profile/
├── profile-business-composition.ts
├── providers/
│ └── main-composition.provider.tsx
│ └── profile-page.provider.tsx
├── hooks/
── use-main-composition.hook.ts
── use-profile-page-store.hook.ts
│ └── use-profile-business-composition.hook.ts
├── stores/
│ └── profile-page.store.ts
├── types/
│ └── main-composition.type.ts
── parts/
│ └── featured-products/
│ ├── featured-products.tsx
│ └── index.ts
│ └── profile-page-state.type.ts
── index.ts
compositions/layouts/profile-main/
├── profile-main.layout.tsx
└── index.ts
compositions/screens/profile/
├── profile.screen.tsx
└── index.ts
```
Сегмент `providers/` — расширение стандартного набора SLM. Спецификация это разрешает: команда сама определяет, какие сегменты используются.
## Тип состояния страницы
## Распределение по сегментам
| Файл | Сегмент | Назначение |
|------|---------|------------|
| `main-composition.type.ts` | `types/` | TypeScript-тип композиции |
| `main-composition.provider.tsx` | `providers/` | Context и Provider-компонент |
| `use-main-composition.hook.ts` | `hooks/` | React-хук доступа |
| `main.screen.tsx` | корень | Корневой компонент screen-модуля |
| `featured-products/` | `parts/` | Вложенный модуль со своим публичным API |
## Тип композиции
Файл: `screens/main/types/main-composition.type.ts`.
Тип описывает, какие бизнес-домены доступны на этой странице. Он не экспортируется через `index.ts`, чтобы другие модули не зависели от внутренней формы композиции screen.
Файл: `compositions/pages/profile/types/profile-page-state.type.ts`.
```ts
import type { CatalogApi } from '@/business/catalog'
import type { CartApi } from '@/business/cart'
export type MainComposition = {
catalog: CatalogApi
cart: CartApi
export type ProfilePageState = {
title: string
isSidebarOpen: boolean
setSidebarOpen: (value: boolean) => void
}
```
## Context и Provider
## Store страницы
Файл: `screens/main/providers/main-composition.provider.tsx`.
Файл: `compositions/pages/profile/stores/profile-page.store.ts`.
Context — внутренняя деталь Provider, наружу он не экспортируется. Значение `null` по умолчанию нужно, чтобы хук мог проверить отсутствие Provider в дереве.
```ts
import { createStore } from 'zustand/vanilla'
import type { ProfilePageState } from '../types/profile-page-state.type'
Provider-компонент экспортируется через `index.ts`. Роутер передаёт в `value` уже собранный граф фабрик со стабильной ссылкой.
export const createProfilePageStore = () =>
createStore<ProfilePageState>((set) => ({
title: 'Profile',
isSidebarOpen: false,
setSidebarOpen: (value) => set({ isSidebarOpen: value }),
}))
```
`createProfilePageStore` не экспортируется через public API модуля. Это внутренняя деталь создания состояния.
## Business composition страницы
Файл: `compositions/pages/profile/profile-business-composition.ts`.
```ts
import { authFactory } from '@/business/auth'
import { profileFactory } from '@/business/profile'
export const createProfileBusinessComposition = () => {
const auth = authFactory()
const profile = profileFactory({ auth })
return { auth, profile }
}
```
Business composition собирается на слое `compositions`, а не внутри business-модулей.
## Provider страницы
Файл: `compositions/pages/profile/providers/profile-page.provider.tsx`.
```tsx
import { createContext, type ReactNode } from 'react'
import type { MainComposition } from '../types/main-composition.type'
import { createContext, useRef, type ReactNode } from 'react'
import type { StoreApi } from 'zustand/vanilla'
import { createProfileBusinessComposition } from '../profile-business-composition'
import { createProfilePageStore } from '../stores/profile-page.store'
import type { ProfilePageState } from '../types/profile-page-state.type'
export const MainCompositionContext = createContext<MainComposition | null>(null)
type ProfileBusinessComposition = ReturnType<typeof createProfileBusinessComposition>
type ProfilePageProviderValue = {
store: StoreApi<ProfilePageState>
business: ProfileBusinessComposition
}
export const ProfilePageContext = createContext<ProfilePageProviderValue | null>(null)
type Props = {
value: MainComposition
children: ReactNode
}
export const MainCompositionProvider = ({ value, children }: Props) => (
<MainCompositionContext.Provider value={value}>
{children}
</MainCompositionContext.Provider>
)
export const ProfilePageProvider = ({ children }: Props) => {
const valueRef = useRef<ProfilePageProviderValue | null>(null)
if (!valueRef.current) {
valueRef.current = {
store: createProfilePageStore(),
business: createProfileBusinessComposition(),
}
}
return (
<ProfilePageContext.Provider value={valueRef.current}>
{children}
</ProfilePageContext.Provider>
)
}
```
## Хук доступа
Context object остаётся технической деталью provider и не должен использоваться внешними модулями напрямую. Наружу экспортируются hooks доступа.
Файл: `screens/main/hooks/use-main-composition.hook.ts`.
## Hooks доступа
Хук остаётся внутренним и не экспортируется через `index.ts` модуля. Он доступен только внутри screen и его `parts/`.
Если хук используется вне Provider, он бросает ошибку. Это даёт раннюю диагностику неправильной композиции дерева.
Файл: `compositions/pages/profile/hooks/use-profile-page-store.hook.ts`.
```ts
import { useContext } from 'react'
import { MainCompositionContext } from '../providers/main-composition.provider'
import { useStore } from 'zustand'
import { ProfilePageContext } from '../providers/profile-page.provider'
import type { ProfilePageState } from '../types/profile-page-state.type'
export const useProfilePageStore = <T,>(selector: (state: ProfilePageState) => T) => {
const ctx = useContext(ProfilePageContext)
export const useMainComposition = () => {
const ctx = useContext(MainCompositionContext)
if (!ctx) {
throw new Error('useMainComposition must be used within MainCompositionProvider')
throw new Error('useProfilePageStore must be used within ProfilePageProvider')
}
return ctx
return useStore(ctx.store, selector)
}
```
## Сборка графа в роутере
Файл: `compositions/pages/profile/hooks/use-profile-business-composition.hook.ts`.
Файл: `app/router.tsx`.
```ts
import { useContext } from 'react'
import { ProfilePageContext } from '../providers/profile-page.provider'
Роутер или другой композитор собирает граф фабрик в точке использования screen. Каждый домен получает свои зависимости через аргументы фабрики.
export const useProfileBusinessComposition = () => {
const ctx = useContext(ProfilePageContext)
Фабрики вызываются вне React-компонента, если не зависят от runtime-параметров. Так API доменов не пересоздаётся на каждый рендер route-компонента.
if (!ctx) {
throw new Error('useProfileBusinessComposition must be used within ProfilePageProvider')
}
```tsx
import { MainScreen, MainCompositionProvider } from '@/screens/main'
import { catalogFactory } from '@/business/catalog'
import { cartFactory } from '@/business/cart'
import { authFactory } from '@/business/auth'
const auth = authFactory()
const catalog = catalogFactory()
const cart = cartFactory({ auth })
const MainRoute = () => (
<MainCompositionProvider value={{ catalog, cart }}>
<MainScreen />
</MainCompositionProvider>
)
return ctx.business
}
```
## Корневой компонент screen
## Layout использует page-level store
Файл: `screens/main/main.screen.tsx`.
Screen получает нужные домены из композиции и достаёт из API готовые хуки, компоненты или функции. В JSX используются уже локальные `useCategories` и `CategoryList`, а не обращение к фабричному API через точку.
Файл: `compositions/layouts/profile-main/profile-main.layout.tsx`.
```tsx
import { useMainComposition } from './hooks/use-main-composition.hook'
import { FeaturedProducts } from './parts/featured-products'
import type { ReactNode } from 'react'
import { useProfilePageStore } from '@/compositions/pages/profile'
export const MainScreen = () => {
const { catalog } = useMainComposition()
const { useCategories, CategoryList } = catalog
const categories = useCategories()
type Props = {
children: ReactNode
}
export const ProfileMainLayout = ({ children }: Props) => {
const title = useProfilePageStore((state) => state.title)
const isSidebarOpen = useProfilePageStore((state) => state.isSidebarOpen)
return (
<div>
<CategoryList categories={categories} />
<FeaturedProducts />
<div data-sidebar-open={isSidebarOpen}>
<header>{title}</header>
<main>{children}</main>
</div>
)
}
```
## Вложенный part
Layout импортирует hook из public API page composition. Он не импортирует screen и не лезет во внутренние файлы `pages/profile`.
Файл: `screens/main/parts/featured-products/featured-products.tsx`.
## Screen использует business composition
Вложенный модуль получает доступ к той же композиции родительского screen. Промежуточные компоненты не прокидывают домены через props.
Из API доменов достаются готовые сущности: `useFeatured`, `ProductCard` и `addItem`. Компонент работает с ними напрямую.
Файл: `compositions/screens/profile/profile.screen.tsx`.
```tsx
import { useMainComposition } from '../../hooks/use-main-composition.hook'
import { useProfileBusinessComposition } from '@/compositions/pages/profile'
export const FeaturedProducts = () => {
const { catalog, cart } = useMainComposition()
const { useFeatured, ProductCard } = catalog
const { addItem } = cart
const products = useFeatured()
export const ProfileScreen = () => {
const { profile } = useProfileBusinessComposition()
const { useCurrentProfile, ProfileCard } = profile
const currentProfile = useCurrentProfile()
return <ProfileCard profile={currentProfile} />
}
```
Screen получает готовые доменные API из page composition и не собирает граф фабрик самостоятельно.
## Публичный API page composition
Файл: `compositions/pages/profile/index.ts`.
```ts
export { ProfilePageProvider } from './providers/profile-page.provider'
export { useProfilePageStore } from './hooks/use-profile-page-store.hook'
export { useProfileBusinessComposition } from './hooks/use-profile-business-composition.hook'
export type { ProfilePageState } from './types/profile-page-state.type'
```
Внутренние `createProfilePageStore`, `createProfileBusinessComposition` и `ProfilePageContext` не экспортируются через public API.
Если нужен готовый `ProfilePage`, его лучше собрать в отдельном entry-point composition module или прямо в роутере. Не смешивайте в одном public API и готовую page composition, и hooks, которые импортируют её дочерние layout/screen modules: это может создать runtime-цикл.
## Подключение в app
В React Router можно собрать дерево прямо в route config:
```tsx
import { ProfilePageProvider } from '@/compositions/pages/profile'
import { ProfileMainLayout } from '@/compositions/layouts/profile-main'
import { ProfileScreen } from '@/compositions/screens/profile'
export const profileRoute = {
path: '/profile',
element: (
<ProfilePageProvider>
<ProfileMainLayout>
<ProfileScreen />
</ProfileMainLayout>
</ProfilePageProvider>
),
}
```
В Next App Router композиция может быть физически разложена по файлам `app`, но реализация остаётся в `compositions`.
```tsx
// app/(profile)/layout.tsx
import { ProfilePageProvider } from '@/compositions/pages/profile'
import { ProfileMainLayout } from '@/compositions/layouts/profile-main'
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<div>
{products.map((product) => (
<ProductCard
key={product.id}
product={product}
onAdd={() => addItem(product.id)}
/>
))}
</div>
<ProfilePageProvider>
<ProfileMainLayout>{children}</ProfileMainLayout>
</ProfilePageProvider>
)
}
```
Файл: `screens/main/parts/featured-products/index.ts`.
```tsx
// app/(profile)/page.tsx
import { ProfileScreen } from '@/compositions/screens/profile'
```ts
export { FeaturedProducts } from './featured-products'
export default function Page() {
return <ProfileScreen />
}
```
## Публичный API screen-модуля
Файл: `screens/main/index.ts`.
Наружу экспортируются только screen и его Provider. `MainComposition`, `MainCompositionContext` и `useMainComposition` остаются деталями реализации.
```ts
export { MainScreen } from './main.screen'
export { MainCompositionProvider } from './providers/main-composition.provider'
```
## Почему тип композиции не экспортируется
Внутренний тип закрывает доступ к форме композиции из внешних модулей. Бизнес-модуль не должен знать, какие домены собраны для конкретного screen.
Такой импорт из бизнес-модуля не должен быть возможен через публичный API screen.
```ts
import type { MainComposition } from '@/screens/main'
```
Когда тип остаётся внутренним, такая связь невозможна через публичный API screen-модуля.
## Почему хук не экспортируется
Если хук доступа сделать публичным, любой модуль сможет вызвать его напрямую. Внутренний хук доступен только через относительные импорты внутри screen-модуля и его `parts/`.
## Почему Provider экспортируется
Provider безопасно экспортировать: сам по себе он не даёт доступ к данным, а только принимает готовую композицию и передаёт её детям внутри React-дерева.
## Стабильность value
Фабрики создаются на уровне модуля, поэтому `catalog` и `cart` сохраняют ссылки между рендерами `MainRoute`.
Если домены зависят от runtime-параметров, граф нужно собирать в отдельном композиторе для этих параметров и передавать в Provider уже готовую композицию.
## Расширение на другие screen-модули
Паттерн повторяется для каждого screen, которому нужна композиция бизнес-доменов.
```text
screens/checkout/providers/checkout-composition.provider.tsx
screens/checkout/hooks/use-checkout-composition.hook.ts
screens/checkout/types/checkout-composition.type.ts
```
Имена включают имя screen-модуля. Не используйте универсальные названия вроде `useComposition` или `useScope`: по имени файла должно быть понятно, к какой странице привязан Context.
`app` размещает готовые composition modules по правилам фреймворка, но не реализует их внутри себя.

View File

@@ -0,0 +1,73 @@
---
title: Структуры compositions
description: Примеры организации слоя compositions под разные способы сборки React-приложения
---
# Структуры compositions
Раздел показывает, что SLM не фиксирует жёсткую структуру внутри `compositions`. Команда выбирает организацию под фреймворк, роутинг, CMS и продуктовую задачу.
## Базовая рекомендация
Подходит для большинства приложений, где есть явные страницы, layouts, screens и переиспользуемые композиционные блоки.
```text
src/compositions/
├── pages/
│ ├── home/
│ └── profile/
├── layouts/
│ ├── main/
│ └── dashboard/
├── screens/
│ ├── home/
│ └── profile/
└── widgets/
├── page-heading/
└── promo-banner/
```
`pages`, `layouts`, `screens` и `widgets` здесь не являются отдельными SLM-слоями. Это типы composition modules внутри одного слоя `compositions`.
## Entry-points и blocks
Подходит для проектов, где точка сборки не всегда является страницей: CMS registry, embedded UI, route entries, feature entries.
```text
src/compositions/
├── entry-points/
│ ├── cms-profile/
│ └── embedded-checkout/
├── pages/
│ └── profile/
├── layouts/
│ └── profile-main/
├── screens/
│ └── profile/
└── blocks/
├── profile-summary/
└── recommended-products/
```
## Группировка вокруг продукта
Подходит, когда удобнее держать все части одной крупной области рядом.
```text
src/compositions/
└── profile/
├── page/
├── layout/
├── screen/
└── blocks/
```
## Главное правило
Любая структура допустима, если соблюдаются границы слоя:
- `app` подключает готовые composition modules к фреймворку.
- `compositions` может импортировать `business`, `infra`, `ui`, `shared`.
- `business`, `infra`, `ui`, `shared` не импортируют `compositions`.
- Импорты между composition modules идут только через public API.
- Deep imports внутрь composition modules запрещены.

View File

@@ -1,27 +1,29 @@
---
title: Композиция фабрик
description: Пример композиции business-фабрик на уровне screen-модуля в React-проекте
description: Пример композиции business-фабрик на уровне composition module в React-проекте
---
# Композиция фабрик
Раздел показывает, как собрать API нескольких business-модулей в React screen-модуле. Пример подходит для простой композиции, когда screen сам является точкой использования доменов.
Раздел показывает, как собрать API нескольких business-модулей в React composition module. Пример подходит для простой композиции, когда page composition сама является точкой использования доменов.
## Идея
Композиция фабрик выполняется в модуле-потребителе: screen, layout или другом модуле группы «Композиция». Business-модули не импортируют runtime-код друг друга напрямую, а cross-domain зависимости получают только через аргументы фабрик.
Композиция фабрик выполняется в модуле-потребителе на слое `compositions`: page, layout, screen, widget или другом composition module.
## Структура screen-модуля
Business-модули не импортируют runtime-код друг друга напрямую, а cross-domain зависимости получают только через аргументы фабрик.
## Структура page composition
```text
screens/home/
├── home.screen.tsx
compositions/pages/home/
├── home.page.tsx
└── index.ts
```
## Сборка фабрик
Файл: `screens/home/home.screen.tsx`.
Файл: `compositions/pages/home/home.page.tsx`.
```tsx
import { customerFactory } from '@/business/customer'
@@ -32,7 +34,7 @@ const order = orderFactory({ customer })
const { useOrder, OrderCard } = order
export const HomeScreen = () => {
export const HomePage = () => {
const currentOrder = useOrder()
return <OrderCard order={currentOrder} />
@@ -41,12 +43,12 @@ export const HomeScreen = () => {
`customerFactory` создаётся первой, потому что `orderFactory` зависит от части API домена `customer`. Модуль `order` не импортирует `customer` в runtime — зависимость передаётся снаружи.
## Публичный API screen-модуля
## Публичный API page composition
Файл: `screens/home/index.ts`.
Файл: `compositions/pages/home/index.ts`.
```ts
export { HomeScreen } from './home.screen'
export { HomePage } from './home.page'
```
Screen экспортирует только собственный публичный API. Собранные экземпляры business API остаются деталями реализации screen-модуля.
Page composition экспортирует только собственный публичный API. Собранные экземпляры business API остаются деталями реализации модуля.

View File

@@ -5,7 +5,7 @@ description: Пример создания фабрики business-модуля
# Создание фабрики
Раздел показывает, как оформить фабрику business-модуля в React-проекте: описать публичный API, зависимости и функцию, возвращающую runtime API.
Раздел показывает, как оформить фабрику business-модуля в React-проекте: описать публичный API, зависимости и функцию, возвращающую API фабрики.
## Структура business-модуля
@@ -25,7 +25,7 @@ business/customer/
## Тип публичного API
Публичный API описывает runtime-возможности, которые модуль отдаёт потребителям: хуки, компоненты и сценарные методы.
Публичный API описывает возможности, которые модуль отдаёт потребителям через фабрику: хуки, компоненты и сценарные методы.
```ts
// business/customer/types/customer-api.type.ts