chore: init

This commit is contained in:
2026-07-24 14:35:35 +03:00
commit 192a8a185b
38 changed files with 8846 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
node_modules/
.DS_Store

28
README.md Normal file
View File

@@ -0,0 +1,28 @@
# SLM Design
Документация и agent skill для архитектуры Scoped Layered Module Design.
## Структура
- `docs/` — исходная документация и спецификация SLM Design.
- `src-skills/` — исходники agent skills.
- `skills/` — собранные skills для установки через `npx skills`.
## Сборка
Требуется Node.js 20 или новее.
```bash
npm run build
npm run check
```
`npm run build` пересобирает `skills/slm-design/` из `docs/` и `src-skills/slm-design/`. Не редактируй собранные файлы вручную.
## Установка
После публикации репозитория:
```bash
npx skills add <owner>/slm-design-new --skill slm-design
```

12
docs/README.md Normal file
View File

@@ -0,0 +1,12 @@
# SLM Design
`docs/` - файлы документации по SLM-архитектуре.
Используй эту документацию как источник истины для задач по SLM Design: слоям, модулям, сегментам, публичному API, business factory, composition modules и монорепозиториям.
## Структура
- `canons/` - основные каноны SLM Design.
- `examples/` - дополнительные примеры реализации.
Точка входа: `canons/index.md`.

View File

@@ -0,0 +1,331 @@
---
title: Business-фабрика
description: Контракт business-модуля, runtime-зависимости, logic API, доменные ошибки и место сборки фабрик
---
# Business-фабрика
Раздел фиксирует архитектурный контракт business-модуля: фабрика описывает доменный logic API приложения и не знает о конкретном backend, SDK, storage, state/query runtime, browser API или React tree.
## Главный принцип
Business-модуль описывает домен приложения, а не форму backend API, SDK, storage, external hook, state manager или внешнего сервиса.
Фабрика должна отвечать на вопрос: какой API нужен приложению для работы с доменом. Она не должна отвечать на вопрос: какие методы есть у конкретного REST-клиента, SDK или browser API.
## Когда создавать business-модуль
Полный контракт business-модуля — фабрика, `deps`, доменные типы, доменные ошибки, adapters, сборка в `compositions/business/{domain}`, обязательные factory/assembly tests и colocated tests для внутренней runtime-safe логики. Он применяется к любому модулю, созданному в `business`.
Создавай business-модуль, когда выполняется хотя бы одно условие:
- сценарии нужны нескольким страницам, composition modules или другим доменам;
- у логики есть собственная доменная модель и доменные ошибки, которые нельзя выразить типами внешнего API;
- доменный сценарий зависит от внешних runtime-capabilities (backend, SDK, product storage, source hook, domain store, event, browser API), которые нужно изолировать за `deps`;
- домен нужно тестировать независимо от UI и конкретного backend.
Не создавай business-модуль заранее, если логика нужна одной странице, не имеет доменной модели, product I/O и domain state. Presentation-only store или browser interaction, принадлежащие UI scope, сами по себе не создают business-домен. Такая page-local orchestration живёт в соответствующем composition module.
Наличие product I/O отменяет page-local исключение. Даже если источник нужен одной странице, consumer composition не обращается к нему напрямую: объяви business-контракт, dependency adapter и domain errors.
«Облегчённого» business-модуля не существует: если модуль создан в `business/`, контракт применяется целиком. Подъём вызревшей логики из composition module в business-модуль — обычный рефакторинг: объяви доменные типы и `deps`, перенеси сценарии в services и hooks, собери фабрику и покрой её factory-level тестами.
## Обязательные правила
- Фабрика лежит в корне business-модуля: `business/{domain}/{domain}.factory.ts`.
- Фабрика принимает runtime-зависимости только через `deps`.
- Фабрика возвращает только logic API домена: hooks, selectors, command/query methods, scenario services.
- Фабрика не возвращает React-компоненты, layouts, guards, boundaries, providers или page-level wrappers.
- Business-модуль не содержит React-компоненты. UI-решения домена размещаются в `compositions`; полностью универсальные UI-контролы размещаются в `ui`.
- Business-модуль не импортирует реальные SDK, generated operations, HTTP-клиенты, storage, env, browser API или composition-сборку.
- Business-модуль не импортирует React state/effect runtime, SWR/query runtime, Zustand/Redux/MobX store runtime или event bus implementation.
- Source/query hooks, domain state stores, subscriptions, clock, random и technical services передаются через business-owned `deps`.
- Public contract business-модуля использует собственные доменные типы, а не DTO внешнего API.
- `index.ts` business-модуля экспортирует только фабрику и type-only экспорты. Исключений нет.
- Runtime-сборка business-фабрики выполняется вне `business`, обычно в `compositions/business/{domain}`.
- Для каждой runtime-capability создаётся явный dependency adapter. Adapter не пишется inline внутри builder.
- Из public API business выходят только собственные domain errors со стабильным `code`.
- Factory-level и assembly tests обязательны и входят в критерий завершения модуля.
## Проектирование фабрики
Проектирование начинается со сценариев домена, а не с API-клиента.
Порядок работы:
1. Опиши публичные сценарии, которые нужны приложению.
2. Опиши доменные типы, которыми должен оперировать UI и другие business-модули.
3. Проведи inventory всех runtime-capabilities: data sources, hooks, stores, events, browser APIs, env, clock и cross-domain APIs.
4. Опиши минимальные внешние возможности в `{Domain}Deps`.
5. Назови внешние возможности бизнес-языком, а не языком backend endpoint'ов и библиотек.
6. Опиши domain error codes для каждого публичного сценария.
7. Собери фабрику из внутренних services, hook wrappers, mappers и helpers поверх переданных deps.
8. Верни наружу только `{Domain}Api`.
9. Реализуй каждую dependency отдельным adapter в `compositions/business/{domain}`.
Правильные вопросы:
- Не `какой endpoint дернуть?`, а `какой бизнес-сценарий выполняем?`.
- Не `какой DTO пришёл?`, а `какую доменную модель отдаём наружу?`.
- Не `как называется метод SDK?`, а `какая внешняя возможность нужна домену?`.
- Не `как устроен backend сейчас?`, а `какой стабильный контракт нужен приложению?`.
## Контракт зависимостей
`{Domain}Deps` описывает не технические инструменты, а возможности, которые нужны домену.
Правила:
- имя зависимости отражает бизнес-возможность: `phoneAuth`, `session`, `profile`, `agreements`, `notifications`;
- методы внутри зависимости называют сценарное действие без повторения endpoint names: `phoneAuth.requestCode`, `phoneAuth.verifyCode`, `profile.getCurrentUser`, `agreements.saveUserAgreements`;
- параметры используют доменные типы business-модуля;
- результат внешней границы принимается как `unknown`, если данные требуют runtime-нормализации;
- пустой успешный ответ описывается как `Promise<void>`, если body не нужен;
- dependency не должна возвращать generated DTO как доменный тип;
- mapper, normalizer или domain error не передаётся через `deps`, если это часть доменной логики.
- source/query hook описывается business-owned result type, без `SWRConfiguration`, `UseQueryResult` и других library types;
- domain state описывается business-owned port, без `StoreApi` и concrete state manager types;
- subscription возвращает cleanup function;
- недетерминированные clock/random/id capabilities передаются явно;
Плохо:
```ts
import type { ApiRequestClient, BoundApi } from '@vendor/api-sdk'
import type { v1PatientProfileList } from '@vendor/api-sdk/operations'
type UserApiTree = {
patient: {
profile: typeof v1PatientProfileList
}
}
export type UserDeps = {
api: BoundApi<UserApiTree, ApiRequestClient>
}
```
Проблема: business-модуль знает про SDK, generated operation и форму внешнего клиента.
Хорошо:
```ts
import type { AuthState } from './auth-state.type'
import type { VerifyPhoneCodeData } from './verify-phone-code-data.type'
export type AuthDeps = {
phoneAuth: {
requestCode: (phone: string) => Promise<unknown>
resendCode: (challengeId: string) => Promise<unknown>
verifyCode: (data: VerifyPhoneCodeData) => Promise<unknown>
}
session: {
setToken: (token?: string | null) => void
useToken: () => string | null | undefined
}
sessionEvents: {
onInvalidated: (listener: () => void) => () => void
}
state: {
create: (initialState: AuthState) => {
get: () => AuthState
set: (state: AuthState) => void
useState: () => AuthState
}
}
}
```
Конкретный SDK, storage, source hook, store или mock подключается в `compositions/business/auth` через адаптер. Business-модуль знает только о своём `AuthDeps`.
## Публичный API фабрики
`{Domain}Api` описывает logic API домена, который фабрика возвращает наружу в runtime.
Правила:
- API говорит на языке домена;
- API не повторяет endpoint names;
- API не отдаёт DTO внешнего сервиса;
- API не раскрывает внутренние `create*` services;
- API остаётся стабильным при замене backend, SDK или storage;
- API может возвращать hooks и selectors, созданные поверх переданных dependency hooks/state ports; business не импортирует concrete hook/store runtime;
- API не возвращает React-компоненты, UI-компоненты, layouts, guards, boundaries или page-level wrappers.
Плохо:
```ts
export type AuthApi = {
AuthGuard: ComponentType<AuthGuardProps>
LoginButton: ComponentType<LoginButtonProps>
useAuth: ReturnType<typeof createAuthHook>
}
```
Проблема: factory output смешивает доменную логику с UI и начинает собирать React tree.
Хорошо:
```ts
export type AuthApi = {
requestPhoneCode: ReturnType<typeof createRequestPhoneCode>
resendPhoneCode: ReturnType<typeof createResendPhoneCode>
verifyPhoneCode: ReturnType<typeof createVerifyPhoneCode>
useAuth: ReturnType<typeof createAuthHook>
useIsAuthenticated: ReturnType<typeof createIsAuthenticatedHook>
startSessionInvalidationTracking: () => () => void
}
```
Такой API сообщает composition-слою доменное состояние и действия, но не навязывает UI-решение.
## Реализация фабрики
Фабрика связывает внутренние части business-модуля с переданными зависимостями.
```ts
import { createCurrentUserHook } from './hooks/use-current-user.hook'
import { createStoredUserAgreementsGetter } from './services/get-stored-user-agreements.service'
import { createUpdateCurrentUserProfile } from './services/update-current-user-profile.service'
import type { UserFactory } from './types/user-factory.type'
export const userFactory: UserFactory = (deps) => {
const { authApi, profile, storage } = deps
return {
getStoredUserAgreements: createStoredUserAgreementsGetter(storage),
updateCurrentUserProfile: createUpdateCurrentUserProfile(profile),
useCurrentUser: createCurrentUserHook({ authApi, profile }),
}
}
```
Фабрика не должна:
- создавать API-клиент;
- выбирать backend endpoint;
- читать env;
- обращаться к browser storage напрямую;
- делать запросы при создании API;
- импортировать composition-сборку;
- подстраивать свой API под конкретный внешний сервис;
- создавать или возвращать React-компоненты;
- импортировать React state/effect APIs;
- импортировать SWR, query library или state manager runtime;
- создавать concrete store, query client или event bus;
- подписываться на external event без dependency contract и явного cleanup.
## Runtime-границы и ошибки
Любая dependency фабрики считается ненадёжной runtime-границей.
Business-модуль защищает публичный контракт от таких случаев:
- dependency вернула `null`, `undefined`, пустой body или объект неправильной формы;
- dependency вернула rejected promise;
- dependency синхронно выбросила исключение;
- storage содержит устаревшие или битые данные;
- внешний сервис поменял форму ответа без изменения TypeScript-типов.
Защита выполняется внутри business-модуля через mappers, normalizers, type guards и domain errors. Fallback допустим только для валидного доменного исхода, явно представленного dependency contract, а не для technical failure или malformed response.
Наружу не должны протекать ошибки SDK, HTTP-клиента, storage, query hook, store, generated API или browser API. Потребитель business API всегда получает только собственную domain error со стабильным `code`, а не `message`, `status`, `response`, `stack` или форму внешней ошибки.
```ts
if (isDomainError<AuthErrorCode>(error) && error.code === 'AUTH_PHONE_CODE_VERIFY_FAILED') {
showInvalidCodeMessage()
}
```
Business всегда преобразует rejected promise, synchronous throw, source hook error и невалидную успешную структуру в собственную domain error.
## Сборка business-фабрики
Реальные runtime-зависимости подключаются в composition module `compositions/business/{domain}`.
```text
src/
├── business/
│ └── auth/
│ ├── auth.factory.ts
│ ├── types/
│ └── index.ts
└── compositions/
└── business/
└── auth/
├── create-auth-business.ts
├── adapters/
├── types/
└── index.ts
```
Если business-домены сгруппированы, сборка повторяет тот же относительный путь: `business/app/auth` соответствует `compositions/business/app/auth`, `business/cms/content` соответствует `compositions/business/cms/content`. Группировка не является default-структурой.
Правила:
- `business/{domain}` объявляет фабрику и контракт `deps`.
- `compositions/business/{domain}` отдельными adapters адаптирует SDK, storage, infra-клиенты, source hooks, stores, events и другие runtime-capabilities к этому контракту.
- Builder явно создаёт или получает scoped runtime instances без I/O, создаёт adapters поверх них, передаёт adapters фабрике и возвращает API; integration logic не пишется inline.
- `create{Domain}Business()` возвращает готовый `{Domain}Api`.
- Browser/application `create{Domain}Business()` принимает аргументы только для API других уже собранных business-фабрик.
- SDK, storage, env, HTTP-клиенты и browser API не передаются в `create{Domain}Business()` как deps.
- Если домен не зависит от других business API, `create{Domain}Business()` вызывается без аргументов.
- Request-scoped builder отделяет cross-domain API от `requestScopeInput`. В input находятся только framework/request data; concrete client factory импортируется внутри integration module. Request input используется только для создания adapters и не передаётся factory как raw dependency.
- Конечный граф API собирается в месте, которое владеет lifecycle: page composition, route composition, provider, request scope или test setup.
- `infra` не собирает business-фабрики, потому что не должен импортировать `business`.
- `app` не реализует business-сборку, а только подключает готовые composition modules к фреймворку.
```ts
import { createAuthBusiness } from '@/compositions/business/auth'
import { createUserBusiness } from '@/compositions/business/user'
const authApi = createAuthBusiness()
const userApi = createUserBusiness({ authApi })
```
Подробный пример см. в [Business composition](../examples/business-composition.md).
## Public API файла index.ts
Жёсткое правило без исключений: `business/{domain}/index.ts` экспортирует только фабрику и type-only экспорты.
```ts
export { userFactory } from './user.factory'
export type { User } from './types/user.type'
export type { UserApi } from './types/user-api.type'
export type { UserDeps } from './types/user-deps.type'
export type { UserFactory } from './types/user-factory.type'
export type { UserErrorCode } from './types/user-error-code.type'
```
## Тестирование
Business-модуль тестируется через public API фабрики. Factory-level тесты обязательны, импортируют модуль только через `business/{domain}` и не используют deep imports во внутренние `services`, `hooks`, `mappers` или `lib`.
Сборка в `compositions/business/{domain}` обязательно тестируется отдельно: эти тесты проверяют adapters, корректность передачи deps, отсутствие I/O при создании API и lifecycle cleanup, но не заменяют сценарные тесты business-модуля.
Colocated unit tests обязательны там, где есть mappers, normalizers, type guards, domain errors или другая внутренняя runtime-safe логика. Они являются дополнительным уровнем, а не заменой factory-level и assembly tests.
Подробный пример см. в [Business testing](../examples/business-testing.md).
## Чеклист
- Фабрика лежит в корне business-модуля.
- Фабрика принимает все runtime-зависимости через `deps`.
- В `deps` нет SDK, generated operations, HTTP-клиентов, backend DTO, StoreApi и query-library types.
- Контракты зависимостей названы бизнес-языком.
- Source/query hooks, stores, events и platform APIs переданы через `deps`.
- Business не импортирует React/SWR/query/store runtime.
- Все public types принадлежат business-модулю.
- Внешние ответы нормализуются перед попаданием в public API.
- Все source errors без исключений заменяются собственными domain errors.
- Public API фабрики не повторяет внешний API.
- Public API фабрики не возвращает React-компоненты.
- Business-модуль не содержит React-компоненты.
- `index.ts` экспортирует только фабрику и type-only экспорты, без исключений.
- Runtime-сборка живёт в `compositions/business/{domain}`.
- Для каждой runtime-capability существует private adapter.
- Builder не содержит inline integration logic.
- Business-модуль можно подключить к другому backend через адаптер без изменения фабрики.
- Factory-level и assembly tests созданы и выполняются.

View File

@@ -0,0 +1,293 @@
---
title: Runtime-граница business
description: Строгая изоляция business-фабрики от источников данных, state/query runtime, infra, browser API и внешних ошибок
---
# Runtime-граница business
## Главный инвариант
Business-модуль выполняет доменную композицию только над:
- собственными типами и детерминированной логикой;
- capabilities, переданными фабрике через `{Domain}Deps`.
Business не вызывает runtime-возможность, если она не была передана фабрике. Это относится не только к данным и `infra`, но и к hooks, stores, subscriptions, browser API и другим concrete runtime-механизмам.
Фабрика отвечает на вопрос «какой стабильный доменный API нужен приложению», а не «какими библиотеками и источниками он реализован».
## Что разрешено внутри business
Business может напрямую использовать:
- собственные domain types;
- собственные services, mappers, normalizers, validators и type guards;
- собственные domain errors;
- детерминированные вычисления без I/O, runtime-state и скрытого окружения;
- чистые библиотеки вроде schema validators, decimal/date utilities, если результат определяется только явными аргументами и типы библиотеки не становятся public contract;
- type-only контракты других business API для cross-domain dependencies.
## Что передаётся через deps
Через `{Domain}Deps` передавай любую runtime-capability:
| Capability | Примеры concrete implementation |
|---|---|
| Product source | REST SDK, GraphQL client, CMS, storage |
| Source/query hook | SWR, TanStack Query, Apollo hook |
| Domain state runtime | Zustand, Redux, MobX, RxJS store |
| Technical service | logger, telemetry, notifications, i18n engine |
| Platform API | `window`, navigation, clipboard, geolocation |
| Lifecycle event | unauthorized event, socket event, subscription |
| Environment | env/config provider, feature runtime configuration |
| Nondeterminism | clock, timer, random, ID generator |
| Cross-domain behavior | ограниченный API другой business-фабрики |
Не импортируй concrete implementation в business даже в том случае, если библиотека используется только в одном внутреннем файле.
## Запрещённые imports
Production-код `business/**` не импортирует напрямую ни runtime values, ни types из concrete runtimes:
- `infra`, `compositions`, `app`;
- SDK, generated operations и HTTP clients;
- storage implementation;
- React state/effect APIs;
- SWR, TanStack Query, Apollo и другие query runtimes;
- Zustand, Redux, MobX, RxJS stores;
- browser и framework runtime APIs;
- event bus implementation;
- env и process-specific configuration.
Запрет нельзя обойти через `import type`, alias, barrel, type cast или helper в `shared`. Type-only разрешены собственные contracts, детерминированный `shared`, чистые libraries и суженные public API других business-доменов.
## Доменный шлюз данных
Business API является единственной продуктовой границей для потребительского кода.
```text
page / layout / screen / widget
→ {Domain}Api
→ business scenario
→ {Domain}Deps
→ private dependency adapter
→ infra client / SDK / storage / external source
```
Внешний сервис остаётся физическим источником данных. Business является единственным источником доменной истины: он определяет модель, сценарий, нормализацию, fallback и ошибки.
Обычный consumer composition не создаёт параллельный продуктовый контракт поверх DTO или client. Единственная зона concrete product integration внутри `compositions``compositions/business/{domain}`.
## Business-owned deps
`{Domain}Deps` принадлежит business-модулю и описывает необходимые возможности доменным языком.
Требования:
- группируй методы по capability, а не по имени SDK/client;
- принимай доменные аргументы;
- принимай внешние результаты как `unknown`, если нужна runtime-проверка;
- описывай собственную минимальную форму dependency hook/result;
- описывай собственный state port, а не `StoreApi` конкретной библиотеки;
- возвращай cleanup из subscription capability;
- не передавай mapper, normalizer или domain error через deps;
- не используй generated DTO как доменную модель;
- не передавай целый client, если домену нужны два конкретных действия.
Плохо:
```ts
import type { StoreApi } from 'zustand'
import type { AdminApiClient } from '@vendor/admin-sdk'
export type AuthDeps = {
api: AdminApiClient
store: StoreApi<AuthState>
}
```
Хорошо:
```ts
import type { AuthState } from './auth-state.type'
import type { VerifyPhoneCodeData } from './verify-phone-code-data.type'
export type SourceHookResult = {
data: unknown
error: unknown
isLoading: boolean
refresh: () => Promise<void>
}
export type AuthDeps = {
phoneAuth: {
requestCode: (phone: string) => Promise<unknown>
resendCode: (challengeId: string) => Promise<unknown>
verifyCode: (data: VerifyPhoneCodeData) => Promise<unknown>
}
session: {
setToken: (token?: string | null) => void
useToken: () => string | null | undefined
}
sessionEvents: {
onInvalidated: (listener: () => void) => () => void
}
state: {
create: (initialState: AuthState) => {
get: () => AuthState
set: (state: AuthState) => void
useState: () => AuthState
}
}
}
```
## Dependency adapters
Adapter находится снаружи business и реализует конкретную часть `{Domain}Deps`.
```text
compositions/business/auth/
├── create-auth-business.ts
├── adapters/
│ ├── admin-auth-session.adapter.ts
│ ├── browser-auth-navigation.adapter.ts
│ ├── zustand-auth-state.adapter.ts
│ └── admin-auth-session-events.adapter.ts
└── index.ts
```
Правила adapter:
- импортирует type-only business contract;
- импортирует concrete infra/runtime implementation;
- преобразует доменные аргументы в transport arguments;
- возвращает raw/unknown runtime result, если business должен его проверить;
- пробрасывает source error без создания domain error;
- не реализует бизнес-правило;
- не выбирает доменный fallback;
- не экспортируется через public API integration module;
- тестируется на wiring, payload и соответствие dependency contract.
Каждая runtime-capability получает явный adapter. Не скрывай несколько разных integrations как inline-функции внутри builder.
## Dependency hooks
Если business должен предоставить hook, concrete hook передаётся через deps.
```text
SWR/TanStack hook
→ dependency adapter
→ business-owned source hook contract
→ business wrapper hook
→ domain result / domain error
```
Business wrapper может:
- вызвать переданный dependency hook;
- нормализовать `data` в доменную модель;
- заменить source error доменной ошибкой;
- вычислить domain state и selectors;
- вернуть собственный стабильный result type.
Dependency hook обязан быть non-throwing и non-Suspense: technical failure возвращается в `error: unknown`, а не выбрасывается во время вызова hook. Business wrapper обязан заменить этот `error` собственной domain error. Публичные callbacks dependency result, например `refresh`, также оборачиваются business и не могут выдать source error наружу.
Business wrapper не импортирует query library. В public contract не протекают `SWRConfiguration`, `UseQueryResult`, query keys, cache implementation или library-specific mutate API без отдельного domain contract.
## Domain state
Business владеет:
- моделью доменного state;
- допустимыми переходами;
- commands и selectors;
- реакцией на dependency results и events.
Business не владеет concrete state manager. Business выбирает initial domain state и передаёт его в `deps.state.create(initialState)`; adapter только создаёт concrete store с переданным значением.
Zustand/Redux/MobX adapter реализует business-owned state adapter factory и создаётся в `compositions/business/{domain}`. Business-фабрика получает adapter factory через `deps`, передаёт initial domain state и получает concrete port.
Локальный UI-state является другим случаем. Состояние раскрытия sidebar, выбранной вкладки или шага локального UI-flow может использовать concrete state manager непосредственно внутри владеющего composition module, если оно не подменяет доменное состояние и product data boundary.
## Доменные ошибки
Из любого публичного метода, command, query или hook business-модуля выходят только собственные доменные ошибки.
Business обязан преобразовать:
- rejected promise dependency;
- synchronous throw dependency;
- source hook error;
- ошибку store/storage/browser API;
- невалидный успешный ответ;
- неизвестную runtime-ошибку.
Domain error содержит стабильный `code`. Исходная ошибка может сохраняться только в `cause` для диагностики.
Потребитель не ориентируется на:
- `message` внешней ошибки;
- HTTP status;
- `response`;
- SDK error class;
- stack или transport code.
Adapter не импортирует и не создаёт domain error. Error mapping всегда выполняет business.
Rejected promise, synchronous throw, source error и malformed response всегда превращаются в domain error. Fallback допустим только для валидного доменного исхода, явно представленного dependency contract, например корректного отсутствия данных. Fallback не используется для поглощения technical failure или contract drift.
## Чистый builder
`compositions/business/{domain}/create-{domain}-business.ts` только:
1. явно создаёт или получает runtime instances нужного lifecycle без I/O;
2. создаёт adapters поверх этих instances;
3. передаёт adapters и API других доменов в factory;
4. возвращает готовый `{Domain}Api`.
Builder не содержит:
- inline SDK calls;
- `window`/storage operations;
- domain mapping;
- domain errors;
- Zustand/SWR setup вперемешку с другими dependencies;
- product request при создании API;
- скрытую subscription без cleanup contract.
Если capability имеет lifecycle, business API предоставляет domain-level `start`/`subscribe` operation, которая использует dependency и возвращает cleanup wrapper. Business преобразует ошибки регистрации, callback и cleanup в собственные domain errors. Builder остаётся без side effects, а владелец scope запускает operation после mount/commit и вызывает cleanup при завершении lifecycle.
Browser/application builder без cross-domain dependencies вызывается без аргументов. Для request scope integration module определяет отдельный `requestScopeInput` только с framework/request data, например headers, cookies, request ID и abort signal. Concrete client factory импортируется и вызывается внутри integration module. Request input используется только для создания adapters, не передаётся в business как raw client и не экспортируется consumer compositions.
## Graph и lifecycle
Per-domain builder не является владельцем полного graph.
Владелец graph:
- выбирает application-lifetime composition/route/page/request/test scope;
- собирает домены в ацикличном порядке;
- создаёт ровно необходимый набор API;
- использует точный graph type;
- управляет cleanup subscriptions/resources;
- не повторяет adapter wiring;
- не импортирует raw infra для «досборки» домена.
Не используй generic `Partial<Business>` с последующим `as Business`. Если scope содержит только Auth, его contract должен обещать только Auth.
## Cross-domain dependencies
Один business-домен не импортирует runtime другого домена. Он объявляет нужную capability в своих `Deps` через type-only API другого домена, по возможности суженный `Pick`.
Готовый API передаётся builder-у при сборке graph:
```text
createAuthBusiness()
→ createUserBusiness({ authApi })
→ createOrdersBusiness({ userApi })
```
Runtime-цикл означает ошибочную границу доменов. Не скрывай цикл service locator, lazy import или глобальным event bus.
Подробный контракт фабрики находится в [Business-фабрике](./business-factory.md). Практическая сборка показана в [Business composition](../examples/business-composition.md).

View File

@@ -0,0 +1,216 @@
---
title: Процесс архитектурного решения
description: Обязательный порядок классификации задачи, выбора владельца, слоя, scope и стратегии изменений
---
# Процесс архитектурного решения
Не изменяй файлы, пока не принято архитектурное решение. Название папки, существующий похожий код и удобный импорт не доказывают правильность размещения.
## Карточка решения
Перед реализацией определи:
| Вопрос | Что зафиксировать |
|---|---|
| Роль изменения | Framework wiring, продуктовый сценарий, интеграция, композиция интерфейса, технический сервис, UI или чистый фундамент |
| Владелец | Домен, route/page scope, composition module, infra-модуль, UI-модуль или локальный consumer |
| Данные | Продуктовые данные, техническое состояние, framework input, локальное UI-state или отсутствуют |
| Runtime-возможности | Источники данных, hooks, stores, SDK, browser API, events, clock, random, env и другие внешние capabilities |
| Место | Приложение или package, слой, модуль, вложенный модуль и сегмент |
| Публичная граница | Что действительно нужно экспортировать и кто будет consumer |
| Путь данных | От consumer до business API, dependency adapter и конкретного источника |
| Lifecycle | Кто создаёт instance, сколько instances допустимо и кто выполняет cleanup |
| Стратегия | Локальная правка, новый модуль, новый business-контракт, adapter, перенос или исправление public API |
| Проверки | Typecheck, тесты, import graph, public API, lifecycle и архитектурные инварианты |
Карточку не обязательно выводить пользователю, если решение очевидно. Но агент обязан уметь обосновать каждый пункт до изменения файлов.
## Сбор контекста
Перед выбором места:
1. Прочитай локальные инструкции приложения или package.
2. Найди фактическую границу SLM: `src/`, `apps/{app}/src` или другой локальный root.
3. Проверь существующие слои, группы и соседние модули. Не создавай новую параллельную структуру без необходимости.
4. Проверь aliases, package exports и реальную разрешимость импортов.
5. Найди текущих consumers, public API и runtime import graph изменяемой ответственности.
6. Проверь существующие templates или generators после архитектурного выбора. Шаблон не принимает решение за SLM.
7. Отдельно найди product I/O, hooks, stores, subscriptions, browser API и другие runtime-возможности.
8. Проверь, нет ли уже business-домена, которому принадлежит сценарий.
Не считай неиспользуемый provider, пустой context, тип будущего graph или ссылку на несуществующий домен готовой архитектурой. Решение должно быть достижимо из runtime entry point и иметь реальных consumers.
## Выбор роли
Классифицируй ответственность в следующем порядке.
### Framework wiring
Если код существует только из-за фреймворка, размести его в `app`:
- route-файл;
- bootstrap;
- framework error entry;
- подключение глобальных ресурсов;
- тонкое подключение готового composition module.
`app` не реализует продуктовую композицию, business graph, store, provider или экран.
### Продуктовый сценарий
Если код определяет пользовательский сценарий, доменную модель, продуктовый state, бизнес-правило, нормализацию внешних данных, error mapping или доменный переход после ошибки, владелец находится в `business/{domain}`.
Визуальная реакция на готовый domain error принадлежит consumer composition: сообщение, error screen, redirect, retry control и UI fallback выбираются по стабильному доменному `code`.
Любой новый внешний источник продуктовых данных требует business-контракта. Колокация внешних вызовов в page/screen/widget services не является допустимым упрощением.
### Интеграция business-домена
Если код реализует `{Domain}Deps` через SDK, HTTP, storage, browser API, state/query runtime, event bus или другой concrete runtime, размести его в `compositions/business/{domain}`.
Это интеграционный composition module, а не business-домен и не обычная page/screen/widget composition.
### Продуктовая композиция
Если код собирает route/page/layout/screen/widget, управляет UI-state, provider scope или lifecycle готового business graph, размести его в соответствующем composition module.
Потребительский composition module получает продуктовые данные только через `{Domain}Api`. Он не импортирует product SDK, generated operations, product storage adapter или конкретный источник.
### Технический сервис
Если код предоставляет техническую возможность без продуктовой модели и сценариев, размести его в `infra`:
- HTTP client;
- SDK wrapper;
- logger;
- theme engine;
- i18n engine;
- telemetry transport;
- технический realtime client.
Composition может использовать технический infra-сервис напрямую, если сервис не становится обходным путём к продуктовым данным. Если capability нужна business, она всё равно передаётся через business-owned `deps` и adapter.
### Универсальный UI
Если сущность отображает интерфейс, не знает продуктовый сценарий и применима независимо от конкретной composition, размести её в `ui`.
### Чистый фундамент
Если код детерминирован, не имеет runtime-state, не знает продукт и переиспользуется несколькими владельцами, рассмотри `shared`. По умолчанию оставляй код рядом с первым владельцем.
## Выбор scope
Выбирай минимальный scope, который полностью владеет ответственностью:
1. Нужен одному component/module и не имеет самостоятельной ответственности: оставь внутри владельца.
2. Нужен как самостоятельная часть одного module: создай nested module в `parts/`.
3. Нужен нескольким частям одной page/route ветки: подними в общий composition scope этой ветки.
4. Нужен нескольким composition modules и остаётся продуктовой композицией: создай отдельный composition module.
5. Является доменным сценарием или product data boundary: создай или расширь `business/{domain}`.
6. Является техническим сервисом: создай или расширь `infra/{service}`.
7. Является универсальным UI: создай или расширь `ui/{module}`.
8. Выноси в package только `ui`, `infra` или `shared` код с реальным вторым consumer либо явно зафиксированным межприложенческим ownership/reuse-контрактом.
Не поднимай код выше ради короткого импорта. Не создавай `shared`, общий provider, generic business context или package «на будущее».
## Component, module и group
Применяй решение последовательно:
1. Только отображает готовые props и не владеет зависимостями: component в `ui/` родительского module.
2. Владеет сценарием, данными, state, dependency, lifecycle или внутренней декомпозицией: самостоятельный module.
3. Самостоятельный module, локальный для владельца: nested module в `parts/`.
4. Папка только классифицирует конечные modules: group без `index.ts`, state и runtime logic.
5. `ui/`, `parts/`, `hooks/`, `types/`, `services/` и другие служебные папки внутри module: segments, а не modules.
Если component начинает получать данные, выбирать источник, вызывать сценарный hook или управлять процессом, не добавляй логику в component. Измени архитектурную форму сущности.
## Выбор стратегии
### Новый продуктовый сценарий
1. Найди домен-владелец.
2. Спроектируй `{Domain}Api`, доменные типы и доменные ошибки.
3. Опиши минимальные runtime-capabilities в `{Domain}Deps`.
4. Реализуй детерминированную доменную логику.
5. Создай отдельные adapters в `compositions/business/{domain}`.
6. Собери фабрику чистым builder.
7. Подключи API во владельце lifecycle graph.
8. Используй API из потребительских compositions.
9. Добавь factory-level и assembly tests.
### Прямой product I/O вне business boundary
Не расширяй существующее нарушение.
1. Определи сценарий и домен.
2. Перенеси контракт данных в business-owned `Deps`.
3. Перенеси нормализацию, fallback и error mapping в business.
4. Оставь concrete source call в dependency adapter.
5. Замени прямой вызов на `{Domain}Api`.
6. Закрой adapter и source details из public API.
### Новый store или dependency hook
Сначала определи, является state локальным UI-state или доменным state.
- State является локальным UI-state, если сбрасывается вместе с UI scope, управляет только представлением и не хранит продуктовый факт или product data cache. Такой state может принадлежать composition module и использовать выбранный state manager внутри владельца.
- State является доменным, если выражает продуктовый факт, инвариант, доступен через business API или участвует в бизнес-сценарии.
- Доменный state принадлежит business-контракту. Фабрика получает state adapter factory через `deps`, выбирает initial domain state и создаёт concrete port через adapter.
- Source/query hook реализуется adapter-ом; business вызывает только dependency hook и возвращает собственный доменный hook/result.
### Сборка graph
1. Собери каждый домен отдельным `compositions/business/{domain}` builder.
2. Определи DAG cross-domain зависимостей.
3. Выбери один явный lifecycle scope: application-lifetime composition, route, page, request или test. Слой `app` только подключает application composition.
4. Создавай graph у владельца scope, а не в случайном screen/widget или на module scope без обоснования.
5. Передавай consumers точный graph type. Не используй `Partial<Graph>` с приведением к полному типу.
6. Для subscriptions, timers и resources зафиксируй cleanup/dispose.
### Архитектурное ревью
Проверяй не только пути файлов, но и семантику:
- business-shaped код вне `business`;
- product graph в `infra`;
- type-only imports, которые фактически переносят ownership;
- provider, который не создаёт и не получает instance от явного владельца;
- orphan modules и providers, недостижимые из entry point;
- public API, раскрывающий raw store, context, adapter или generated types;
- отсутствующие tests обязательного business-контракта.
## Условия остановки
Останови реализацию и сначала исправь решение, если:
- владелец ответственности не определён;
- один state или source имеет несколько конкурирующих владельцев;
- business требует прямого runtime или type-only import concrete runtime;
- graph создаёт runtime-цикл;
- lifecycle instance или cleanup не определён;
- public API нужен только для обхода границы;
- шаблон генерирует архитектуру, противоречащую принятому решению;
- изменение требует незапрошенной миграции нескольких независимых областей.
## Локальные материалы
Основной процесс достаточен для типового решения. Открывай только материал, который нужен текущей ветке задачи.
| Ситуация | Материал |
|---|---|
| Нужна полная карта допустимых файлов, root entries, segments и tests | [Атлас файлов SLM](./file-atlas.md) |
| Задача затрагивает product I/O, source hook, domain store, event, lifecycle или external errors | [Runtime-граница business](./business-runtime-boundary.md) |
| Выполняется архитектурное ревью или финальная проверка реализации | [Архитектурная проверка](./validation.md) |
| Неясен layer, направление import или роль `app/compositions/business/infra/ui/shared` | [Слои](./layers.md) |
| Нужно отличить module, component, group, nested module или спроектировать public API | [Модули](./modules.md) |
| Проектируется factory, Api, Deps, domain error или сборка домена | [Business-фабрика](./business-factory.md) |
| Неясно размещение hook/store/service/mapper/provider/type/style | [Сегменты](./segments.md) |
| Решается вынос из `apps/*/src` в `packages/*` | [Монорепозитории](./monorepo.md) |
| Нужен полный пример adapters, builder, state runtime и graph lifecycle | [Business composition](../examples/business-composition.md) |
| Нужна матрица factory-level, assembly и colocated tests | [Тестирование business-модулей](../examples/business-testing.md) |
| Нужен page/route provider, локальный UI store и доступ к готовому graph | [Композиция через Provider](../examples/react/composition-provider.md) |
| Команда выбирает организацию groups внутри `compositions` | [Структуры compositions](../examples/react/composition-structures.md) |
Не используй карту как scaffold checklist. Наличие возможной папки не означает, что её нужно создать.

508
docs/canons/file-atlas.md Normal file
View File

@@ -0,0 +1,508 @@
---
title: Атлас файлов SLM
description: Карта слоёв, типов modules, root files, segments, public API, tests и запрещённых файловых сочетаний
---
# Атлас файлов SLM
Используй атлас после того, как определены роль изменения и владелец ответственности. Не выбирай архитектуру по желаемому имени файла.
Атлас исчерпывает стандартные архитектурные роли SLM, но не является закрытым списком framework-файлов. Команда может добавить локальный segment или suffix, если его ответственность не дублирует существующую, не нарушает направление зависимостей и закреплена в локальных инструкциях.
## Единицы структуры
| Единица | Что означает | Имеет public API | Владеет runtime |
|---|---|---|---|
| Layer | Верхнеуровневая область ответственности внутри SLM root | Нет общего требования | Зависит от layer |
| Group | Навигационная папка для modules или других groups | Нет, `index.ts` запрещён | Нет |
| Module | Самостоятельный владелец одной ответственности | Да | Может |
| Segment | Папка внутри module по назначению файлов | Нет отдельного внешнего API | Только как часть владельца |
| Component | Презентационная часть родительского module в `ui/` | Локальный `index.ts` допустим | Нет архитектурного runtime |
| Root file | Главный entry/contract конкретного module | Экспортируется module `index.ts` | Зависит от типа module |
Сначала определи module, затем root file, затем необходимые segments. Не создавай все папки из атласа заранее.
## Карта SLM root
```text
src/
├── app/ # framework wiring
├── compositions/ # product tree, graph owners и business integrations
├── business/ # доменные контракты и сценарии
├── infra/ # технические runtime-сервисы
├── ui/ # универсальные UI modules
└── shared/ # детерминированный фундамент
```
В monorepo вместо `src/` границей приложения обычно является `apps/{app}/src/`. Packages находятся выше SLM root и не являются дополнительными слоями.
## Root files modules
| Pattern | Роль | Где допустим |
|---|---|---|
| `{name}.page.tsx` | Готовая page composition | `compositions` |
| `{name}.layout.tsx` | Product layout composition | `compositions` |
| `{name}.screen.tsx` | Уникальный screen leaf/branch | `compositions` |
| `{name}.widget.tsx` | Самостоятельный composition block | `compositions` |
| `{name}.route.tsx` | Route composition и route lifecycle | `compositions` |
| `{name}.entry.tsx` | Готовая точка подключения product tree | `compositions` |
| `{scope}-business-composition.ts` | Non-visual сборка business graph/scope | `compositions` |
| `{name}.ts` | Другой non-visual root, названный по ответственности | `compositions`, nested modules |
| `{name}.tsx` | Root UI/module component без специальной роли | `compositions`, `ui`, nested modules |
| `{domainName}.factory.ts` | Единственный runtime entry business-домена | `business/{domainPath}` |
| `create-{domainName}-business.ts` | Builder одной business-фабрики | `compositions/business/{domainPath}` |
| `{name}.client.ts` | Технический client | `infra/{service}` |
| `{name}.service.ts` | Технический root service, если service и есть module entry | `infra/{service}` |
| `index.ts` | Public API конечного module | В module; запрещён у group |
Root suffix не определяет owner автоматически. Например, `profile.store.ts` остаётся domain store или page UI store в зависимости от смысла state.
## Layer App
`app` содержит только файлы, требуемые framework/runtime entry.
Возможные файлы:
| Файл или pattern | Назначение |
|---|---|
| `main.tsx`, `bootstrap.tsx` | Запуск приложения |
| `app.tsx` | Тонкое подключение application entry/providers |
| `app-router.tsx`, `router.tsx` | Framework route registry |
| `page.tsx`, `layout.tsx`, `route.ts`, `error.tsx`, `not-found.tsx` | Framework-convention files |
| `middleware.ts` | Framework middleware boundary |
| framework metadata/config files | Только framework contract |
Правила:
- framework file импортирует готовый entry/route/page composition через public API;
- product tree собирается в `compositions`, не в `app`;
- business graph, domain store, screen, widget и product provider в `app` запрещены;
- у `app` нет SLM modules и общего `index.ts`;
- framework-specific server/client правила определяет профильный framework skill.
Минимальный пример:
```text
src/app/
├── app-router.tsx
├── app.tsx
└── main.tsx
src/compositions/entries/profile/
├── profile.entry.tsx
└── index.ts
```
## Consumer composition module
Consumer composition собирает product UI и использует готовые `{Domain}Api`.
```text
compositions/{group}/{name}/
├── {name}.{page|layout|screen|widget|route|entry}.tsx # visual entry, если нужен
├── {name}-business-composition.ts # business graph, если module владеет им
├── {name}.ts # другой non-visual entry, если нужен
├── ui/ # presentation components
├── parts/ # nested modules
├── providers/ # provider владельца scope
├── guards/ # route/UI guards над готовым DomainApi
├── hooks/ # access/orchestration hooks
├── stores/ # только локальный UI-state
├── services/ # orchestration готовых API
├── mappers/ # domain result -> ViewModel
├── types/ # module-owned types
├── errors/ # только composition/UI errors, не domain errors
├── lib/ # локальные deterministic helpers
├── config/ # module configuration
├── styles/ # styles module
├── tests/ # scope/integration tests при необходимости
└── index.ts # public API
```
Не все segments обязательны. Создавай только используемые.
Composition module не обязан иметь `.tsx`: request/application business graph использует `{scope}-business-composition.ts`, а другая non-visual orchestration может иметь root `.ts`, названный по ответственности, и public `index.ts`.
Guard принадлежит composition scope, использует готовый `{Domain}Api` и выбирает route/UI outcome. Guard не получает product data напрямую, не создаёт business graph и не импортирует source runtime.
Consumer composition не содержит:
- product SDK/client/generated operation;
- product storage adapter;
- source/query adapter домена;
- domain store implementation;
- domain mapper/error;
- business factory implementation.
Product data поступает только через `{Domain}Api`. `stores/` хранит presentation-only state: sidebar, tab, local step, transient form UI. Product entities и domain state туда не копируются.
### Page/route graph owner
Если composition владеет business graph и lifecycle, возможна структура:
```text
compositions/routes/profile/
├── profile.route.tsx
├── profile-business-composition.ts
├── providers/
│ ├── profile-business.provider.tsx
│ └── profile-business.context.ts
├── hooks/
│ └── use-profile-business.hook.ts
├── types/
│ └── profile-business.type.ts
├── tests/
│ └── profile-business-lifecycle.test.tsx
└── index.ts
```
Правила graph owner:
- graph type перечисляет только реально доступные API;
- `Partial<Business>` с cast к полному graph запрещён;
- graph создаётся в lifecycle владельца;
- domain lifecycle operation запускается после commit и возвращает cleanup;
- raw SDK/client/event bus не импортируется для досборки домена;
- screen/widget не создаёт тот же graph повторно.
## Integration module business
`compositions/business/{domainPath}` является единственным module, который знает одновременно business dependency contract и concrete runtime.
`{domainPath}` означает полный относительный путь конечного business module, а `{domainName}` — имя его последней папки. При groups integration path зеркалирует business path:
```text
business/app/auth/
compositions/business/app/auth/
business/cms/content/
compositions/business/cms/content/
```
```text
compositions/business/{domainPath}/
├── create-{domainName}-business.ts # обязательный builder
├── create-{domainName}-business.test.ts # обязательный assembly test
├── adapters/ # обязательны при runtime dependencies
│ ├── {source}.adapter.ts
│ ├── {storage}.adapter.ts
│ ├── {query}-hook.adapter.ts
│ ├── {state-manager}-state.adapter.ts
│ ├── {event-source}-events.adapter.ts
│ └── {platform}-navigation.adapter.ts
├── types/ # builder/cross-domain/request input types
│ ├── create-{domainName}-business-deps.type.ts
│ └── create-{domainName}-business-request-input.type.ts
├── testing/ # assembly fixtures, не public API
└── index.ts # builder и type-only integration inputs
```
Builder:
1. Явно создаёт или получает runtime instances нужного lifecycle без I/O.
2. Создаёт adapters поверх runtime instances.
3. Передаёт adapters и cross-domain API фабрике.
4. Возвращает готовый `{Domain}Api`.
Browser/application builder без cross-domain dependencies вызывается без аргументов. Request-scoped builder отдельно принимает `requestScopeInput` с request data; concrete client factory импортируется integration module.
Integration module не содержит:
- domain mapper/normalizer;
- domain error;
- business scenario;
- React provider/layout/screen/widget;
- full application graph;
- exported private adapter.
## Business module
Каждый `business/{domainPath}` имеет полный factory contract.
```text
business/{domainPath}/
├── {domainName}.factory.ts # обязательно
├── index.ts # обязательно
├── types/ # обязательно для contracts
│ ├── {domainName}-api.type.ts
│ ├── {domainName}-deps.type.ts
│ ├── {domainName}-factory.type.ts
│ ├── {domainName}-error.type.ts
│ ├── {domainName}-error-code.type.ts
│ ├── {entity}.type.ts
│ ├── {source}-hook-result.type.ts
│ └── {domainName}-state.type.ts
├── errors/
│ └── {domainName}-business.error.ts
├── services/
│ └── {scenario}.service.ts
├── hooks/
│ └── use-{scenario}.hook.ts # wrapper над dependency hook
├── mappers/
│ └── map-{entity}.ts # unknown -> domain
├── lib/
│ └── {domain-helper}.ts
├── config/
│ └── {domainName}.config.ts # deterministic domain constants
└── tests/
└── {domainName}-factory/
├── public-api.test.ts
├── {scenario}.test.ts
└── testing/
└── create-{domainName}-deps.mock.ts
```
Обязательный минимум:
- `{domainName}.factory.ts`;
- `{Domain}Api`, `{Domain}Deps`, `{Domain}Factory`;
- domain error codes и собственная domain error для runtime failure;
- `index.ts` с одним runtime export фабрики и type-only exports;
- factory-level tests каждого public runtime operation;
- integration builder и assembly test для каждого business-модуля, включая dependency-free factory.
Условные файлы:
- `services/` только при выделенном сценарии;
- `hooks/` только для wrapper над dependency hook;
- `mappers/`/`normalizers/`/type guards при внешнем `unknown`;
- `errors/` при runtime operations;
- colocated tests обязательны для каждого mapper, normalizer, type guard и domain error;
- colocated tests services/hooks обязательны при самостоятельной branching, race или другой runtime-safe логике.
В business запрещены:
- `ui/`, React components, providers, layouts и guards;
- concrete `stores/` Zustand/Redux/MobX;
- `adapters/` concrete runtime;
- SDK/client/generated DTO;
- SWR/TanStack Query/Apollo runtime или types;
- React state/effect runtime или types;
- storage/browser/event/env implementation;
- raw external error в public API.
## Infra module
Infra module владеет техническим сервисом без продуктовой модели.
```text
infra/{service}/
├── {service}.client.ts # если module является client
├── {service}.service.ts # если module является service
├── client.ts # допустимый technical entry
├── config/
├── clients/
├── services/
├── transports/
├── hooks/ # technical hooks
├── providers/ # provider technical service
├── ui/ # только technical UI, например theme tooling
├── errors/ # transport/technical errors
├── types/ # technical contracts/DTO
├── lib/
├── tests/
└── index.ts
```
Различай два вида adapter:
| Вид | Где живёт | Что знает |
|---|---|---|
| Transport adapter/client | `infra` | Протокол, SDK, HTTP, transport types |
| Domain dependency adapter | `compositions/business/{domainPath}` | Business `Deps` и конкретный infra runtime |
Infra не содержит business graph, domain state, product provider или domain error. Type-only imports business API не дают infra права агрегировать product graph.
## UI module
UI module предоставляет универсальный UI без business logic и product I/O.
```text
ui/{name}/
├── {name}.tsx # обязательный root component
├── ui/ # внутренние presentation components
├── parts/ # nested UI modules при самостоятельной роли
├── hooks/ # presentation behavior
├── stores/ # только локальный UI-state
├── providers/ # UI scope provider
├── types/ # props и UI contracts
├── styles/
├── lib/ # presentation helpers
├── tests/
└── index.ts
```
UI module может строиться на других UI modules и `shared`. Он не импортирует business, infra или compositions, не получает product data самостоятельно и не выбирает источник.
## Shared
`shared` содержит только детерминированный фундамент без знания о продукте и runtime-state.
```text
shared/
├── lib/
│ └── {utility}/
│ ├── {utility}.ts
│ ├── {utility}.test.ts
│ └── index.ts
├── types/
├── styles/
├── config/ # только product-agnostic constants
├── assets/ # product-agnostic assets при локальном соглашении
└── sprites/ # специализированная группа assets, если используется
```
Shared не содержит:
- product/domain types;
- stores и mutable singletons;
- SDK/client wrappers;
- React providers;
- environment-dependent services;
- imports из других SLM layers.
## Components внутри ui segment
Presentation component родительского module имеет плоскую структуру:
```text
{module}/ui/{component}/
├── {component}.tsx
├── types/
│ └── {component}-props.type.ts
├── styles/
│ └── {component}.module.css
└── index.ts
```
В папке component запрещены:
- `hooks/`, `stores/`, `services/`, `providers/`, `parts/`;
- source calls и scenario hooks;
- imports project code вне parent module, кроме разрешённых UI modules;
- nested components как отдельные architectural folders.
Если это требуется, сущность становится module и перемещается в `parts/` либо на общий composition/UI уровень.
## Nested modules в parts
Каждый элемент `parts/` является полноценным module:
```text
{parent}/parts/{part}/
├── {part}.tsx # visual root, если нужен
├── {part}.ts # non-visual root, если нужен
├── ui/
├── parts/
├── hooks/
├── stores/ # только state ответственности part
├── types/
├── styles/
├── tests/
└── index.ts
```
Одиночные `.tsx`, `.ts` или style files непосредственно в `parts/` запрещены. Если nested module нужен за пределами parent, подними его в минимальный общий scope.
## Segment matrix
| Segment | Consumer composition | Business integration | Business | Infra | UI | Shared |
|---|---|---|---|---|---|---|
| `ui/` | Да | Нет | Нет | Условно, technical UI | Да | Нет |
| `parts/` | Да | Нет | Нет | Условно | Да | Нет |
| `providers/` | Да | Нет | Нет | Да | Условно | Нет |
| `guards/` | Да, над готовым DomainApi | Нет | Нет | Нет | Нет | Нет |
| `hooks/` | Да | Нет | Только wrappers над deps | Technical hooks | Presentation hooks | Нет |
| `stores/` | Только UI-state | Нет, используй `adapters/` | Нет concrete stores | Technical state | Только UI-state | Нет |
| `services/` | Orchestration готовых API | Нет, используй `adapters/` | Domain scenarios над deps | Technical services | Presentation-only | Нет |
| `adapters/` | Нет product adapters | Да | Нет | Только transport adapters по локальному соглашению | Нет | Нет |
| `mappers/` | Domain -> ViewModel | Нет, transport adaptation остаётся в adapter | `unknown` -> domain | Transport mapping | View mapping | Чистые generic transforms |
| `errors/` | UI/composition errors | Нет domain errors | Domain errors | Technical/transport errors | UI errors | Только generic errors |
| `types/` | Module contracts | Integration input types | Domain contracts | Technical contracts/DTO | Props/UI contracts | Product-agnostic types |
| `styles/` | Да | Нет | Нет | Условно | Да | Global/foundation styles |
| `lib/` | Local helpers | Assembly helpers | Domain deterministic helpers | Technical helpers | Presentation helpers | Generic deterministic helpers |
| `config/` | Composition constants | Runtime assembly config | Domain constants | Technical config/env | UI constants | Product-agnostic constants |
| `tests/` | Scope/integration tests | Обязательные assembly tests | Обязательные factory tests | Technical tests | UI tests | Unit tests |
## Имена обычных файлов
| Pattern | Назначение |
|---|---|
| `{name}.type.ts` | Module-owned type |
| `{name}-props.type.ts` | Component props |
| `{name}.hook.ts`, `use-{name}.hook.ts` | Hook владельца |
| `{name}.store.ts` | Concrete store только допустимого owner/scope |
| `{name}.service.ts` | Scenario или technical service по layer |
| `{name}.adapter.ts` | Adapter с явно определённым видом |
| `map-{name}.ts`, `normalize-{name}.ts` | Mapper/normalizer владельца |
| `{name}.provider.tsx` | Provider владельца scope |
| `{name}.guard.tsx` | Route/UI guard consumer composition |
| `{name}.context.ts`, `{name}.context.tsx` | Private context implementation |
| `{name}.error.ts` | Error соответствующего layer |
| `{name}.config.ts` | Configuration владельца |
| `{name}.constant.ts` | Константа владельца |
| `{name}.module.css` | Styles конкретного module/component |
| `{name}.test.ts`, `{name}.test.tsx` | Colocated test |
Suffix описывает техническую форму, но не переносит ownership. `user.type.ts` не становится shared только потому, что это type; `auth.store.ts` не становится infra только потому, что использует Zustand.
## Public API по типам modules
| Module | Runtime exports | Type exports | Не экспортировать |
|---|---|---|---|
| Consumer composition | Entry/provider/access hooks | Props, state/view types | Raw context/store factory/internal parts |
| Business integration | `create{Domain}Business` | Cross-domain/request input types | Adapters, clients, mocks |
| Business | Только `{domainName}Factory` | Api, Deps, Factory, domain/error types | Services, hooks, mappers, error class |
| Infra | Минимальный technical API | Technical contracts | Mutable internals и generated tree без необходимости |
| UI | Root component и доказанные UI helpers | Props/UI types | Internal components/store/context |
| Nested module | Root entry | Props/module types | Parent internals |
Если дочерние layout/screen/widget импортируют access hooks владельца scope, не экспортируй из того же public API готовый entry, который импортирует эти дочерние modules. Раздели scope API и ready entry на отдельные composition modules, чтобы не создать runtime-цикл.
## Tests map
| Проверяемая граница | Размещение | Обязательность |
|---|---|---|
| Business public contract | `business/{domainPath}/tests/{domainName}-factory/` | Обязательно |
| Business mapper/normalizer/type guard/domain error | Рядом с файлом | Обязательно для каждого такого файла |
| Business service/hook wrapper | Рядом с файлом | При самостоятельной branching/race/runtime-safe логике |
| Adapter и builder wiring | `compositions/business/{domainPath}/*.test.ts` | Обязательно для каждого business-модуля |
| Graph lifecycle/provider | Tests graph owner module | При state/subscriptions/resources |
| Consumer composition | Рядом или `tests/` module | По поведению scope |
| Infra transport/service | Внутри infra module | По technical contract |
| UI module/component | Внутри UI module | По интерактивному поведению |
## Запрещённые структуры
```text
business/auth/ui/ # React UI внутри business
business/auth/stores/auth.store.ts # concrete Zustand/Redux store
business/auth/adapters/backend.adapter.ts # concrete runtime adapter
business/auth/types/sdk-response.type.ts # generated/external DTO contract
compositions/pages/profile/services/api.ts # прямой product source
compositions/screens/profile/store.ts # product/domain cache в screen
infra/business/ # product graph/provider в infra
shared/user.type.ts # product domain type в shared
business/app/index.ts # group с public API
compositions/pages/index.ts # group с public API
{module}/parts/hero.tsx # файл вместо nested module
{module}/ui/card/hooks/ # component с собственной логикой
```
## Новый тип файла или segment
Если нужной роли нет в атласе:
1. Назови ответственность файла без технического suffix.
2. Определи module-владельца и допустимые зависимости.
3. Проверь, не является ли файл существующим `service`, `adapter`, `mapper`, `provider` или nested module.
4. Создай новый segment только для нескольких файлов с одной устойчивой ролью.
5. Не создавай global segment на уровне `src/`.
6. Зафиксируй локальное соглашение, если pattern будет повторяться.
7. Обнови template, если новый pattern стал обязательной повторяемой структурой.
Не подгоняй ответственность под красивое дерево. Минимальный корректный module лучше полного scaffold без реального поведения.

148
docs/canons/index.md Normal file
View File

@@ -0,0 +1,148 @@
---
title: SLM Design
description: Назначение архитектуры, ключевые принципы и карта разделов документации
---
# Основы SLM Design
Scoped Layered Module Design — модульная архитектура фронтенд-приложений. Код организован по слоям ответственности, а модуль содержит всё, что ему нужно: компоненты, хуки, сторы, типы, стили.
## Рабочий алгоритм
1. Заполни архитектурную карточку из [процесса принятия решения](./decision-process.md): роль, владелец, данные, runtime-capabilities, место, public API, lifecycle и проверки.
2. Сначала выбери слой ответственности, затем module scope, затем segments и только после этого конкретные файлы.
3. Для product data и domain state примени [runtime-границу business](./business-runtime-boundary.md).
4. Выбирай минимальное корректное место и не создавай общий provider, store, package или business-контракт «на будущее».
5. После реализации пройди [архитектурную проверку](./validation.md). Задача не завершена без обязательных business и assembly tests.
## Дополнительные примеры
Каноны ниже достаточны для базового архитектурного решения. Если нужен подробный пример реализации, открой конкретный файл:
- [Композиция через Provider](../examples/react/composition-provider.md) — page-level provider, store и business composition.
- [Структуры compositions](../examples/react/composition-structures.md) — допустимые структуры слоя `compositions`.
- [Business composition](../examples/business-composition.md) — runtime-сборка business-фабрик в `compositions/business/{domain}`.
- [Тестирование business-модулей](../examples/business-testing.md) — factory-level тесты и тесты сборки.
## Разделы спецификации
Спецификация SLM Design состоит из нескольких связанных разделов. Этот обзор даёт общий контекст, а детальные правила описаны дальше:
- [Слои](./layers.md) — уровни организации `src/`, направление зависимостей и зона ответственности каждого слоя.
- [Модули](./modules.md) — границы ответственности, публичный API, типы модулей и отличие модуля от компонента.
- [Атлас файлов SLM](./file-atlas.md) — root files, segments, структуры всех типов modules, public API и tests.
- [Business-фабрика](./business-factory.md) — контракт business-модуля, logic API, deps, доменные ошибки и сборка фабрик.
- [Runtime-граница business](./business-runtime-boundary.md) — capabilities фабрики, adapters, hooks, stores и безусловные domain errors.
- [Сегменты](./segments.md) — внутренние папки модуля (`ui/`, `parts/`, `hooks/`, `types/` и другие) и правила размещения файлов.
- [Монорепозитории](./monorepo.md) — применение SLM в `apps/` и `packages/`, правила выноса общих слоёв и ограничения для business/compositions.
- [Архитектурная проверка](./validation.md) — блокирующие gates создания, рефакторинга и ревью.
Рекомендуемый порядок чтения: процесс решения → runtime-граница business → архитектурная проверка → атлас или подробности только нужной ветки.
## Преимущества
### Единый слой композиции
Страницы, маршруты и крупные продуктовые части интерфейса собираются в `compositions`. Слой не навязывает жёсткую структуру: команда может использовать `pages/layouts/screens/widgets` или другую организацию под свой фреймворк и продукт.
### Вертикальная организация домена
Бизнес-домен не разбивается по техническим слоям — сценарии, сущности, типы, hooks, services и mappers живут в одном модуле. Это сокращает время навигации и упрощает сопровождение: доменная логика локализована.
### Dependency Injection без фреймворков
Runtime-зависимости business-модуля реализуются через фабрики — модуль декларирует что ему нужно, а composition-сборка предоставляет зависимости. Домены изолированы от SDK, storage и backend-клиентов без DI-контейнеров и шин событий.
### Разделение ответственности без перегрузки слоёв
Композиция приложения (`compositions/`), сервисы приложения (`infra/`), UI-кит (`ui/`) и общие ресурсы (`shared/`) — разные слои с разной природой. Ни один слой не превращается в свалку разнородного кода.
### Графовая композиция там, где она нужна
Внутри `compositions` допускается граф импортов через публичный API. Это позволяет page-level store, provider или сборку business-фабрик использовать одновременно в layout, screen и widget, не перенося продуктовый runtime-state в `infra` или `shared`.
### Горизонтальная инкапсуляция
Вложенные модули (`parts/`) и публичные API позволяют нескольким разработчикам работать над одной областью приложения параллельно, не затрагивая код друг друга.
### Колокация по умолчанию
Код начинает жизнь рядом с местом использования и поднимается в общие слои только при реальной потребности. Глобальные слои не засоряются преждевременными абстракциями.
### Масштабирование через группировку
При росте проекта слои не теряют структуру — модули группируются по естественным признакам: композиции по страницам и маршрутам, бизнес-домены по субдоменам, UI-компоненты по уровню абстракции.
### Адаптация к монорепозиториям
SLM применяется внутри каждого приложения, а `packages/*` используются только для общего кода из слоёв `ui`, `infra` и `shared`. `compositions` и бизнес-домены остаются внутри приложений, чтобы не размывать продуктовые границы.
## Происхождение
SLM Design вырос на основе:
- **Feature-Sliced Design** — слоистая структура, публичный API модуля, направление зависимостей
- **Vertical Slice Architecture** — модуль как вертикальный срез, содержащий всё необходимое
- **Screaming Architecture** — структура проекта «кричит» о назначении: открыл `business/auth` — видишь авторизацию
- **Colocation Principle** — код живёт рядом с местом использования
## Пример структуры проекта
```text
src/
├── app/
├── compositions/
│ ├── business/
│ │ ├── auth/
│ │ └── user/
│ ├── pages/
│ │ ├── home/
│ │ ├── profile/
│ │ └── product-detail/
│ ├── layouts/
│ │ ├── main/
│ │ └── dashboard/
│ ├── screens/
│ │ ├── home/
│ │ └── profile/
│ └── widgets/
│ ├── page-heading/
│ └── promo-banner/
├── business/
│ ├── auth/
│ ├── catalog/
│ ├── orders/
│ └── chat/
├── infra/
│ ├── theme/
│ ├── i18n/
│ ├── backend-api/
│ └── logger/
├── ui/
│ ├── button/
│ ├── input/
│ ├── modal/
│ ├── toast/
│ └── dropdown/
└── shared/
├── lib/
├── types/
└── styles/
```
## Принципы
- **Композиция — отдельный слой.** Страницы, маршруты и крупные продуктовые части интерфейса собираются в `compositions`.
- **Структура композиции свободна.** Команда сама выбирает организацию внутри `compositions`; базовая рекомендация — `pages/layouts/screens/widgets`.
- **Домен — единое целое.** Доменная модель, сценарии, типы, services и mappers живут в одном business-модуле. Concrete data/state/query hooks передаются фабрике через adapters.
- **Колокация.** Код рождается рядом с местом использования и поднимается только при необходимости.
- **Зависимости однонаправлены за пределами compositions.** `app` подключает `compositions`; `compositions` связывает `business`, `infra`, `ui` и `shared`; `business` вызывает concrete runtime-возможности только через переданные фабрике `deps`.
- **Product data проходит через business.** Page, layout, screen и widget не обращаются к product source напрямую.
- **Ошибки принадлежат домену.** Из business API выходят только собственные domain errors со стабильным `code`.
- **Внутри compositions допустим граф.** Composition modules могут импортировать друг друга через public API.
- **Архитектура — каркас, не клетка.** Правила фиксируют границы ответственности и public API, а внутреннюю форму композиции определяет команда.

285
docs/canons/layers.md Normal file
View File

@@ -0,0 +1,285 @@
---
title: Слои
description: Иерархия слоёв от app до shared, правила зависимостей и зона ответственности каждого слоя
---
# Слои
Раздел описывает слои SLM: что такое слой, какие бывают, как между ними направлены зависимости и какие правила действуют на каждом.
## Определение
**Слой — уровень организации кода внутри `src/`. Каждый слой отвечает за свою область и задаёт правила для кода внутри: направление импортов, именование, допустимые связи между модулями.**
## Группы слоёв
Слои делятся на три группы:
| Группа | Слои | Описание |
|--------|------|----------|
| Композиция | `app`, `compositions` | Подключают приложение к фреймворку и собирают страницы, маршруты и крупные продуктовые части интерфейса |
| Ядро | `business`, `infra`, `ui` | Реализация продукта: бизнес-домены, техсервисы, UI-кит |
| Фундамент | `shared` | Общие ресурсы: утилиты, хелперы, стили, конфиги |
## Направление зависимостей
Любой импорт между модулями — только через публичный API.
```text
app → compositions
compositions → business | infra | ui | shared
business → shared
infra → infra | shared
ui → ui | shared
shared -/→ SLM-слои
```
- `app` подключает приложение к фреймворку и импортирует готовые composition modules
- `compositions` импортирует `business`, `infra`, `ui`, `shared`
- `business` импортирует только собственные файлы, детерминированный `shared`, чистые библиотеки и type-only контракты других business-модулей
- `infra` импортирует `infra` и `shared`
- `ui` импортирует `ui` и `shared`
- `shared` не импортирует другие SLM-слои
- `business`, `infra`, `ui`, `shared` не импортируют `compositions`
- Внутри `compositions` направление импортов между composition modules не фиксируется, но импорты разрешены только через публичный API и не должны создавать runtime-циклы
- Модули `business` вызывают любую runtime-capability только через `deps` фабрики; source/query hooks, stores, events и platform APIs также являются dependencies
- `import type` не разрешает переносить ownership: business не импортирует generated DTO, SDK/client/store/query types, а infra не объявляет product graph через type-only business imports
- Pure deterministic third-party libraries допустимы внутри business, если не имеют I/O/state/hidden environment и не протекают в public contract
## Слой App
Точка входа приложения. Отвечает за запуск, роутинг и подключение composition modules к фреймворку.
В отличие от остальных слоёв, `app/` не содержит модулей SLM. Здесь живут только инфраструктурные файлы, которые не могут быть никаким другим слоем: файлы фреймворка роутинга, точка запуска и код инициализации.
### Требования
- Не содержит модулей SLM — только файлы фреймворка, роутинг, инициализация
- Содержит: файлы маршрутов, bootstrap, обработку ошибок верхнего уровня (404, error boundary), подключение глобальных стилей и ассетов
- Провайдеры, guards, layouts, screens и страницы — только подключает готовые из `compositions`, не реализует
- Не содержит бизнес-логику, UI-компоненты, хуки, сторы, сервисы
- Никем не импортируется
## Слой Compositions
`compositions/` — слой сборки страниц, маршрутов и крупных продуктовых частей интерфейса.
На этом слое собираются page, layout, screen, widget и другие composition modules. Они связываются между собой и с нижними слоями: `business`, `infra`, `ui`, `shared`.
SLM не фиксирует жёсткую структуру внутри `compositions`. Команда выбирает организацию под фреймворк, роутинг, CMS и продуктовую задачу.
Базовая рекомендация:
```text
src/compositions/
├── business/
├── pages/
├── layouts/
├── screens/
└── widgets/
```
`business`, `pages`, `layouts`, `screens` и `widgets` внутри `compositions` не являются отдельными SLM-слоями. Это группы composition modules.
`compositions/business/{domain}` используется для runtime-сборки business-фабрик с реальными зависимостями приложения. Это не business-слой, а composition module, который адаптирует `infra`, SDK, storage и browser API к `deps` business-фабрики.
Внутри `compositions` различай три роли:
| Роль | Ответственность |
|---|---|
| Integration module | `compositions/business/{domain}` реализует adapters и собирает одну business-фабрику |
| Graph owner | Page/route/provider/request scope собирает готовые business API и управляет lifecycle |
| Consumer composition | Page/layout/screen/widget вызывает `{Domain}Api` и не знает concrete product source |
Consumer composition получает продуктовые данные только через business API. Прямые SDK/HTTP/generated/storage вызовы разрешены только dependency adapters интеграционного модуля домена.
Технический infra-сервис без product data можно использовать в composition напрямую. Если такой сервис нужен business, он всё равно описывается business-owned capability и передаётся фабрике через adapter.
Если business-домены сгруппированы, `compositions/business` повторяет ту же группировку: `business/app/auth` соответствует `compositions/business/app/auth`, `business/cms/content` соответствует `compositions/business/cms/content`. Это не default-структура, а способ сохранить навигацию в крупных проектах.
Composition module может содержать обычные сегменты SLM: `ui/`, `parts/`, `hooks/`, `stores/`, `services/`, `mappers/`, `types/`, `styles/`, `lib/`, `config/`, `providers/`.
Page-level store, provider, guard или business composition размещаются внутри page composition module, если они нужны всей странице.
```text
compositions/pages/profile/
├── profile.page.tsx
├── profile-business-composition.ts
├── providers/
├── hooks/
├── stores/
├── types/
└── index.ts
```
Layout, screen и widget могут получать через public API page composition локальный UI-state и готовые `{Domain}Api`. Product data не копируется в page store как параллельный источник истины.
```ts
import { useProfilePageStore } from '@/compositions/pages/profile'
```
Внутри `compositions` направление импортов между composition modules не фиксируется. Допустим граф, но все импорты идут только через public API.
```ts
// Хорошо
import { useProfilePageStore } from '@/compositions/pages/profile'
// Плохо
import { useProfilePageStore } from '@/compositions/pages/profile/hooks/use-profile-page-store.hook'
```
### Требования
- `compositions` содержит composition modules страниц, маршрутов и крупных продуктовых частей интерфейса
- Структура внутри `compositions` выбирается командой
- Базовая рекомендация: `business/`, `pages/`, `layouts/`, `screens/`, `widgets/`
- `business`, `pages`, `layouts`, `screens`, `widgets` внутри `compositions` являются группами composition modules
- `compositions/business/{domain}` собирает конкретную business-фабрику с runtime-зависимостями; при группировке используется зеркальный путь `compositions/business/{group}/{domain}`
- Concrete product sources, source/query hooks, domain stores, browser APIs и events подключаются только adapters интеграционного module
- Page/layout/screen/widget используют product data только через `{Domain}Api`
- Builder одной фабрики явно создаёт или получает scoped runtime instances без I/O, создаёт adapters поверх них и передаёт adapters factory; integration logic не пишется inline
- Providers, stores, guards и business composition размещаются внутри того composition module, которому они принадлежат
- Внутри `compositions` импорты между composition modules разрешены в любую сторону, но только через public API
- Runtime-циклы между composition modules запрещены
- Deep imports внутрь composition modules запрещены
- `business`, `infra`, `ui` и `shared` не импортируют `compositions`
## Слой Business
Бизнес-домены приложения: auth, catalog, orders, checkout, chat. Каждый домен — отдельный модуль со своими типами, hooks, services, mappers и доменной логикой.
Слой входит в группу «Ядро». Импортирует собственные файлы, детерминированный `shared/`, чистые библиотеки и type-only контракты других business-модулей. Каждый бизнес-модуль создаёт публичный API фабрики в корне. Любые runtime-capabilities передаются через аргументы фабрики.
Business объединяет то, что в FSD разделено на `features` и `entities`: пользовательские сценарии и бизнес-сущности живут вместе, внутри одного домена. Внутри домена сегменты разделяют ответственность: `types/` — доменная модель и dependency contracts, `hooks/` и `services/` — wrappers над переданными capabilities, `mappers/` — доменная нормализация, `lib/` — детерминированные доменные утилиты.
Business-модуль не содержит React-компоненты, layouts, guards, providers и page-level wrappers. Визуальный fallback, route boundary и привязка logic API к React tree размещаются в `compositions`; error mapping и допустимый доменный fallback остаются в business.
```text
src/business/
├── auth/
├── catalog/
├── orders/
├── checkout/
└── chat/
```
Когда количество доменов затрудняет навигацию — можно ввести группировку по крупным предметным областям. Группа — папка для организации, не модуль и не public API.
```text
src/business/
├── app/
│ ├── auth/
│ ├── profile/
│ └── orders/
└── cms/
├── content/
├── media/
└── navigation/
```
`app` и `cms` здесь являются группами доменов. Модулями остаются конечные папки: `auth`, `profile`, `content`, `media`, `navigation`.
### Требования
- Один модуль = один бизнес-домен
- Business-домены можно группировать при необходимости; группа не является модулем и не содержит `index.ts`
- Циклические зависимости между доменами запрещены
- Публичный API фабрики — через фабрику в корне модуля (`{name}.factory.ts`). `index.ts` экспортирует только фабрику и type-only экспорты, без исключений
- Фабрика возвращает только logic API: hooks, selectors, command/query methods, scenario services
- Business-модуль не содержит React-компоненты и не возвращает компоненты из фабрики
- Любые runtime-capabilities — только через `deps` фабрики: product sources, hooks, stores, events, technical services, env и browser APIs
- Business не импортирует React/SWR/query/store runtime; concrete hooks и stores реализуются adapters
- Реальные SDK, API-клиенты, storage, state/query runtime, env и browser API подключаются в `compositions/business/{domain}` или зеркальном пути при группировке
- Business нормализует внешние результаты и подставляет только собственные domain errors
- Доменные типы (`User`, `Product`) живут здесь, не в `shared/`
## Слой infra
Техсервисы приложения: theme, i18n, API-адаптеры, logger, realtime. Каждый сервис — отдельный модуль.
Слой входит в группу «Ядро». Импортирует `infra/` и `shared/`.
Отличие от `shared/`: infra — инфраструктура приложения (сервисы, темы, адаптеры к API), `shared/` — общие ресурсы (утилиты, хелперы, стили, конфиги).
```text
src/infra/
├── theme/
├── i18n/
├── backend-api/
├── maps-api/
├── logger/
├── feature-flags/
└── realtime/
```
### Требования
- Один модуль = один техсервис
- Импортирует `infra/` и `shared/`
- Не содержит продуктовые composition modules конкретных страниц или маршрутов
## Слой UI
UI-кит без бизнес-логики: button, carousel, toast, modal.
Слой входит в группу «Ядро». Импортирует `ui/` и `shared/`.
Компоненты строятся друг на друге: `button` использует `icon`, `carousel` использует `button`.
```text
src/ui/
├── button/
├── input/
├── icon/
├── carousel/
├── modal/
├── toast/
├── dropdown/
├── tabs/
└── tooltip/
```
Когда количество компонентов затрудняет навигацию — вводится группировка на примитивы и композиции. Примитивы (`button`, `icon`, `input`) не импортируют композиции. Композиции (`carousel`, `modal`, `dropdown`) строятся на примитивах.
```text
src/ui/
├── primitives/
│ ├── button/
│ ├── input/
│ ├── icon/
│ └── badge/
└── composites/
├── carousel/
├── modal/
├── dropdown/
├── tabs/
└── tooltip/
```
### Требования
- Не содержит бизнес-логику
- Импортирует только `ui/` и `shared/`
## Слой Shared
Общие ресурсы: утилиты, хелперы, стили, конфиги. Не знает о бизнес-домене.
Слой входит в группу «Фундамент» — ни о ком не знает, никого не импортирует.
Отличие от `infra/`: infra — инфраструктура приложения (сервисы, темы, адаптеры к API), `shared/` — общие ресурсы (утилиты, хелперы, стили, конфиги).
Отличие от `ui/`: UI-компоненты (button, carousel, modal) живут в слое `ui/`, а не здесь.
```text
src/shared/
├── lib/
├── types/
├── styles/
└── sprites/
```
### Требования
- Не имеет runtime-состояния
- Не знает о продуктовых composition modules

300
docs/canons/modules.md Normal file
View File

@@ -0,0 +1,300 @@
---
title: Модули
description: Структура модуля, типы (композиционный, UI, бизнес, инфра), публичный API, отличие модуля от компонента
---
# Модули
Раздел описывает модуль как границу ответственности в SLM: что считается модулем, что такое компонент внутри модуля и как модуль взаимодействует с остальным кодом.
## Определение
**Модуль — минимальная архитектурная единица SLM. Он живёт на одном из слоёв, владеет конкретной областью ответственности и предоставляет наружу только публичный API.**
Модуль может содержать всё, что нужно этой области: компоненты, вложенные модули, хуки, сторы, сервисы, типы, стили, конфиги и утилиты. Набор сегментов не фиксирован — модуль включает только то, что реально нужно.
Модуль не обязан быть UI-блоком. Это может быть page composition, layout composition, screen composition, widget composition, бизнес-домен, инфраструктурный сервис или UI-kit сущность.
Главная граница модуля — не папка, а ответственность.
## Компонент
**Компонент — презентационная единица модуля, которая находится только в `ui/` своего родительского модуля и отвечает за отображение части интерфейса.**
Компонент не является архитектурной единицей: он не владеет сценарием, зависимостями, данными или внутренней структурой. Он работает только внутри границы родительского модуля.
> Компонент отображает. Модуль организует.
Компонент не может:
- Импортировать код проекта за пределами родительского модуля. Единственное исключение — компоненты слоя `ui`, если правила слоёв разрешают родительскому модулю импортировать `ui`.
- Владеть архитектурными зависимостями.
- Содержать вложенные компоненты: папка компонента включает только `{name}.tsx`, `index.ts`, `styles/`, `types/`.
- Содержать вложенные модули.
- Делать внешние запросы.
- Самостоятельно получать данные.
- Выбирать источник данных.
- Композировать данные.
- Вызывать сценарные хуки.
- Оркестрировать сценарий.
- Композировать модули.
- Решать, как устроен процесс.
- Содержать бизнес-логику.
- Содержать сценарную логику.
Компонент может рендерить другие компоненты: соседние компоненты из `ui/` своего модуля, компоненты слоя `ui` и элементы, переданные через props. Запрет «содержать» относится к структуре папки, а не к JSX-разметке: декомпозиция компонентов остаётся плоской внутри `ui/` родительского модуля.
Если компоненту требуется что-то из запрещённого списка, он перестаёт быть компонентом и должен быть оформлен как модуль.
```text
auth/
├── ui/
│ └── logout-button/
│ ├── logout-button.tsx
│ ├── styles/
│ │ └── logout-button.module.css
│ ├── types/
│ │ └── logout-button-props.type.ts
│ └── index.ts
└── index.ts
```
## Что считается модулем
Модулем считается папка, которая представляет самостоятельную область ответственности и имеет публичную границу.
## Группы модулей
Слой может содержать не только модули, но и группы модулей. По умолчанию модуль лежит прямо в слое; группу вводят только когда модулей много и нужна явная классификация.
Группа — навигационная папка внутри слоя или другой группы. Она классифицирует модули по типу, предметной области, продуктовой зоне или runtime-назначению, но сама не является модулем.
Жёсткие правила группы:
- группа не имеет public API;
- группа не содержит `index.ts`;
- группа не импортируется внешним кодом;
- группа не владеет логикой, состоянием, deps или runtime-сборкой;
- группа может содержать другие группы и конечные модули.
Модулем считается конечная папка с самостоятельной ответственностью и публичной границей.
```text
src/business/
├── app/ # группа
│ ├── auth/ # business-модуль
│ └── profile/ # business-модуль
└── cms/ # группа
├── content/ # business-модуль
└── media/ # business-модуль
```
Примеры модулей:
- `compositions/pages/home/` — модуль page composition.
- `compositions/layouts/main/` — модуль layout composition.
- `compositions/screens/profile/` — модуль screen composition.
- `compositions/widgets/page-heading/` — модуль widget composition.
- `business/auth/` — модуль бизнес-домена.
- `infra/theme/` — модуль инфраструктурного сервиса.
- `ui/button/` — модуль UI-kit сущности.
- `compositions/pages/home/parts/hero-section/` — вложенный модуль page composition.
Не считаются модулями:
- `ui/`, `parts/`, `hooks/`, `types/`, `styles/`, `config/`, `providers/` — это сегменты.
- `compositions/pages/`, `business/app/`, `business/cms/` — это группы, если в них нет `index.ts`.
- `compositions/pages/home/ui/user-card/` — это компонент, если он находится в `ui/` и соблюдает ограничения компонента.
## Типы модулей
Тип модуля определяет обязательный корневой файл и стартовую структуру.
### Композиционный модуль
Композиционный модуль — модуль внутри `compositions`, который участвует в сборке страниц, маршрутов и крупных продуктовых частей интерфейса.
Он может быть page, layout, screen, widget, block, entry-point, CMS-entry, route segment или другим типом композиции, выбранным командой.
```text
compositions/pages/profile/
├── profile.page.tsx
├── profile-business-composition.ts
├── providers/
├── hooks/
├── stores/
├── parts/
├── types/
└── index.ts
```
Композиционный модуль может импортировать другие composition modules через public API. Это отличие слоя `compositions`: внутри него допускается графовая композиция.
При этом deep imports запрещены.
```ts
// Хорошо
import { useProfilePageStore } from '@/compositions/pages/profile'
// Плохо
import { useProfilePageStore } from '@/compositions/pages/profile/hooks/use-profile-page-store.hook'
```
### UI-модуль
Модуль строится вокруг основного UI-компонента и обязан иметь основной `.tsx` файл в корне:
```text
button/
├── button.tsx
└── index.ts
```
`ui/` внутри такого модуля используется только для компонентов, которые помогают корневому `.tsx` файлу.
### Бизнес-модуль
Бизнес-модуль — модуль, который строится вокруг публичного API фабрики.
Business-модуль содержит доменную логику, типы, hooks, services, mappers и helpers. Он не содержит React-компоненты и не возвращает компоненты из фабрики.
Бизнес-модуль обязан иметь фабрику в корне:
```text
auth/
├── auth.factory.ts
├── index.ts
└── types/
```
Фабрика возвращает публичный API модуля для использования в runtime.
### Инфраструктурный модуль
Инфраструктурный модуль — модуль, который строится вокруг технического сервиса или интеграции.
Инфраструктурный модуль не обязан иметь фиксированный корневой файл. Его структура определяется природой сервиса.
```text
theme/
├── index.ts
├── config/
├── hooks/
├── styles/
└── ui/
```
```text
backend-api/
├── backend-api.client.ts
├── config/
├── types/
└── index.ts
```
## Структура
Модуль состоит из сегментов. Ни один сегмент не обязателен — модуль включает только те части, которые нужны его ответственности.
```text
{module-name}/
├── {module-name}.factory.ts # фабрика (для business-модулей)
├── {module-name}.tsx # корневой файл модуля (опционален)
├── ui/ # компоненты модуля, кроме business-модулей
├── parts/ # вложенные модули
├── providers/ # провайдеры модуля
├── hooks/ # хуки
├── stores/ # сторы состояния
├── services/ # сценарии и операции модуля
├── mappers/ # трансформация на границе ответственности
├── types/ # типы
├── styles/ # стили
├── lib/ # утилиты модуля
├── config/ # константы и конфигурация
└── index.ts # публичный API
```
Подробное описание сегментов — в разделе [Сегменты](./segments.md).
## Публичный API
Внешний код импортирует модуль только через публичный API.
```ts
// Хорошо
import { customerFactory } from '@/business/customer'
import type { Customer } from '@/business/customer'
```
```ts
// Плохо
import { validateToken } from '@/business/auth/lib/tokens'
```
`index.ts` модуля не обязан экспортировать всё содержимое. Он экспортирует только то, что действительно нужно снаружи.
Внутренние сегменты модуля остаются деталями реализации.
Business-модуль экспортирует из `index.ts` только фабрику и type-only экспорты. Это жёсткое правило без исключений.
```ts
// business/customer/index.ts
export { customerFactory } from './customer.factory'
export type { Customer } from './types/customer.type'
export type { CustomerApi } from './types/customer-api.type'
export type { CustomerDeps } from './types/customer-deps.type'
export type { CustomerFactory } from './types/customer-factory.type'
```
Composition module экспортирует через `index.ts` только безопасный контракт, который нужен другим composition modules или `app`: page/layout/screen/widget, provider, hooks доступа, типы. Внутренние stores, context objects и функции создания состояния не экспортируются без необходимости.
Stateful module по умолчанию не экспортирует raw context, `StoreApi`, mutable singleton, persistence key или concrete adapter. Экспортируй domain/technical commands, selectors и access hooks. Каждый mutable export требует реального внешнего consumer и отдельного обоснования.
Если layout, screen или widget импортируют hooks из page composition, не смешивайте в одном public API готовую page composition и hooks для дочерних модулей: это может создать runtime-цикл.
```ts
// compositions/pages/profile/index.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'
```
## Фабрика
Business-модуль всегда экспортирует фабрику. Фабрика лежит в корне модуля (`{name}.factory.ts`), типизируется через `{Name}Factory` и возвращает публичный logic API фабрики.
Всё, что нужно внешнему коду в runtime, должно быть частью API, который возвращает фабрика.
Фабрика не возвращает React-компоненты, layouts, guards, boundaries, providers или page-level wrappers. Business-модуль не содержит React-компоненты: UI-решения домена размещаются в `compositions`, а полностью универсальные UI-компоненты — в `ui`.
Модуль без runtime-capabilities экспортирует фабрику без аргументов. Модуль с зависимостями экспортирует фабрику, принимающую `deps`: API других доменов и внешние возможности, описанные бизнес-языком. Source/query hooks, state stores, subscriptions, browser API, clock и другие runtime-механизмы также считаются dependencies. Типы всегда экспортируются напрямую через `export type`, но `import type` не разрешает протащить в business generated DTO, SDK/store/query contracts.
Runtime-сборка фабрики с реальными SDK, storage, infra-клиентами, source hooks, stores, events и browser API происходит в `compositions/business/{domain}` через отдельные adapters. Builder явно создаёт или получает scoped runtime instances без I/O, создаёт adapters поверх них и передаёт adapters factory. Конечный граф business API собирается в месте, которое владеет lifecycle: page composition, route composition, application-lifetime composition provider, request scope или test setup.
### Примеры
Подробные правила фабрики см. в [Business-фабрика](./business-factory.md).
Пример runtime-сборки business-фабрик см. в [Business composition](../examples/business-composition.md).
Пример page-level Provider в React см. в [Композиция через Provider](../examples/react/composition-provider.md).
Примеры разных структур слоя `compositions` см. в [Структуры compositions](../examples/react/composition-structures.md).
## Жизненный цикл
Модуль рождается на самом низком уровне использования и поднимается выше только при реальной потребности.
- Нужен одной странице, route branch или крупной продуктовой части интерфейса → внутри соответствующего composition module.
- Нужен нескольким частям одной страницы → внутри page composition или другого общего composition scope.
- Нужен нескольким страницам или маршрутам → отдельный composition module внутри `compositions`.
- Абстрактный UI без бизнес-логики → `ui/`.
- Сценарий, product data contract, domain state, тип или доменная логика → `business/{domain}/`.
- Concrete implementation business dependency → adapter в `compositions/business/{domain}/`.
- Технический сервис → `infra/`.
- Общая чистая утилита → `shared/`.
Подъём — обычный рефакторинг в рамках задачи, а не отдельная активность.

229
docs/canons/monorepo.md Normal file
View File

@@ -0,0 +1,229 @@
---
title: Монорепозитории
description: Правила применения SLM Design для frontend-проектов, находящихся в монорепозитории
---
# Монорепозитории
Раздел описывает, как применять SLM Design, когда фронтенд-проекты находятся в одном монорепозитории. В нём показано, что остаётся внутри приложений, что можно выносить в `packages/` и какие ограничения действуют для общих пакетов.
## Определение
**Монорепозиторий — внешний уровень организации нескольких фронтенд-приложений и общих пакетов. SLM применяется внутри каждого приложения, а frontend-пакеты, относящиеся к SLM, содержат переиспользуемый код, вынесенный из слоёв `ui`, `infra` и `shared`.**
## Базовая структура
Каждое приложение внутри `apps/` сохраняет собственную SLM-структуру в `src/`.
```text
repo/
├── apps/
│ ├── web/
│ │ └── src/
│ │ ├── app/
│ │ ├── compositions/
│ │ ├── business/
│ │ ├── infra/
│ │ ├── ui/
│ │ └── shared/
│ └── admin/
│ └── src/
│ └── ...
└── packages/
├── ui/
│ ├── button/ # самостоятельный пакет UI-модуля
│ ├── input/ # самостоятельный пакет UI-модуля
│ └── modal/ # самостоятельный пакет UI-модуля
├── infra/
│ ├── theme/ # самостоятельный пакет infra-модуля
│ ├── backend-api/ # самостоятельный пакет infra-модуля
│ └── logger/ # самостоятельный пакет infra-модуля
└── shared/ # единый shared-пакет
├── package.json
└── src/
├── lib/ # переиспользуемые утилиты
├── helpers/ # переиспользуемые helpers
└── index.ts
```
`apps/{app}/src` — граница SLM-приложения. `packages/*` находятся выше SLM и не добавляют новые архитектурные слои.
## Группировка frontend-пакетов
Frontend-пакеты, вынесенные из SLM-приложений, рекомендуется группировать по источнику кода: `ui`, `infra`, `shared`.
```text
packages/ui/* # пакеты UI-модулей
packages/infra/* # пакеты infra-модулей
packages/shared # единый shared-пакет
```
Эта группировка повторяет названия SLM-слоёв для навигации, но сама не является слоистой архитектурой внутри `packages/`. Монорепозиторий может содержать другие пакеты: tooling, конфиги, SDK, схемы, e2e и другие технические пакеты вне SLM.
## Пакет и модуль
Пакет не равен SLM-модулю: модуль — архитектурная единица внутри слоя приложения, package — единица монорепозитория для переиспользования, владения, сборки и публикации.
В `packages/ui/*` размещаются пакеты самостоятельных UI-модулей. В `packages/infra/*` размещаются пакеты самостоятельных инфраструктурных модулей. `packages/shared` устроен иначе: это единый пакет для переиспользуемых утилит, helpers и другого фундаментального кода без привязки к конкретному приложению.
```text
packages/ui/button/
packages/ui/modal/
packages/infra/theme/
packages/infra/backend-api/
packages/shared/
```
## Что остаётся в приложении
Слои `app`, `compositions` и `business` остаются внутри конкретного приложения.
```text
apps/web/src/app/
apps/web/src/compositions/
apps/web/src/business/
```
`app` привязан к фреймворку и entry points приложения.
`compositions` привязан к страницам, маршрутам и крупным продуктовым частям интерфейса конкретного приложения. Этот слой не выносится в `packages/*`, потому что отражает продуктовую сборку приложения.
`business` не выносится в `packages/*`. Домены остаются рядом со сценариями приложения, чтобы не превращать монорепозиторий в общий бизнес-слой.
## Что можно выносить
В пакеты выносится только код из `ui`, `infra` и `shared`, который уже нужен двум фронтенд-приложениям либо имеет явно зафиксированный межприложенческий ownership/reuse-контракт.
| Группа | Что выносить | Пример |
|--------|--------------|--------|
| `packages/ui/*` | Самостоятельные UI-модули без бизнес-логики | `packages/ui/button` |
| `packages/infra/*` | Самостоятельные технические сервисы | `packages/infra/backend-api` |
| `packages/shared` | Общие утилиты, helpers и фундаментальный код | `packages/shared` |
По умолчанию начинай внутри приложения. Пакет можно создать до второго consumer только при явном архитектурном решении, известном consumer и стабильном межприложенческом контракте. Абстрактная «общая природа» сама по себе недостаточна.
## UI-пакеты
В `packages/ui/*` размещаются переиспользуемые UI-модули.
```text
packages/ui/button/
├── package.json
└── src/
├── button.tsx
├── styles/
├── types/
└── index.ts
```
UI-пакет не содержит бизнес-логику, обращения к API, сценарные хуки приложения и композицию страниц.
## Infra-пакеты
В `packages/infra/*` размещаются переиспользуемые инфраструктурные модули.
```text
packages/infra/backend-api/
├── package.json
└── src/
├── clients/
├── config/
├── types/
└── index.ts
```
Привязанные к конкретному приложению сервисы остаются в `apps/{app}/src/infra`. Например, локализация со словарями конкретного продукта остаётся в приложении; общим пакетом может быть только переиспользуемый i18n-движок.
## Shared-пакет
`packages/shared` является единым пакетом.
```text
packages/shared/
├── package.json
└── src/
├── lib/
├── helpers/
└── index.ts
```
В `packages/shared` сразу выносится общий фундаментальный код: чистые функции, helpers, утилиты, независимые константы и другой код без знания о продукте.
Проектные стили, типы приложения, продуктовые конфиги и ресурсы, завязанные на одно приложение, в общий `shared` не выносятся.
## Имена пакетов и импорты
Путь импорта задаётся `name` в `package.json`, а не расположением директории.
```json
{
"name": "@repo/theme"
}
```
```text
packages/infra/theme/package.json
```
```ts
import { ThemeProvider } from '@repo/theme'
```
Пакеты должны импортироваться только через публичный API. Deep imports внутрь пакета запрещены.
```ts
// Хорошо
import { Button } from '@repo/button'
// Плохо
import { Button } from '@repo/button/src/button'
```
## Зависимости
На уровне монорепозитория приложения зависят от пакетов, а пакеты не зависят от приложений.
```text
apps → packages
packages -/→ apps
```
Внутри приложения продолжает действовать обычное направление зависимостей SLM: `app` подключает `compositions`, `compositions` связывает `business`, `infra`, `ui` и `shared`, а `business` вызывает concrete runtime-capabilities только через переданные фабрике `deps`.
Пакеты не должны нарушать природу своей группы: `packages/ui/*` не импортирует `packages/infra/*`, `packages/shared` не импортирует другие группы, а `packages/infra/*` не знает о приложениях.
## Когда не выносить
Не выносите код в пакет, если он не может быть использован в двух и более фронтенд-приложениях, зависит от роутинга или страниц, содержит бизнес-логику, отражает продуктовую композицию конкретного интерфейса или не имеет стабильного публичного API.
Фактическое использование в одном приложении допустимо для package только при зафиксированном втором consumer или внешнем ownership/reuse-контракте. Иначе сохрани локальную колокацию.
```text
# Плохо
apps/web/src/compositions/pages/home/parts/promo-section/
packages/ui/promo-section/
```
Если блок нужен только одной странице или отражает продуктовую композицию конкретного приложения, он остаётся локальным composition module.
## Конфигурационные пакеты
Конфигурационные пакеты не относятся к SLM-архитектуре.
Если в монорепозитории есть общие настройки TypeScript, ESLint, сборки или форматирования, они относятся к tooling-инфраструктуре репозитория. Такие пакеты могут находиться в `packages/`, но их структура зависит от выбранного инструментария и не участвует в правилах слоёв внутри `src/`.
## Правила
- SLM применяется внутри каждого `apps/{app}/src`.
- Frontend-пакеты, вынесенные из SLM-приложений, группируются в `packages/ui`, `packages/infra`, `packages/shared`.
- Группы `packages/ui`, `packages/infra`, `packages/shared` не являются SLM-слоями.
- В `packages/ui/*` размещаются пакеты самостоятельных UI-модулей.
- В `packages/infra/*` размещаются пакеты самостоятельных инфраструктурных модулей.
- `packages/shared` является единым пакетом для переиспользуемых утилит и helpers.
- Модуль можно размещать в package при реальном втором consumer или явном межприложенческом ownership/reuse-контракте.
- `app`, `compositions` и `business` не выносятся в пакеты.
- Проектные стили, типы приложения и продуктовые конфиги не выносятся в `packages/shared`.
- Пакеты не импортируют приложения.
- Межпакетные импорты идут только через публичный API.
- Deep imports внутрь пакетов запрещены.
- Локальная колокация важнее преждевременного выноса в `packages/*`.

222
docs/canons/segments.md Normal file
View File

@@ -0,0 +1,222 @@
---
title: Сегменты
description: Сегменты внутри модуля (ui/, parts/, hooks/ и др.), назначение и правила размещения файлов
---
# Сегменты
Раздел описывает сегменты SLM: что такое сегмент, какие бывают и что в каждом из них лежит.
## Определение
**Сегмент — папка внутри модуля, которая группирует файлы по назначению. Набор сегментов не фиксирован — модуль включает только те, которые ему нужны. Команда сама определяет какие сегменты используются в проекте — архитектура даёт рекомендацию.**
## Обзор
| Сегмент | Содержимое |
|---------|------------|
| `ui/` | Презентационные компоненты родительского модуля |
| `parts/` | Вложенные модули со своими сегментами |
| `providers/` | Провайдеры модуля |
| `hooks/` | React-хуки |
| `stores/` | Сторы состояния |
| `services/` | Сценарии и операции владельца module |
| `mappers/` | Трансформация данных между форматами |
| `types/` | TypeScript-типы и интерфейсы |
| `styles/` | Стили |
| `lib/` | Утилиты и хелперы модуля |
| `config/` | Константы и конфигурация |
Сегменты не являются обязательными. Например, `providers/` нужен только модулю, который владеет провайдерами. Если provider, store или guard относится к конкретной странице или маршруту, он размещается внутри соответствующего composition module, а не в `infra` или `shared`.
Business-модули не используют `ui/` для React-компонентов. Доменный logic API живёт в factory/services/hook wrappers/mappers. React tree, guards, layouts и visual fallbacks размещаются в consumer compositions; concrete domain source hooks/stores — в adapters `compositions/business/{domain}`.
## Сегмент ui/
Презентационные компоненты родительского модуля. `ui/` содержит только компоненты, которые отвечают за отображение части интерфейса и не выходят за границы своего модуля.
Компонент в `ui/`:
- Находится в собственной папке.
- Может содержать только `{name}.tsx`, `index.ts`, `styles/`, `types/`.
- Не содержит вложенные компоненты и модули — папка компонента остаётся плоской.
- Может рендерить соседние компоненты из `ui/` своего модуля и компоненты слоя `ui`, если правила слоёв разрешают родительскому модулю импортировать `ui`.
- Не импортирует другой код проекта за пределами родительского модуля.
- Не делает внешние запросы.
- Не вызывает сценарные хуки.
- Не получает данные самостоятельно, не выбирает источник данных и не композирует данные.
- Не содержит бизнес-логику или сценарную логику.
Если UI-сущности нужно что-то за пределами этих ограничений, она должна быть оформлена как модуль. Полная граница описана в разделе [Компонент](./modules.md#компонент).
Корневой файл модуля в `ui/` не размещается. Он лежит в корне модуля: `{module-name}.tsx`.
```text
user/
├── ui/
│ ├── user-avatar/
│ │ ├── user-avatar.tsx
│ │ ├── styles/
│ │ │ └── user-avatar.module.css
│ │ ├── types/
│ │ │ └── user-avatar-props.type.ts
│ │ └── index.ts
│ └── user-status/
│ ├── user-status.tsx
│ └── index.ts
├── types/
├── hooks/
├── user.tsx
└── index.ts
```
Если UI-сущности нужна внутренняя декомпозиция, сценарная логика, получение данных или собственные архитектурные зависимости — это уже не компонент в `ui/`, а модуль в `parts/`.
## Сегмент parts/
Вложенные модули со своими сегментами. `parts/` содержит только модули: каждый элемент `parts/` — папка полноценного модуля с собственным публичным API. Отдельные `.tsx`, стили, хуки или произвольные файлы в `parts/` не размещаются.
```text
compositions/pages/home/
├── parts/
│ ├── hero-section/
│ │ ├── hero-section.tsx
│ │ ├── styles/
│ │ ├── parts/
│ │ │ └── top-banner/
│ │ │ ├── top-banner.tsx
│ │ │ └── index.ts
│ │ └── index.ts
│ └── features-section/
│ ├── features-section.tsx
│ ├── hooks/
│ └── index.ts
├── home.page.tsx
└── index.ts
```
Отличие от `ui/`: элемент `parts/` — модульная папка со своими сегментами. Элемент `ui/` — компонент родительского модуля без собственной архитектурной ответственности.
Вложенность `parts/` инкапсулирует область разработки горизонтально: каждый разработчик работает в своём `parts/`-модуле, не затрагивая чужие. Это снижает конфликты при параллельной разработке.
Если вложенный модуль обрастает своими `parts/` — это сигнал, что он достаточно самостоятельный для подъёма на уровень выше.
## Сегмент providers/
Провайдеры модуля: React Context providers, провайдеры scope-состояния, провайдеры композиции фабрик или другие обёртки, которые принадлежат модулю.
```text
providers/
├── profile-page.provider.tsx
└── profile-business-composition.provider.tsx
```
Provider размещается в том модуле, который владеет соответствующим состоянием или композицией. Page-level provider живёт в page composition module; application-level provider, завязанный на фреймворк, подключается в `app`, но реализуется в нижнем подходящем слое.
## Сегмент hooks/
React-хуки модуля. Инкапсулируют логику, состояние, подписки, побочные эффекты.
```text
hooks/
├── use-auth.hook.ts
├── use-session.hook.ts
└── use-permissions.hook.ts
```
В business-модуле `hooks/` содержит только wrappers, созданные поверх dependency hooks, переданных фабрике. Business не импортирует React state/effect APIs, SWR, TanStack Query, Apollo или другой hook runtime напрямую.
Concrete source hook реализуется adapter-ом в `compositions/business/{domain}` и возвращает business-owned result type. Business wrapper нормализует данные и заменяет source error собственной domain error.
## Сегмент stores/
Сторы состояния composition/infra/UI module. Конкретная реализация зависит от выбранного state manager (Zustand, MobX, Redux и т.д.).
```text
stores/
├── auth.store.ts
└── session.store.ts
```
Если состояние нужно всей странице, concrete store живёт в page composition module. Если состояние относится к бизнес-домену, business владеет state model, transitions и state port. Concrete Zustand/Redux/MobX adapter factory реализуется в `compositions/business/{domain}`, передаётся через `deps`, принимает initial domain state от business-фабрики и возвращает concrete port.
Для каждого store определи creator, scope, количество instances и cleanup. Module singleton допустим только для явно доказанного application/process lifetime.
## Сегмент services/
Сценарии и операции module. Содержимое зависит от слоя и владельца.
```text
services/
├── auth.service.ts
└── token.service.ts
```
Правила по слоям:
- `business/services` реализует доменные сценарии только поверх `deps` фабрики;
- `compositions/business/{domain}/adapters` реализует concrete product/runtime dependencies, а не `services/` обычной composition;
- composition `services/` может оркестрировать готовые business API и технические infra-сервисы, но не обращаться к product source напрямую;
- `infra/services` реализует технический сервис или transport;
- `ui` и `shared` не выполняют product I/O.
Business service не импортирует SDK, generated API, HTTP-клиент, storage, env, browser API, React/SWR/query runtime или concrete store напрямую.
## Сегмент mappers/
Функции трансформации данных на границе ответственности module.
```text
mappers/
├── map-user.ts
├── map-product.ts
└── map-order-to-dto.ts
```
В business-модулях mappers защищают public contract от ненадёжной runtime-границы: преобразуют `unknown` в доменную модель, отклоняют невалидные структуры и не импортируют concrete DTO SDK.
Dependency adapter может преобразовать доменные аргументы в transport payload, но не создаёт доменную модель из ответа, domain error или business fallback. Domain-to-ViewModel mapping принадлежит потребительской composition, если описывает только представление.
## Сегмент types/
TypeScript-типы и интерфейсы модуля. Доменные типы, DTO, пропсы компонентов.
```text
types/
├── user.type.ts
└── session.type.ts
```
В business-модулях `types/` содержит собственные доменные типы, `{Domain}Api`, `{Domain}Deps`, `{Domain}Factory`, dependency hook/state result types и доменные error codes. Generated DTO, SDK-типы, `StoreApi`, query-library types и типы HTTP-клиента не входят в контракт business-модуля.
## Сегмент styles/
Стили модуля. Формат зависит от выбранного подхода (CSS Modules, SCSS, CSS-in-JS и т.д.).
```text
styles/
├── auth.module.css
└── login-form.module.css
```
## Сегмент lib/
Утилиты и хелперы, специфичные для модуля. Чистые функции без побочных эффектов.
```text
lib/
├── validate-email.ts
└── format-phone.ts
```
Отличие от `shared/lib/`: здесь лежат утилиты, нужные только этому модулю. Общие утилиты — в `shared/lib/`.
## Сегмент config/
Константы и конфигурация модуля: маршруты, лимиты, дефолтные значения.
```text
config/
├── routes.ts
└── constants.ts
```

214
docs/canons/validation.md Normal file
View File

@@ -0,0 +1,214 @@
---
title: Архитектурная проверка
description: Блокирующие проверки SLM перед завершением создания, рефакторинга и архитектурного ревью
---
# Архитектурная проверка
Не считай задачу завершённой только потому, что код компилируется или UI отображается. Проверь архитектурное решение, runtime-цепочку и обязательные тестовые границы.
## Проверка владельца
- У каждой ответственности один явный владелец.
- Product state и сценарии принадлежат business-домену.
- Page/route UI-state принадлежит соответствующему composition module.
- Concrete technical service принадлежит infra-модулю.
- Concrete business dependency adapter принадлежит `compositions/business/{domain}`.
- Provider находится у владельца scope, а не в generic infra-модуле.
- Код не поднят в общий слой или package без реального consumer.
## Проверка runtime-цепочки
Для каждого `{Domain}Api` проследи цепочку сборки:
```text
graph owner
→ per-domain builder
→ dependency adapters + business factory
→ {Domain}Api
→ provider/entry
```
И отдельно цепочку вызова:
```text
consumer
→ {Domain}Api
→ business scenario
→ {Domain}Deps
→ dependency adapter
→ source/runtime
```
Если отсутствует хотя бы одно необходимое звено, домен не подключён. Тип будущего API, пустой provider или неиспользуемый builder не заменяет runtime-сборку.
Для каждого route/page entry проверь, что он достигает готового composition module. `app` не должен реализовывать screen/layout/product wiring самостоятельно.
## Проверка business imports
В production-коде `business/**` не должно быть прямых runtime или type-only imports из concrete runtimes:
- `infra`, `compositions`, `app`;
- SDK/generated clients;
- SWR/TanStack Query/Apollo;
- Zustand/Redux/MobX/RxJS store runtime;
- React state/effect runtime;
- storage/browser API;
- env/event bus/clock/random implementations.
Разрешены собственные файлы, type-only business contracts, `shared` без runtime-capabilities и чистые детерминированные библиотеки.
Проверь не только import paths, но и re-export/barrel/helper, который может скрывать запрещённую зависимость.
## Проверка deps
- Каждая runtime-capability присутствует в `{Domain}Deps`.
- Контракт принадлежит business и назван доменным языком.
- В `Deps` нет client/SDK/generated operation/StoreApi/query-library types.
- Dependency hook возвращает business-owned source result.
- State dependency использует business-owned state port.
- External result имеет `unknown`, если требует runtime validation.
- Subscription возвращает cleanup.
- Cross-domain API сужен до реально используемых методов.
## Проверка adapters
- Для каждой runtime-capability есть явный adapter.
- Adapter находится в `compositions/business/{domain}`.
- Builder не содержит inline integration logic.
- Adapter не формирует domain error.
- Adapter не выполняет domain normalization.
- Adapter не выбирает business fallback.
- Private adapters отсутствуют в public `index.ts`.
- Concrete transport imports не протекают в обычные consumer compositions.
## Проверка product data
- Page/layout/screen/widget получает product data через `{Domain}Api`.
- Нет прямого вызова SDK/client/generated operation из потребительской composition.
- Нет product storage access в UI/component/composition service.
- DTO не используется как domain/view contract без business normalization.
- Один источник не имеет параллельного прямого и business-пути.
## Проверка ошибок
Для каждого public runtime operation проверь:
- rejected dependency;
- synchronous throw dependency;
- `undefined`/`null`/empty body;
- объект неправильной формы;
- source hook error;
- invalid state/storage value;
- неизвестную runtime-ошибку.
Во всех случаях наружу выходит только domain error со стабильным `code`. Source error сохраняется в `cause`, но не становится consumer contract. Technical failure и malformed response нельзя превращать в fallback; fallback разрешён только для валидного доменного исхода.
Не оставляй формулировку «domain error, если контракт это обещает». Business public contract всегда обещает только domain errors.
## Проверка state и hooks
- Business владеет domain state model, но не concrete state manager.
- Zustand/Redux/MobX store создаётся adapter-ом, не factory.
- SWR/Query hook создаётся adapter-ом, не business-модулем.
- Dependency hook является non-throwing/non-Suspense и передаёт technical error через business-owned result.
- Business wrapper вызывает dependency hook и возвращает собственный result type.
- Business wrapper преобразует error result и ошибки callbacks в domain errors.
- Store/query library types отсутствуют в public API и `Deps`.
- Определены creator, scope, количество instances и cleanup.
- Module singleton используется только при явно доказанном application/process lifetime.
- Provider не создаёт ложное впечатление владения instance, созданным на module scope.
## Проверка graph
- Per-domain builders собирают только свои фабрики.
- Graph owner назван и соответствует lifecycle.
- Домены создаются в топологическом порядке.
- Runtime-циклы отсутствуют.
- Один и тот же graph не копируется по нескольким providers без обоснования scope.
- Screen/widget не собирает graph самостоятельно.
- Provider value имеет точный тип.
- Нет `Partial<Graph>` с unchecked cast к полному graph.
- Subscription, timer, socket и другие resources имеют cleanup/dispose.
- Pending operation не может записать stale state после invalidation/unmount без явно принятой политики.
## Проверка public API
- Межмодульные импорты идут через реальный public entrypoint.
- Import alias/package export существует физически.
- Group не имеет `index.ts`.
- Business `index.ts` экспортирует runtime только factory, остальное через `export type`.
- Integration module экспортирует builder и необходимые type-only integration input contracts.
- Raw context, raw store, mutable singleton, adapter, generated operation и persistence key закрыты.
- Каждый export имеет реального внешнего consumer.
- Deep imports отсутствуют, включая tests уровня public contract.
## Проверка тестов
Business-модуль не завершён без factory-level tests.
Обязательный минимум:
1. Форма public API фабрики.
2. Happy path каждого runtime method/hook.
3. Invalid dependency response.
4. Rejected dependency.
5. Synchronous throw каждого обычного method/callback/state/lifecycle dependency. Dependency hooks проверяются отдельным non-throwing contract.
6. Domain error `code` и `cause`.
7. Порядок side effects и остановка после ошибки.
8. State transitions и concurrent calls.
`compositions/business/{domain}` не завершён без assembly tests:
1. Factory получает правильные adapters.
2. Каждый adapter вызывает нужный runtime source с правильным payload.
3. Builder не делает I/O при создании API.
4. Cross-domain API передан в правильном виде.
5. Private adapters не раскрыты public API.
6. Adapter пробрасывает source error без создания domain error.
7. Dependency hook не бросает и не использует Suspense/throw-on-error mode.
8. Lifecycle cleanup проверен, если есть subscriptions/resources.
Colocated tests обязательны для mappers, normalizers, type guards, domain errors и другой внутренней runtime-safe логики. Они дополняют, но не заменяют factory-level tests. Подробная матрица находится в [Тестировании business-модулей](../examples/business-testing.md).
Проверь наличие исполняемого test script и test runner именно в изменяемом workspace. Root task без локального script не является выполненной тестовой инфраструктурой.
## Проверка целостности репозитория
- Все imports разрешаются.
- Все упомянутые modules и public entrypoints существуют.
- Direct runtime packages объявлены в package текущего workspace.
- Старый provider/store/source path удалён после миграции, если больше не используется.
- Нет speculative scaffold с пустым graph, несуществующими доменами или placeholder contracts.
- Template исправлен, если именно он системно создаёт нарушение.
- Выполнены доступные typecheck, tests, lint/build и `git diff --check`.
## Формат архитектурного ревью
Для каждого нарушения укажи:
1. Путь и строку.
2. Нарушенный invariant.
3. Runtime или maintenance риск.
4. Минимальную корректную границу.
5. Необходимые tests.
Отделяй обязательное нарушение от необязательного улучшения. Не предлагай большую миграцию, если нарушение можно устранить локально без создания второй архитектуры.
## Финальный gate
Перед завершением ответь «да» на все вопросы:
- Архитектурная роль изменения определена?
- Владелец ответственности и state определён?
- Все runtime-capabilities проходят через правильную границу?
- Product data проходит через business API?
- Business вызывает только переданные deps и собственную детерминированную логику?
- Наружу выходят только domain errors?
- Adapters существуют и закрыты?
- Graph и lifecycle определены?
- Public API минимален и разрешим?
- Обязательные tests созданы и запущены?
- Изменение не оставило старый параллельный путь?
Если хотя бы один ответ «нет», задача не завершена.

View File

@@ -0,0 +1,390 @@
---
title: Business composition
description: Пример runtime-сборки business-фабрик в compositions/business
---
# Business composition
`compositions/business/{domain}` — composition module, который собирает конкретную business-фабрику с реальными runtime-зависимостями приложения.
Этот модуль не является бизнес-доменом. Он находится на слое `compositions`, потому что связывает `business`, `infra`, SDK, storage, browser API и другие внешние runtime-источники.
Это единственная integration-зона concrete product dependencies. Page, layout, screen и widget не импортируют SDK/client/storage напрямую и получают product data только через готовый `{Domain}Api`.
## Структура
```text
src/compositions/business/
├── auth/
│ ├── create-auth-business.ts
│ ├── create-auth-business.test.ts
│ ├── adapters/
│ │ ├── phone-auth.adapter.ts
│ │ ├── session.adapter.ts
│ │ ├── auth-session-events.adapter.ts
│ │ └── zustand-auth-state.adapter.ts
│ └── index.ts
├── user/
│ ├── create-user-business.ts
│ ├── create-user-business.test.ts
│ ├── adapters/
│ │ ├── user-profile.adapter.ts
│ │ └── user-storage.adapter.ts
│ ├── types/
│ │ └── create-user-business-deps.type.ts
│ └── index.ts
└── content/
├── create-content-business.ts
├── create-content-business.test.ts
├── adapters/
│ └── content-api.adapter.ts
└── index.ts
```
Если business-домены сгруппированы, `compositions/business` повторяет тот же относительный путь. Например: `business/app/auth` соответствует `compositions/business/app/auth`, `business/cms/content` соответствует `compositions/business/cms/content`.
Сегменты добавляются только по необходимости, но каждая concrete business dependency всегда оформляется отдельным файлом в `adapters/`. Не оставляй короткий adapter inline внутри builder.
## Ответственность
`compositions/business/{domain}` отвечает за adapter composition:
- создаёт или получает внешние клиенты из `infra`;
- отдельными adapters адаптирует SDK, API, storage, source/query hooks, state managers, events и browser API к `deps` business-модуля;
- вызывает business-фабрику;
- принимает API других business-модулей, если текущий домен зависит от них;
- экспортирует готовый `{Domain}Api` через builder-функцию;
- тестирует сборку и корректность адаптеров.
`compositions/business/{domain}` не должен содержать доменную логику. Если код описывает бизнес-правило, маппинг доменной модели, доменную ошибку или сценарий, он должен жить в соответствующем `business`-модуле.
`compositions/business/{domain}` не должен содержать React-компоненты, layouts, guards, providers и page-level wrappers. Применение logic API в React tree выполняется в обычных composition modules страниц, layouts, screens или widgets.
Builder не реализует dependencies inline. Он явно создаёт scoped runtime instances без I/O, создаёт adapters поверх них и передаёт adapters фабрике.
## Business-контракт
Business-модуль объявляет dependency contract.
```ts
// business/auth/types/auth-deps.type.ts
import type { AuthState } from './auth-state.type'
import type { VerifyPhoneCodeData } from './verify-phone-code-data.type'
export type AuthDeps = {
phoneAuth: {
requestCode: (phone: string) => Promise<unknown>
resendCode: (challengeId: string) => Promise<unknown>
verifyCode: (data: VerifyPhoneCodeData) => Promise<unknown>
}
session: {
setToken: (token?: string | null) => void
useToken: () => string | null | undefined
}
sessionEvents: {
onInvalidated: (listener: () => void) => () => void
}
state: {
create: (initialState: AuthState) => {
get: () => AuthState
set: (state: AuthState) => void
useState: () => AuthState
}
}
}
```
Business-модуль не знает, через какой SDK, backend или storage реализованы эти возможности.
## Adapter composition
Composition-адаптер знает про конкретный runtime и приводит его к business-контракту.
```ts
// compositions/business/auth/adapters/phone-auth.adapter.ts
import type { AuthDeps } from '@/business/auth'
import type { AuthApiClient } from '@/infra/backend-api'
export const createPhoneAuthAdapter = (authApiClient: AuthApiClient): AuthDeps['phoneAuth'] => ({
requestCode: (phone) => {
return authApiClient.authOtp.phoneStart({ body: { phone } })
},
resendCode: (challengeId) => {
return authApiClient.authOtp.phoneResend({ body: { challengeId } })
},
verifyCode: (data) => {
return authApiClient.authOtp.phoneVerify({ body: data })
},
})
```
Адаптер не формирует доменные ошибки и не выбирает доменный `code`. Он может вернуть результат внешнего вызова или пробросить ошибку dependency. Решение о доменном коде принимает business-модуль.
Плохо:
```ts
export const createVerifyPhoneCode = (
authApiClient: AuthApiClient,
): AuthDeps['phoneAuth']['verifyCode'] => async (data) => {
try {
return await authApiClient.authOtp.phoneVerify({ body: data })
} catch (error) {
throw new AuthBusinessError('AUTH_PHONE_CODE_VERIFY_FAILED', error)
}
}
```
Проблема: composition-адаптер начал владеть доменной ошибкой.
Хорошо:
```ts
export const createVerifyPhoneCode = (
authApiClient: AuthApiClient,
): AuthDeps['phoneAuth']['verifyCode'] => (data) => {
return authApiClient.authOtp.phoneVerify({ body: data })
}
```
## State adapter
Доменное состояние принадлежит business-контракту, но concrete state manager остаётся снаружи business.
```ts
// compositions/business/auth/adapters/zustand-auth-state.adapter.ts
import { useStore } from 'zustand'
import { createStore } from 'zustand/vanilla'
import type { AuthDeps, AuthState } from '@/business/auth'
export const authStateAdapter: AuthDeps['state'] = {
create: (initialState) => {
const store = createStore<AuthState>()(() => initialState)
return {
get: store.getState,
set: (state) => store.setState(state),
useState: () => useStore(store),
}
},
}
```
`authFactory` выбирает initial domain state и вызывает `deps.state.create(initialState)`. Он не импортирует Zustand и не раскрывает `StoreApi` через public contract. Adapter только создаёт concrete store с переданным состоянием и не выбирает доменную политику.
Для SWR, TanStack Query и других source hooks действует то же правило: adapter реализует business-owned hook contract, business wrapper нормализует `data`, заменяет source `error` собственной domain error и возвращает собственный result type.
## Lifecycle adapter
External event также передаётся через business-owned contract.
```ts
// compositions/business/auth/adapters/auth-session-events.adapter.ts
import type { AuthDeps } from '@/business/auth'
import { onAuthSessionInvalidated } from '@/infra/backend-api'
export const authSessionEventsAdapter: AuthDeps['sessionEvents'] = {
onInvalidated: onAuthSessionInvalidated,
}
```
Business API предоставляет domain-level operation `startSessionInvalidationTracking()`. Внутри business она вызывает `deps.sessionEvents.onInvalidated`, выполняет доменный state transition и возвращает cleanup wrapper. Ошибки регистрации, callback и cleanup заменяются `AuthBusinessError`.
Graph owner запускает operation после commit и вызывает возвращённый cleanup при unmount, как показано в полном provider ниже. Provider не импортирует raw infra event и не связывает его с business command самостоятельно.
## Builder одного домена
Builder собирает одну business-фабрику.
```ts
// compositions/business/auth/create-auth-business.ts
import { authFactory } from '@/business/auth'
import { createBackendApiClient } from '@/infra/backend-api'
import { authSessionEventsAdapter } from './adapters/auth-session-events.adapter'
import { authStateAdapter } from './adapters/zustand-auth-state.adapter'
import { createPhoneAuthAdapter } from './adapters/phone-auth.adapter'
import { createSessionAdapter } from './adapters/session.adapter'
export const createAuthBusiness = () => {
const authApiClient = createBackendApiClient()
return authFactory({
phoneAuth: createPhoneAuthAdapter(authApiClient),
session: createSessionAdapter(),
sessionEvents: authSessionEventsAdapter,
state: authStateAdapter,
})
}
```
Browser/application builder без cross-domain зависимостей вызывается без аргументов. Он явно создаёт runtime instances и передаёт их private adapter factories. Client/adapter constructors не выполняют I/O, не читают storage/env неявно и не запускают subscriptions; lifecycle каждого instance соответствует lifecycle builder result.
Request-scoped builder принимает отдельный `requestScopeInput` только с request data, а concrete client factory импортирует сам. Не используй application singleton для request credentials, cookies или tenant context.
## Cross-domain зависимости
Если один business-модуль зависит от API другого business-модуля, builder принимает уже собранный API.
```ts
// compositions/business/user/types/create-user-business-deps.type.ts
import type { AuthApi } from '@/business/auth'
export type CreateUserBusinessDeps = {
authApi: Pick<AuthApi, 'useAuth'>
}
```
```ts
// compositions/business/user/create-user-business.ts
import { userFactory } from '@/business/user'
import { createBackendApiClient } from '@/infra/backend-api'
import { createUserProfileAdapter } from './adapters/user-profile.adapter'
import { createUserStorageAdapter } from './adapters/user-storage.adapter'
import type { CreateUserBusinessDeps } from './types/create-user-business-deps.type'
export const createUserBusiness = (deps: CreateUserBusinessDeps) => {
const apiClient = createBackendApiClient()
return userFactory({
authApi: deps.authApi,
profile: createUserProfileAdapter(apiClient),
storage: createUserStorageAdapter(),
})
}
```
Правила:
- сначала создаются независимые домены;
- затем создаются домены, которым нужны API уже созданных доменов;
- browser/application builder deps содержат только API других business-фабрик;
- request-scoped builder отделяет cross-domain API от `requestScopeInput` с request data;
- зависимость сужается через `Pick`, если нужен один метод;
- циклические runtime-зависимости между business API запрещены;
- если появляется цикл, нужно пересмотреть границы доменов или вынести общий сценарий в отдельный домен.
## Сборка графа в месте использования
Конечный граф создаётся там, где понятен lifecycle: page provider, route composition, application-lifetime composition provider, request scope или test setup. Слой `app` только подключает готовую composition.
```tsx
// compositions/routes/profile/providers/profile-business.provider.tsx
'use client'
import { createContext, useEffect, useState, type ReactNode } from 'react'
import { createAuthBusiness } from '@/compositions/business/auth'
import { createUserBusiness } from '@/compositions/business/user'
type ProfileBusiness = {
authApi: ReturnType<typeof createAuthBusiness>
userApi: ReturnType<typeof createUserBusiness>
}
export const ProfileBusinessContext = createContext<ProfileBusiness | null>(null)
const createProfileBusiness = (): ProfileBusiness => {
const authApi = createAuthBusiness()
const userApi = createUserBusiness({ authApi })
return { authApi, userApi }
}
export const ProfileBusinessProvider = ({ children }: { children: ReactNode }) => {
const [business] = useState(createProfileBusiness)
useEffect(() => {
return business.authApi.startSessionInvalidationTracking()
}, [business.authApi])
return (
<ProfileBusinessContext.Provider value={business}>
{children}
</ProfileBusinessContext.Provider>
)
}
```
Route-level `ProfileBusinessProvider` владеет lifecycle graph. `compositions/business/*` только предоставляет чистые функции сборки. React Strict Mode может повторно вызвать lazy initializer в development, поэтому factory, builder и adapter constructors не выполняют I/O и не запускают subscriptions.
Graph owner импортирует builders, но не raw SDK/client/event bus для «досборки» конкретного домена. Если external event влияет на domain state, event subscription является частью `{Domain}Deps`; business API предоставляет domain-level lifecycle operation, которую provider запускает в effect и cleanup которой вызывает при unmount. Registration и cleanup errors преобразуются business-модулем в domain errors.
## Public API
`index.ts` composition-модуля экспортирует builder и type-only deps, если builder зависит от других business API.
```ts
// compositions/business/auth/index.ts
export { createAuthBusiness } from './create-auth-business'
```
```ts
// compositions/business/user/index.ts
export { createUserBusiness } from './create-user-business'
export type { CreateUserBusinessDeps } from './types/create-user-business-deps.type'
```
Не экспортируй из public API:
- внутренние SDK-клиенты;
- generated operation trees;
- private adapters;
- test mocks;
- helpers, которые нужны только для сборки.
Если адаптер нужен нескольким composition-модулям, сначала проверь, не является ли это infra-сервисом. Не поднимай адаптер в `shared` только ради удобного импорта.
## Как не превратить сборку в кашу
Признаки плохой сборки:
- один файл создаёт все API-клиенты, все dependency-адаптеры и все фабрики;
- рядом лежат unrelated helpers для разных доменов;
- dependency-адаптеры смешаны с domain mappers;
- Zustand/SWR/SDK logic написана прямо внутри builder;
- graph owner напрямую связывает raw infra event с business command;
- business-правила реализованы в `compositions/business`;
- public API экспортирует внутренние адаптеры;
- невозможно протестировать сборку одного домена отдельно.
Что делать вместо этого:
- один домен runtime-сборки — один composition module;
- dependency-адаптеры держать рядом с конкретной сборкой домена;
- большие dependency-адаптеры выносить в `adapters/`;
- типы сборщика выносить в `types/`, если они перестали быть локальными;
- доменные mappers оставлять в `business/{domain}/mappers`;
- тестировать сборку домена отдельно от полной сборки приложения.
## Тестирование сборки
Тесты `compositions/business/{domain}` не заменяют factory-level тесты business-модуля.
Они проверяют только composition-риск:
- правильные dependency-адаптеры переданы в фабрику;
- API другой фабрики передан в нужном виде;
- SDK operation вызывается с ожидаемым payload;
- storage/browser adapter соответствует dependency-контракту;
- state/query adapter соответствует business-owned contract и не раскрывает library types;
- сборка не делает запросы во время создания business API;
- client/adapter constructors не выполняют import-time I/O, storage access или subscriptions;
- lifecycle operation запускается владельцем scope и вызывает cleanup;
- минимальный API-клиент не тянет лишние generated-операции.
Factory-level поведение самого домена тестируется в `business/{domain}/tests/{domain}-factory`.
## Чеклист
- Runtime-сборка находится в `compositions/business/{domain}`.
- Business-модуль не импортирует реальные SDK, API или storage.
- Dependency-адаптер реализован на composition-слое.
- State/query runtime реализован adapter-ом, а не импортирован business-модулем.
- Файлы внутри модуля сборки разнесены по ответственности.
- Runtime-зависимости между доменами передаются через builder deps.
- Builder deps содержат только API других собранных business-фабрик.
- Request-scoped builder отделяет cross-domain API от `requestScopeInput`.
- Public API composition-модуля не раскрывает internal adapters.
- Builder не содержит inline integration logic.
- Lifecycle operation запускается после commit и имеет cleanup.
- Сборка покрыта тестами на корректность связки deps и адаптеров.
- Business-поведение покрыто factory-level тестами в business-модуле.

View File

@@ -0,0 +1,364 @@
---
title: Тестирование business-модулей
description: Factory-level и colocated unit-тесты для business-фабрик SLM
---
# Тестирование business-модулей
Business-модуль тестируется как доменный контракт приложения. Главный контракт business-модуля — фабрика и API, который она возвращает.
Factory-level тесты обязательны для каждого business-модуля. Assembly tests обязательны для `compositions/business/{domain}`. Внутренние colocated unit-тесты добавляются для runtime-safe логики и не заменяют проверку public API фабрики.
## Уровни тестов
Полное изменение домена проверяется на трёх границах:
1. Factory-level тесты.
2. Assembly tests dependency adapters и builder.
3. Colocated unit-тесты внутренней runtime-safe логики.
Factory-level тесты отвечают на вопрос: работает ли домен снаружи через публичный API фабрики.
Assembly tests отвечают на вопрос: правильно ли concrete runtime реализует `Deps` и передан фабрике.
Colocated unit-тесты отвечают на вопрос: надёжна ли внутренняя runtime-safe механика, на которой держится публичный контракт.
## Factory-level тесты
Размещение:
```text
business/{domain}/tests/{domain}-factory/
```
Пример:
```text
business/user/tests/user-factory/
├── public-api.test.tsx
├── use-current-user.test.tsx
├── update-current-user-profile.test.ts
├── get-stored-user-agreements.test.ts
└── testing/
└── create-user-deps.mock.ts
```
Factory-level тесты импортируют модуль только через public API.
```ts
import { userFactory } from '@/business/user'
```
Factory-level тесты не импортируют:
- `services/*`;
- `hooks/*`;
- `mappers/*`;
- `lib/*`;
- `errors/*`;
- любые deep imports business-модуля.
## Что покрывать на factory-level
Каждый runtime-метод, который возвращает фабрика, должен иметь factory-level тесты.
Обязательно проверяются:
- полный публичный runtime API фабрики;
- happy path каждого метода;
- edge cases публичного контракта;
- корректные ответы DI-зависимостей;
- пустые ответы DI-зависимостей;
- невалидные ответы DI-зависимостей;
- rejected promise от dependency;
- синхронное исключение dependency;
- преобразование внешних ошибок в доменные ошибки;
- преобразование ошибок source hooks, stores и subscriptions в доменные ошибки;
- стабильный доменный `code` для каждой ошибки public contract;
- сохранение исходной ошибки в `cause`;
- отсутствие зависимости public contract от `status`, `message`, `response` и других внешних полей ошибки;
- порядок side effects;
- отсутствие следующих side effects после ошибки;
- hooks, если фабрика возвращает hooks.
Пример:
```ts
const deps = createUserDepsMock({
profile: {
useCurrent: createCurrentUserSourceHookMock({ data: sourceUser }),
},
})
const userApi = userFactory(deps)
await userApi.updateCurrentUserProfile(data)
const result = renderHook(() => userApi.useCurrentUser())
```
Тест проверяет поведение `userApi`, а не внутреннее устройство `createUpdateCurrentUserProfile`.
## Public API тест
У каждого business-модуля должен быть тест, который фиксирует публичный runtime API фабрики.
```ts
it('returns stable user business API', () => {
const userApi = userFactory(createUserDepsMock())
expect(Object.keys(userApi).sort()).toEqual([
'getStoredUserAgreements',
'updateCurrentUserProfile',
'useCurrentUser',
])
})
```
Такой тест не заменяет сценарные тесты методов. Он только фиксирует форму API и защищает от случайного удаления или переименования методов.
## DI-границы
Любая dependency фабрики считается ненадёжной runtime-границей.
Для каждой dependency нужно проверить минимум:
- корректный успешный ответ;
- `undefined`;
- `null`;
- пустой объект;
- объект неправильной формы;
- rejected promise;
- синхронный throw обычного method/callback/state/lifecycle dependency;
- повторные вызовы;
- смену результата dependency hook или domain state.
Если dependency является callback'ом, проверяется порядок вызовов и payload.
Если dependency работает с storage, проверяются битые, устаревшие и отсутствующие данные.
## Hooks через фабрику
Hooks, которые возвращает фабрика, тестируются через factory API.
Проверяй:
- hook не делает запрос без готовых входных данных;
- dependency hook получает ожидаемые доменные аргументы;
- hook корректно обрабатывает смену dependency result;
- `data` имеет доменную модель;
- `error` имеет доменный контракт;
- loading/refresh state соответствует собственному API модуля;
- невалидный dependency response не попадает наружу как валидная доменная модель.
```ts
const useCurrent = createCurrentUserSourceHookMock({ data: sourceUser })
const deps = createUserDepsMock({ profile: { useCurrent } })
const userApi = userFactory(deps)
const { result } = renderHook(() => userApi.useCurrentUser())
```
Не тестируй hook business-модуля как отдельную публичную сущность, если он не является public API фабрики.
SWR/Query cache keys, provider wrapper и library-specific revalidation тестируются в assembly tests dependency adapter, а не в business factory tests.
## Command-сценарии
Для command-методов вроде `save`, `update`, `change`, `request`, `verify` проверяются:
- payload передаётся во внешнюю dependency в ожидаемой форме;
- входной payload не мутируется;
- пустой успешный ответ считается успехом, если body не нужен;
- ошибка dependency превращается в доменную ошибку;
- потребитель может принять решение по доменному `code`;
- side effects выполняются в правильном порядке;
- при ошибке одного шага следующие side effects не выполняются;
- повторный вызов не использует устаревшее состояние, если это важно для сценария.
Если сценарий использует несколько зависимостей, тест должен явно фиксировать порядок.
## Colocated unit-тесты
Colocated unit-тесты размещаются рядом с файлом, который владеет runtime-логикой.
```text
business/{domain}/
├── errors/
│ ├── {domain}-business.error.ts
│ └── {domain}-business.error.test.ts
├── lib/
│ ├── normalize-{entity}.ts
│ └── normalize-{entity}.test.ts
├── mappers/
│ ├── map-{entity}.ts
│ └── map-{entity}.test.ts
├── services/
│ ├── update-{entity}.service.ts
│ └── update-{entity}.service.test.ts
└── hooks/
├── use-{scenario}.hook.ts
└── use-{scenario}.hook.test.tsx
```
Colocated unit-тесты нужны для:
- mappers;
- normalizers;
- type guards;
- runtime-safe helpers;
- domain errors;
- сложных services;
- hook wrappers со сложной нормализацией domain result/error;
- storage parsers;
- fallback-логики.
Colocated unit-тесты не нужны для:
- type-only файлов;
- `index.ts` без runtime-логики;
- простых re-export файлов;
- статических config-файлов без branching;
- типов, которые проверяются typecheck'ом.
## Почему colocated тесты не заменяют factory-level
Colocated тест может доказать, что mapper работает правильно, но он не доказывает, что фабрика использует этот mapper в публичном сценарии.
Colocated тест может доказать, что service обрабатывает ошибку, но он не доказывает, что service реально попал в public API фабрики.
Factory-level тест проверяет интеграцию внутренних частей business-модуля как чёрный ящик.
Правило:
- сначала покрывай public API фабрики;
- затем усиливай покрытие colocated тестами там, где есть runtime-safe логика.
## Маппинг и runtime safety
Если business-модуль получает данные с любой dependency boundary, тестируй не только happy path. Boundary включает методы, source hooks, stores, events и browser capabilities.
Проверяй:
- отсутствующие обязательные поля;
- nullable-поля;
- поля неправильного runtime-типа;
- пустые строки;
- пробельные строки;
- странные идентификаторы;
- пустые массивы;
- не-массив вместо массива;
- частично валидные объекты;
- дефолтные значения;
- domain error для malformed response, если модель не может быть безопасно построена.
Fallback допустим только для валидного доменного исхода, например корректно представленного отсутствия данных. Rejection, synchronous throw, source error и malformed response всегда дают domain error.
## Доменные ошибки
Business-модуль никогда не отдаёт наружу сырые ошибки SDK, HTTP-клиента, source hook, store, storage или browser API. Public contract всегда содержит только собственные domain errors.
Factory-level тесты должны доказывать, что потребитель может работать только с доменным `code` и не знает форму внешней ошибки.
Проверяй:
- `error.name`;
- стабильный `error.code`;
- сохранение `cause`;
- отсутствие утечки DTO/HTTP-specific деталей в public contract;
- rejected promise от разных dependencies маппится в ожидаемый доменный код;
- синхронный throw dependency маппится в ожидаемый доменный код;
- невалидный успешный ответ превращается в доменный код ошибки;
- разные технические ошибки дают один код, если для потребителя это один бизнес-сценарий;
- разные пользовательские сценарии дают разные коды, если UI должен реагировать по-разному;
- safe fallback только для валидного доменного исхода, явно представленного dependency contract.
UI и i18n должны ориентироваться на `code`, а не на `message` внешней ошибки.
Пример factory-level проверки:
```ts
it('throws domain error code when phone code verification fails', async () => {
const externalError = new Error('Request failed with status code 500')
const authApi = authFactory({
phoneAuth: {
requestCode: vi.fn(),
resendCode: vi.fn(),
verifyCode: vi.fn().mockRejectedValue(externalError),
},
session,
sessionEvents: createAuthSessionEventsMock(),
state: createAuthStateAdapterMock(),
})
await expect(authApi.verifyPhoneCode(data)).rejects.toMatchObject({
name: 'AuthBusinessError',
code: 'AUTH_PHONE_CODE_VERIFY_FAILED',
cause: externalError,
})
})
```
Не проверяй в потребительских сценариях `externalError.message`, HTTP status или тип ошибки SDK как ожидаемое поведение business API.
## Тестирование compositions/business
Тесты `compositions/business/{domain}` проверяют сборку, а не бизнес-поведение.
Проверяй:
- builder вызывает нужную business-фабрику;
- adapter вызывает SDK operation с ожидаемым payload;
- storage/browser adapter соответствует dependency contract;
- API другого домена передаётся в нужном виде;
- builder deps содержат только API других собранных business-фабрик;
- builder/client/adapter constructors не выполняют I/O, storage/env reads или subscriptions во время создания API;
- state/query runtime находится в adapter и не импортируется business-модулем;
- dependency hook работает без Suspense/throw-on-error и возвращает technical error через result;
- adapter пробрасывает source error без создания domain error;
- lifecycle subscription возвращает и вызывает cleanup;
- public API composition-модуля не экспортирует внутренние adapters.
Не проверяй здесь domain errors, fallback'и и маппинг доменной модели. Это ответственность factory-level и colocated тестов в `business/{domain}`.
## Что не тестировать unit-тестами business-модуля
Unit-тесты business-модуля не проверяют:
- реальные REST-запросы;
- generated-клиенты;
- настоящий backend;
- Next.js routing;
- визуальную вёрстку;
- интеграцию с production storage;
- реальные внешние сервисы;
- e2e-поток целого приложения.
Эти проверки относятся к `infra`, `compositions`, integration или e2e уровням.
## Архитектурные импорты
Проверяй production import graph business-модуля. В `business/**` не должно быть runtime или type-only imports из concrete runtimes:
- SDK/client/infra;
- SWR/TanStack Query/Apollo;
- Zustand/Redux/MobX;
- React state/effect APIs;
- storage/browser/event implementations.
Factory-level test обязан импортировать фабрику через public API business-модуля. Deep import `../../{domain}.factory` не доказывает корректность public boundary.
## Чеклист
- Каждый runtime-метод фабрики имеет factory-level тесты.
- Factory-level тесты импортируют модуль только через public API.
- Public API фабрики зафиксирован отдельным тестом.
- DI-зависимости проверены на корректные, пустые, невалидные и ошибочные ответы.
- Hooks тестируются через API, который вернула фабрика.
- Command-сценарии проверяют порядок side effects.
- После ошибки не выполняются лишние side effects.
- Runtime-safe mappers, normalizers и guards покрыты colocated unit-тестами.
- Type-only файлы не покрываются бессмысленными unit-тестами.
- Доменные ошибки имеют стабильный `code`, сохраняют `cause` и не раскрывают внешнюю ошибку как public contract.
- Тесты `compositions/business/{domain}` проверяют сборку, а не бизнес-логику.
- Business production imports не содержат concrete state/query/source runtime.
- Тесты не требуют backend, network, env и долгоживущих процессов.

View File

@@ -0,0 +1,346 @@
---
title: Композиция через Provider
description: Пример page-level Provider для composition modules в React-проекте
---
# Композиция через Provider
Раздел показывает, как page composition может владеть provider, store и business composition, которые нужны layout, screen и другим composition modules.
## Идея
Page composition хранит состояние и композицию бизнес-доменов на уровне страницы. Layout и screen не импортируют друг друга: они получают доступ к page-level данным через публичный API page composition.
В примере `ProfilePageState` — только локальное UI-state страницы. Это не domain state и не product data cache. Доменное состояние описывается business-модулем, а concrete store/query hook передаётся его фабрике через adapter в `compositions/business/{domain}`.
В примере page composition владеет scope-контрактом страницы, но не экспортирует готовый `ProfilePage`, потому что layout и screen импортируют hooks из `pages/profile`. Дерево страницы собирается в отдельном entry-point composition module, который слой `app` только подключает.
## Принципы
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.
4. **Сборка снаружи business.** Business-модули не используют page-level providers. Page composition вызывает builders из `compositions/business/{domain}` и владеет lifecycle готового графа.
5. **Без deep imports.** Layout и screen импортируют hooks только из public API page composition.
## Структура модулей
```text
compositions/pages/profile/
├── profile-business-composition.ts
├── providers/
│ └── profile-page.provider.tsx
├── hooks/
│ ├── use-profile-page-store.hook.ts
│ └── use-profile-business-composition.hook.ts
├── stores/
│ └── profile-page.store.ts
├── types/
│ └── profile-page-state.type.ts
└── index.ts
compositions/layouts/profile-main/
├── profile-main.layout.tsx
└── index.ts
compositions/screens/profile/
├── profile.screen.tsx
├── ui/
│ ├── profile-error/
│ ├── profile-summary/
│ └── profile-summary-skeleton/
└── index.ts
```
## Тип состояния страницы
Файл: `compositions/pages/profile/types/profile-page-state.type.ts`.
```ts
export type ProfilePageState = {
title: string
isSidebarOpen: boolean
setSidebarOpen: (value: boolean) => void
}
```
## Store страницы
Файл: `compositions/pages/profile/stores/profile-page.store.ts`.
```ts
import { createStore } from 'zustand/vanilla'
import type { ProfilePageState } from '../types/profile-page-state.type'
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 { createAuthBusiness } from '@/compositions/business/auth'
import { createProfileBusiness } from '@/compositions/business/profile'
export const createProfileBusinessComposition = () => {
const authApi = createAuthBusiness()
const profileApi = createProfileBusiness({ authApi })
return { authApi, profileApi }
}
```
Page composition собирает нужный для страницы граф из per-domain builders. Реальные runtime-зависимости остаются в `compositions/business/{domain}`, а не внутри `business`.
Page composition не импортирует SDK, product storage, source hook или raw infra event для дополнительной настройки домена. Такое wiring принадлежит соответствующему integration module.
## Provider страницы
Файл: `compositions/pages/profile/providers/profile-page.provider.tsx`.
```tsx
'use client'
import { createContext, useEffect, useState, 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'
type ProfileBusinessComposition = ReturnType<typeof createProfileBusinessComposition>
type ProfilePageProviderValue = {
store: StoreApi<ProfilePageState>
business: ProfileBusinessComposition
}
export const ProfilePageContext = createContext<ProfilePageProviderValue | null>(null)
type Props = {
children: ReactNode
}
const createProfilePageProviderValue = (): ProfilePageProviderValue => ({
store: createProfilePageStore(),
business: createProfileBusinessComposition(),
})
export const ProfilePageProvider = ({ children }: Props) => {
const [value] = useState(createProfilePageProviderValue)
useEffect(() => {
return value.business.authApi.startSessionInvalidationTracking()
}, [value.business.authApi])
return (
<ProfilePageContext.Provider value={value}>
{children}
</ProfilePageContext.Provider>
)
}
```
Context object остаётся технической деталью provider и не должен использоваться внешними модулями напрямую. Наружу экспортируются hooks доступа.
Lazy initializer может быть повторно вызван React Strict Mode в development. Поэтому store/business constructors не выполняют I/O и не запускают subscriptions. Domain-level lifecycle operations запускаются отдельно в effect и возвращают cleanup.
## Hooks доступа
Файл: `compositions/pages/profile/hooks/use-profile-page-store.hook.ts`.
```ts
'use client'
import { useContext } from 'react'
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)
if (!ctx) {
throw new Error('useProfilePageStore must be used within ProfilePageProvider')
}
return useStore(ctx.store, selector)
}
```
Файл: `compositions/pages/profile/hooks/use-profile-business-composition.hook.ts`.
```ts
'use client'
import { useContext } from 'react'
import { ProfilePageContext } from '../providers/profile-page.provider'
export const useProfileBusinessComposition = () => {
const ctx = useContext(ProfilePageContext)
if (!ctx) {
throw new Error('useProfileBusinessComposition must be used within ProfilePageProvider')
}
return ctx.business
}
```
## Layout использует page-level store
Файл: `compositions/layouts/profile-main/profile-main.layout.tsx`.
```tsx
'use client'
import type { ReactNode } from 'react'
import { useProfilePageStore } from '@/compositions/pages/profile'
type Props = {
children: ReactNode
}
export const ProfileMainLayout = ({ children }: Props) => {
const title = useProfilePageStore((state) => state.title)
const isSidebarOpen = useProfilePageStore((state) => state.isSidebarOpen)
return (
<div data-sidebar-open={isSidebarOpen}>
<header>{title}</header>
<main>{children}</main>
</div>
)
}
```
Layout импортирует hook из public API page composition. Он не импортирует screen и не лезет во внутренние файлы `pages/profile`.
## Screen использует business composition
Файл: `compositions/screens/profile/profile.screen.tsx`.
```tsx
'use client'
import { useProfileBusinessComposition } from '@/compositions/pages/profile'
import { ProfileError } from './ui/profile-error'
import { ProfileSummary } from './ui/profile-summary'
import { ProfileSummarySkeleton } from './ui/profile-summary-skeleton'
export const ProfileScreen = () => {
const { profileApi } = useProfileBusinessComposition()
const currentProfile = profileApi.useCurrentProfile()
if (currentProfile.isLoading) {
return <ProfileSummarySkeleton />
}
if (currentProfile.error) {
return <ProfileError code={currentProfile.error.code} />
}
return currentProfile.data ? <ProfileSummary profile={currentProfile.data} /> : null
}
```
Screen получает готовые доменные API из page composition и не собирает граф фабрик самостоятельно. `ProfileSummary` — компонент screen composition, а не часть `business/profile`.
## Публичный 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.
Готовое дерево собирай в отдельном entry-point composition module. Не смешивай в одном public API готовую page composition и hooks, которые импортируют её дочерние layout/screen modules: это может создать runtime-цикл.
## Подключение в app
Entry composition связывает provider, layout и screen:
```tsx
// compositions/entries/profile/profile.entry.tsx
'use client'
import { ProfilePageProvider } from '@/compositions/pages/profile'
import { ProfileMainLayout } from '@/compositions/layouts/profile-main'
import { ProfileScreen } from '@/compositions/screens/profile'
export const ProfileEntry = () => (
<ProfilePageProvider>
<ProfileMainLayout>
<ProfileScreen />
</ProfileMainLayout>
</ProfilePageProvider>
)
```
React Router config только подключает готовый entry:
```tsx
import { ProfileEntry } from '@/compositions/entries/profile'
export const profileRoute = {
path: '/profile',
element: <ProfileEntry />,
}
```
Для Next App Router создай готовые layout/page entries в `compositions`, а framework files только подключают их.
```tsx
// compositions/entries/profile/profile-layout.entry.tsx
'use client'
import { ProfilePageProvider } from '@/compositions/pages/profile'
import { ProfileMainLayout } from '@/compositions/layouts/profile-main'
import type { ReactNode } from 'react'
export const ProfileLayoutEntry = ({ children }: { children: ReactNode }) => {
return (
<ProfilePageProvider>
<ProfileMainLayout>{children}</ProfileMainLayout>
</ProfilePageProvider>
)
}
```
```tsx
// compositions/entries/profile/profile-page.entry.tsx
'use client'
import { ProfileScreen } from '@/compositions/screens/profile'
export const ProfilePageEntry = () => <ProfileScreen />
```
```tsx
// app/(profile)/layout.tsx
import { ProfileLayoutEntry } from '@/compositions/entries/profile'
export default ProfileLayoutEntry
```
```tsx
// app/(profile)/page.tsx
import { ProfilePageEntry } from '@/compositions/entries/profile'
export default ProfilePageEntry
```
`app` размещает готовые entry composition modules по правилам фреймворка, но не реализует product tree внутри себя.

View File

@@ -0,0 +1,83 @@
---
title: Структуры compositions
description: Примеры организации слоя compositions под разные способы сборки React-приложения
---
# Структуры compositions
Раздел показывает, что SLM не фиксирует жёсткую структуру внутри `compositions`. Команда выбирает организацию под фреймворк, роутинг, CMS и продуктовую задачу.
## Базовая рекомендация
Подходит для большинства приложений, где есть явные страницы, layouts, screens и переиспользуемые композиционные блоки.
```text
src/compositions/
├── business/
│ ├── auth/
│ └── user/
├── pages/
│ ├── home/
│ └── profile/
├── layouts/
│ ├── main/
│ └── dashboard/
├── screens/
│ ├── home/
│ └── profile/
└── widgets/
├── page-heading/
└── promo-banner/
```
`business`, `pages`, `layouts`, `screens` и `widgets` здесь не являются отдельными SLM-слоями. Это группы composition modules внутри одного слоя `compositions`.
`compositions/business/{domain}` используется для runtime-сборки business-фабрик. Он не заменяет `business/{domain}` и не содержит доменную логику.
Только эта группа integration modules знает одновременно business dependency contract и concrete product runtime. Остальные composition modules являются graph owners или consumers готовых business API.
## 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`.
- `compositions/business/{domain}` отдельными adapters собирает конкретную business-фабрику с runtime-зависимостями.
- Page/layout/screen/widget получают product data только через `{Domain}Api`.
- Graph owner импортирует builders, но не raw product SDK/client/event для досборки домена.
- `business`, `infra`, `ui`, `shared` не импортируют `compositions`.
- Импорты между composition modules идут только через public API.
- Deep imports внутрь composition modules запрещены.

14
package.json Normal file
View File

@@ -0,0 +1,14 @@
{
"name": "slm-design",
"private": true,
"type": "module",
"scripts": {
"build": "npm run build:skill",
"build:skill": "node scripts/build-skill.mjs",
"check": "npm run build && npm run check:skill",
"check:skill": "node scripts/check-skill.mjs"
},
"engines": {
"node": ">=20"
}
}

70
scripts/build-skill.mjs Normal file
View File

@@ -0,0 +1,70 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import skillConfig from '../src-skills/slm-design/skill.config.mjs';
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const sourceDir = path.join(repoRoot, 'src-skills', skillConfig.name);
const sourcePath = path.join(sourceDir, skillConfig.source);
const docsDir = path.join(repoRoot, 'docs');
const outputDir = path.join(repoRoot, 'skills', skillConfig.name);
const includePattern = /<!--\s*include:\s*(.*?)\s*-->/g;
const isWithin = (filePath, parentPath) => {
const relativePath = path.relative(parentPath, filePath);
return relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath));
};
const removeFrontmatter = (content) => {
return content.replace(/^\uFEFF?---\r?\n[\s\S]*?\r?\n---\r?\n?/, '');
};
const shiftHeadings = (content) => {
return content.replace(/^(#{1,6})(?=\s)/gm, (heading) => '#'.repeat(Math.min(heading.length + 1, 6)));
};
const resolveIncludes = (content, baseDir) => {
return content.replace(includePattern, (match, includePath) => {
const resolvedPath = path.resolve(baseDir, includePath);
if (!isWithin(resolvedPath, repoRoot) || !fs.existsSync(resolvedPath)) {
throw new Error(`Include file not found: ${includePath}`);
}
return shiftHeadings(removeFrontmatter(fs.readFileSync(resolvedPath, 'utf8')).trim());
});
};
const rewriteLinks = (content) => {
return skillConfig.linkRewrites.reduce((result, { from, to }) => {
return result.split(`](${from})`).join(`](${to})`);
}, content);
};
const createFrontmatter = () => {
return `---\nname: ${skillConfig.name}\ndescription: ${JSON.stringify(skillConfig.description)}\n---`;
};
if (!fs.existsSync(sourcePath)) {
throw new Error(`Skill source not found: ${path.relative(repoRoot, sourcePath)}`);
}
if (!fs.existsSync(docsDir)) {
throw new Error('Documentation directory not found: docs');
}
const source = fs.readFileSync(sourcePath, 'utf8');
const content = rewriteLinks(resolveIncludes(source, path.dirname(sourcePath))).trim();
const output = [
createFrontmatter(),
'<!-- Generated from src-skills/slm-design/SKILL.md. Do not edit manually. -->',
content,
].join('\n\n');
fs.rmSync(outputDir, { recursive: true, force: true });
fs.mkdirSync(outputDir, { recursive: true });
fs.writeFileSync(path.join(outputDir, 'SKILL.md'), `${output}\n`);
fs.cpSync(docsDir, path.join(outputDir, 'reference'), { recursive: true });
console.log(path.relative(repoRoot, path.join(outputDir, 'SKILL.md')));

99
scripts/check-skill.mjs Normal file
View File

@@ -0,0 +1,99 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import skillConfig from '../src-skills/slm-design/skill.config.mjs';
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const skillDir = path.join(repoRoot, 'skills', skillConfig.name);
const skillPath = path.join(skillDir, 'SKILL.md');
const referenceFiles = [
'README.md',
'canons/business-factory.md',
'canons/business-runtime-boundary.md',
'canons/decision-process.md',
'canons/file-atlas.md',
'canons/index.md',
'canons/layers.md',
'canons/modules.md',
'canons/monorepo.md',
'canons/segments.md',
'canons/validation.md',
'examples/business-composition.md',
'examples/business-testing.md',
'examples/react/composition-provider.md',
'examples/react/composition-structures.md',
];
const assert = (condition, message) => {
if (!condition) {
throw new Error(message);
}
};
const listMarkdownFiles = (directoryPath) => {
return fs.readdirSync(directoryPath, { withFileTypes: true }).flatMap((entry) => {
const entryPath = path.join(directoryPath, entry.name);
if (entry.isDirectory()) {
return listMarkdownFiles(entryPath);
}
return entry.isFile() && path.extname(entry.name) === '.md' ? [entryPath] : [];
});
};
const assertLocalLinksExist = (filePath) => {
const content = fs.readFileSync(filePath, 'utf8');
const links = [...content.matchAll(/]\(([^)\s]+)(?:\s+"[^"]*")?\)/g)];
for (const [, rawTarget] of links) {
if (rawTarget.startsWith('#') || /^[a-z][a-z+.-]*:/i.test(rawTarget) || rawTarget.startsWith('//')) {
continue;
}
const [targetPath] = rawTarget.split('#');
const resolvedPath = path.resolve(path.dirname(filePath), targetPath);
assert(fs.existsSync(resolvedPath), `Broken link in ${path.relative(repoRoot, filePath)}: ${rawTarget}`);
}
};
assert(fs.existsSync(skillPath), 'Run npm run build before checking the skill.');
const skillContent = fs.readFileSync(skillPath, 'utf8');
assert(skillContent.startsWith(`---\nname: ${skillConfig.name}\n`), 'SKILL.md must contain the skill name in frontmatter.');
assert(skillContent.includes('description: '), 'SKILL.md must contain a description in frontmatter.');
assert(!skillContent.includes('<!-- include:'), 'SKILL.md contains an unresolved include.');
assert(!skillContent.includes('/home/gromov/'), 'SKILL.md must not contain an absolute workspace path.');
assert(!skillContent.includes('reference/slm-design'), 'SKILL.md contains an old documentation path.');
assert(skillContent.includes('## Процесс архитектурного решения'), 'SKILL.md does not include the decision process.');
assert(skillContent.includes('## Runtime-граница business'), 'SKILL.md does not include the business runtime boundary.');
assert(skillContent.includes('## Архитектурная проверка'), 'SKILL.md does not include the validation rules.');
assert(
skillContent.includes('./reference/canons/business-factory.md'),
'The business factory link must point to local reference documentation.',
);
for (const referenceFile of referenceFiles) {
assert(
fs.existsSync(path.join(skillDir, 'reference', referenceFile)),
`Missing copied reference file: ${referenceFile}`,
);
}
for (const filePath of listMarkdownFiles(skillDir)) {
const content = fs.readFileSync(filePath, 'utf8');
assert(
!content.includes('/home/gromov/'),
`${path.relative(repoRoot, filePath)} contains an absolute workspace path.`,
);
assert(
!content.includes('reference/slm-design'),
`${path.relative(repoRoot, filePath)} contains an old documentation path.`,
);
assertLocalLinksExist(filePath);
}
console.log(`Validated ${path.relative(repoRoot, skillPath)}`);

719
skills/slm-design/SKILL.md Normal file
View File

@@ -0,0 +1,719 @@
---
name: slm-design
description: "Используй при определении архитектурной роли изменения и работе по SLM Design: выборе владельца кода, слоя, модуля, scope, public API, направления зависимостей и пути продуктовых данных. Триггеры: SLM, Scoped Layered Module Design, где разместить или перенести код, business factory, DomainApi, DomainDeps, compositions/business, dependency adapter, inline adapter в builder, прямой вызов API из page/screen/hook, Zustand/SWR/SDK внутри business, domain error, deep import, module vs component, ui vs parts, page-level provider/store, business graph, Partial<Business>, event bus, subscription cleanup, lifecycle, factory-level и assembly tests, архитектура template/scaffold, перенос между apps/*/src и packages/*. НЕ используй для форматирования уже размещённого React/TypeScript/CSS-кода, реализации REST/OpenAPI-клиента, Next.js routing/rendering или механики генерации шаблона без архитектурного выбора. В смешанной задаче сначала зафиксируй SLM-границу, затем применяй профильный skill."
---
<!-- Generated from src-skills/slm-design/SKILL.md. Do not edit manually. -->
# SLM Design
## Процесс архитектурного решения
Не изменяй файлы, пока не принято архитектурное решение. Название папки, существующий похожий код и удобный импорт не доказывают правильность размещения.
### Карточка решения
Перед реализацией определи:
| Вопрос | Что зафиксировать |
|---|---|
| Роль изменения | Framework wiring, продуктовый сценарий, интеграция, композиция интерфейса, технический сервис, UI или чистый фундамент |
| Владелец | Домен, route/page scope, composition module, infra-модуль, UI-модуль или локальный consumer |
| Данные | Продуктовые данные, техническое состояние, framework input, локальное UI-state или отсутствуют |
| Runtime-возможности | Источники данных, hooks, stores, SDK, browser API, events, clock, random, env и другие внешние capabilities |
| Место | Приложение или package, слой, модуль, вложенный модуль и сегмент |
| Публичная граница | Что действительно нужно экспортировать и кто будет consumer |
| Путь данных | От consumer до business API, dependency adapter и конкретного источника |
| Lifecycle | Кто создаёт instance, сколько instances допустимо и кто выполняет cleanup |
| Стратегия | Локальная правка, новый модуль, новый business-контракт, adapter, перенос или исправление public API |
| Проверки | Typecheck, тесты, import graph, public API, lifecycle и архитектурные инварианты |
Карточку не обязательно выводить пользователю, если решение очевидно. Но агент обязан уметь обосновать каждый пункт до изменения файлов.
### Сбор контекста
Перед выбором места:
1. Прочитай локальные инструкции приложения или package.
2. Найди фактическую границу SLM: `src/`, `apps/{app}/src` или другой локальный root.
3. Проверь существующие слои, группы и соседние модули. Не создавай новую параллельную структуру без необходимости.
4. Проверь aliases, package exports и реальную разрешимость импортов.
5. Найди текущих consumers, public API и runtime import graph изменяемой ответственности.
6. Проверь существующие templates или generators после архитектурного выбора. Шаблон не принимает решение за SLM.
7. Отдельно найди product I/O, hooks, stores, subscriptions, browser API и другие runtime-возможности.
8. Проверь, нет ли уже business-домена, которому принадлежит сценарий.
Не считай неиспользуемый provider, пустой context, тип будущего graph или ссылку на несуществующий домен готовой архитектурой. Решение должно быть достижимо из runtime entry point и иметь реальных consumers.
### Выбор роли
Классифицируй ответственность в следующем порядке.
#### Framework wiring
Если код существует только из-за фреймворка, размести его в `app`:
- route-файл;
- bootstrap;
- framework error entry;
- подключение глобальных ресурсов;
- тонкое подключение готового composition module.
`app` не реализует продуктовую композицию, business graph, store, provider или экран.
#### Продуктовый сценарий
Если код определяет пользовательский сценарий, доменную модель, продуктовый state, бизнес-правило, нормализацию внешних данных, error mapping или доменный переход после ошибки, владелец находится в `business/{domain}`.
Визуальная реакция на готовый domain error принадлежит consumer composition: сообщение, error screen, redirect, retry control и UI fallback выбираются по стабильному доменному `code`.
Любой новый внешний источник продуктовых данных требует business-контракта. Колокация внешних вызовов в page/screen/widget services не является допустимым упрощением.
#### Интеграция business-домена
Если код реализует `{Domain}Deps` через SDK, HTTP, storage, browser API, state/query runtime, event bus или другой concrete runtime, размести его в `compositions/business/{domain}`.
Это интеграционный composition module, а не business-домен и не обычная page/screen/widget composition.
#### Продуктовая композиция
Если код собирает route/page/layout/screen/widget, управляет UI-state, provider scope или lifecycle готового business graph, размести его в соответствующем composition module.
Потребительский composition module получает продуктовые данные только через `{Domain}Api`. Он не импортирует product SDK, generated operations, product storage adapter или конкретный источник.
#### Технический сервис
Если код предоставляет техническую возможность без продуктовой модели и сценариев, размести его в `infra`:
- HTTP client;
- SDK wrapper;
- logger;
- theme engine;
- i18n engine;
- telemetry transport;
- технический realtime client.
Composition может использовать технический infra-сервис напрямую, если сервис не становится обходным путём к продуктовым данным. Если capability нужна business, она всё равно передаётся через business-owned `deps` и adapter.
#### Универсальный UI
Если сущность отображает интерфейс, не знает продуктовый сценарий и применима независимо от конкретной composition, размести её в `ui`.
#### Чистый фундамент
Если код детерминирован, не имеет runtime-state, не знает продукт и переиспользуется несколькими владельцами, рассмотри `shared`. По умолчанию оставляй код рядом с первым владельцем.
### Выбор scope
Выбирай минимальный scope, который полностью владеет ответственностью:
1. Нужен одному component/module и не имеет самостоятельной ответственности: оставь внутри владельца.
2. Нужен как самостоятельная часть одного module: создай nested module в `parts/`.
3. Нужен нескольким частям одной page/route ветки: подними в общий composition scope этой ветки.
4. Нужен нескольким composition modules и остаётся продуктовой композицией: создай отдельный composition module.
5. Является доменным сценарием или product data boundary: создай или расширь `business/{domain}`.
6. Является техническим сервисом: создай или расширь `infra/{service}`.
7. Является универсальным UI: создай или расширь `ui/{module}`.
8. Выноси в package только `ui`, `infra` или `shared` код с реальным вторым consumer либо явно зафиксированным межприложенческим ownership/reuse-контрактом.
Не поднимай код выше ради короткого импорта. Не создавай `shared`, общий provider, generic business context или package «на будущее».
### Component, module и group
Применяй решение последовательно:
1. Только отображает готовые props и не владеет зависимостями: component в `ui/` родительского module.
2. Владеет сценарием, данными, state, dependency, lifecycle или внутренней декомпозицией: самостоятельный module.
3. Самостоятельный module, локальный для владельца: nested module в `parts/`.
4. Папка только классифицирует конечные modules: group без `index.ts`, state и runtime logic.
5. `ui/`, `parts/`, `hooks/`, `types/`, `services/` и другие служебные папки внутри module: segments, а не modules.
Если component начинает получать данные, выбирать источник, вызывать сценарный hook или управлять процессом, не добавляй логику в component. Измени архитектурную форму сущности.
### Выбор стратегии
#### Новый продуктовый сценарий
1. Найди домен-владелец.
2. Спроектируй `{Domain}Api`, доменные типы и доменные ошибки.
3. Опиши минимальные runtime-capabilities в `{Domain}Deps`.
4. Реализуй детерминированную доменную логику.
5. Создай отдельные adapters в `compositions/business/{domain}`.
6. Собери фабрику чистым builder.
7. Подключи API во владельце lifecycle graph.
8. Используй API из потребительских compositions.
9. Добавь factory-level и assembly tests.
#### Прямой product I/O вне business boundary
Не расширяй существующее нарушение.
1. Определи сценарий и домен.
2. Перенеси контракт данных в business-owned `Deps`.
3. Перенеси нормализацию, fallback и error mapping в business.
4. Оставь concrete source call в dependency adapter.
5. Замени прямой вызов на `{Domain}Api`.
6. Закрой adapter и source details из public API.
#### Новый store или dependency hook
Сначала определи, является state локальным UI-state или доменным state.
- State является локальным UI-state, если сбрасывается вместе с UI scope, управляет только представлением и не хранит продуктовый факт или product data cache. Такой state может принадлежать composition module и использовать выбранный state manager внутри владельца.
- State является доменным, если выражает продуктовый факт, инвариант, доступен через business API или участвует в бизнес-сценарии.
- Доменный state принадлежит business-контракту. Фабрика получает state adapter factory через `deps`, выбирает initial domain state и создаёт concrete port через adapter.
- Source/query hook реализуется adapter-ом; business вызывает только dependency hook и возвращает собственный доменный hook/result.
#### Сборка graph
1. Собери каждый домен отдельным `compositions/business/{domain}` builder.
2. Определи DAG cross-domain зависимостей.
3. Выбери один явный lifecycle scope: application-lifetime composition, route, page, request или test. Слой `app` только подключает application composition.
4. Создавай graph у владельца scope, а не в случайном screen/widget или на module scope без обоснования.
5. Передавай consumers точный graph type. Не используй `Partial<Graph>` с приведением к полному типу.
6. Для subscriptions, timers и resources зафиксируй cleanup/dispose.
#### Архитектурное ревью
Проверяй не только пути файлов, но и семантику:
- business-shaped код вне `business`;
- product graph в `infra`;
- type-only imports, которые фактически переносят ownership;
- provider, который не создаёт и не получает instance от явного владельца;
- orphan modules и providers, недостижимые из entry point;
- public API, раскрывающий raw store, context, adapter или generated types;
- отсутствующие tests обязательного business-контракта.
### Условия остановки
Останови реализацию и сначала исправь решение, если:
- владелец ответственности не определён;
- один state или source имеет несколько конкурирующих владельцев;
- business требует прямого runtime или type-only import concrete runtime;
- graph создаёт runtime-цикл;
- lifecycle instance или cleanup не определён;
- public API нужен только для обхода границы;
- шаблон генерирует архитектуру, противоречащую принятому решению;
- изменение требует незапрошенной миграции нескольких независимых областей.
### Локальные материалы
Основной процесс достаточен для типового решения. Открывай только материал, который нужен текущей ветке задачи.
| Ситуация | Материал |
|---|---|
| Нужна полная карта допустимых файлов, root entries, segments и tests | [Атлас файлов SLM](./reference/canons/file-atlas.md) |
| Задача затрагивает product I/O, source hook, domain store, event, lifecycle или external errors | [Runtime-граница business](#runtime-граница-business) |
| Выполняется архитектурное ревью или финальная проверка реализации | [Архитектурная проверка](#архитектурная-проверка) |
| Неясен layer, направление import или роль `app/compositions/business/infra/ui/shared` | [Слои](./reference/canons/layers.md) |
| Нужно отличить module, component, group, nested module или спроектировать public API | [Модули](./reference/canons/modules.md) |
| Проектируется factory, Api, Deps, domain error или сборка домена | [Business-фабрика](./reference/canons/business-factory.md) |
| Неясно размещение hook/store/service/mapper/provider/type/style | [Сегменты](./reference/canons/segments.md) |
| Решается вынос из `apps/*/src` в `packages/*` | [Монорепозитории](./reference/canons/monorepo.md) |
| Нужен полный пример adapters, builder, state runtime и graph lifecycle | [Business composition](./reference/examples/business-composition.md) |
| Нужна матрица factory-level, assembly и colocated tests | [Тестирование business-модулей](./reference/examples/business-testing.md) |
| Нужен page/route provider, локальный UI store и доступ к готовому graph | [Композиция через Provider](./reference/examples/react/composition-provider.md) |
| Команда выбирает организацию groups внутри `compositions` | [Структуры compositions](./reference/examples/react/composition-structures.md) |
Не используй карту как scaffold checklist. Наличие возможной папки не означает, что её нужно создать.
## Runtime-граница business
### Главный инвариант
Business-модуль выполняет доменную композицию только над:
- собственными типами и детерминированной логикой;
- capabilities, переданными фабрике через `{Domain}Deps`.
Business не вызывает runtime-возможность, если она не была передана фабрике. Это относится не только к данным и `infra`, но и к hooks, stores, subscriptions, browser API и другим concrete runtime-механизмам.
Фабрика отвечает на вопрос «какой стабильный доменный API нужен приложению», а не «какими библиотеками и источниками он реализован».
### Что разрешено внутри business
Business может напрямую использовать:
- собственные domain types;
- собственные services, mappers, normalizers, validators и type guards;
- собственные domain errors;
- детерминированные вычисления без I/O, runtime-state и скрытого окружения;
- чистые библиотеки вроде schema validators, decimal/date utilities, если результат определяется только явными аргументами и типы библиотеки не становятся public contract;
- type-only контракты других business API для cross-domain dependencies.
### Что передаётся через deps
Через `{Domain}Deps` передавай любую runtime-capability:
| Capability | Примеры concrete implementation |
|---|---|
| Product source | REST SDK, GraphQL client, CMS, storage |
| Source/query hook | SWR, TanStack Query, Apollo hook |
| Domain state runtime | Zustand, Redux, MobX, RxJS store |
| Technical service | logger, telemetry, notifications, i18n engine |
| Platform API | `window`, navigation, clipboard, geolocation |
| Lifecycle event | unauthorized event, socket event, subscription |
| Environment | env/config provider, feature runtime configuration |
| Nondeterminism | clock, timer, random, ID generator |
| Cross-domain behavior | ограниченный API другой business-фабрики |
Не импортируй concrete implementation в business даже в том случае, если библиотека используется только в одном внутреннем файле.
### Запрещённые imports
Production-код `business/**` не импортирует напрямую ни runtime values, ни types из concrete runtimes:
- `infra`, `compositions`, `app`;
- SDK, generated operations и HTTP clients;
- storage implementation;
- React state/effect APIs;
- SWR, TanStack Query, Apollo и другие query runtimes;
- Zustand, Redux, MobX, RxJS stores;
- browser и framework runtime APIs;
- event bus implementation;
- env и process-specific configuration.
Запрет нельзя обойти через `import type`, alias, barrel, type cast или helper в `shared`. Type-only разрешены собственные contracts, детерминированный `shared`, чистые libraries и суженные public API других business-доменов.
### Доменный шлюз данных
Business API является единственной продуктовой границей для потребительского кода.
```text
page / layout / screen / widget
→ {Domain}Api
→ business scenario
→ {Domain}Deps
→ private dependency adapter
→ infra client / SDK / storage / external source
```
Внешний сервис остаётся физическим источником данных. Business является единственным источником доменной истины: он определяет модель, сценарий, нормализацию, fallback и ошибки.
Обычный consumer composition не создаёт параллельный продуктовый контракт поверх DTO или client. Единственная зона concrete product integration внутри `compositions``compositions/business/{domain}`.
### Business-owned deps
`{Domain}Deps` принадлежит business-модулю и описывает необходимые возможности доменным языком.
Требования:
- группируй методы по capability, а не по имени SDK/client;
- принимай доменные аргументы;
- принимай внешние результаты как `unknown`, если нужна runtime-проверка;
- описывай собственную минимальную форму dependency hook/result;
- описывай собственный state port, а не `StoreApi` конкретной библиотеки;
- возвращай cleanup из subscription capability;
- не передавай mapper, normalizer или domain error через deps;
- не используй generated DTO как доменную модель;
- не передавай целый client, если домену нужны два конкретных действия.
Плохо:
```ts
import type { StoreApi } from 'zustand'
import type { AdminApiClient } from '@vendor/admin-sdk'
export type AuthDeps = {
api: AdminApiClient
store: StoreApi<AuthState>
}
```
Хорошо:
```ts
import type { AuthState } from './auth-state.type'
import type { VerifyPhoneCodeData } from './verify-phone-code-data.type'
export type SourceHookResult = {
data: unknown
error: unknown
isLoading: boolean
refresh: () => Promise<void>
}
export type AuthDeps = {
phoneAuth: {
requestCode: (phone: string) => Promise<unknown>
resendCode: (challengeId: string) => Promise<unknown>
verifyCode: (data: VerifyPhoneCodeData) => Promise<unknown>
}
session: {
setToken: (token?: string | null) => void
useToken: () => string | null | undefined
}
sessionEvents: {
onInvalidated: (listener: () => void) => () => void
}
state: {
create: (initialState: AuthState) => {
get: () => AuthState
set: (state: AuthState) => void
useState: () => AuthState
}
}
}
```
### Dependency adapters
Adapter находится снаружи business и реализует конкретную часть `{Domain}Deps`.
```text
compositions/business/auth/
├── create-auth-business.ts
├── adapters/
│ ├── admin-auth-session.adapter.ts
│ ├── browser-auth-navigation.adapter.ts
│ ├── zustand-auth-state.adapter.ts
│ └── admin-auth-session-events.adapter.ts
└── index.ts
```
Правила adapter:
- импортирует type-only business contract;
- импортирует concrete infra/runtime implementation;
- преобразует доменные аргументы в transport arguments;
- возвращает raw/unknown runtime result, если business должен его проверить;
- пробрасывает source error без создания domain error;
- не реализует бизнес-правило;
- не выбирает доменный fallback;
- не экспортируется через public API integration module;
- тестируется на wiring, payload и соответствие dependency contract.
Каждая runtime-capability получает явный adapter. Не скрывай несколько разных integrations как inline-функции внутри builder.
### Dependency hooks
Если business должен предоставить hook, concrete hook передаётся через deps.
```text
SWR/TanStack hook
→ dependency adapter
→ business-owned source hook contract
→ business wrapper hook
→ domain result / domain error
```
Business wrapper может:
- вызвать переданный dependency hook;
- нормализовать `data` в доменную модель;
- заменить source error доменной ошибкой;
- вычислить domain state и selectors;
- вернуть собственный стабильный result type.
Dependency hook обязан быть non-throwing и non-Suspense: technical failure возвращается в `error: unknown`, а не выбрасывается во время вызова hook. Business wrapper обязан заменить этот `error` собственной domain error. Публичные callbacks dependency result, например `refresh`, также оборачиваются business и не могут выдать source error наружу.
Business wrapper не импортирует query library. В public contract не протекают `SWRConfiguration`, `UseQueryResult`, query keys, cache implementation или library-specific mutate API без отдельного domain contract.
### Domain state
Business владеет:
- моделью доменного state;
- допустимыми переходами;
- commands и selectors;
- реакцией на dependency results и events.
Business не владеет concrete state manager. Business выбирает initial domain state и передаёт его в `deps.state.create(initialState)`; adapter только создаёт concrete store с переданным значением.
Zustand/Redux/MobX adapter реализует business-owned state adapter factory и создаётся в `compositions/business/{domain}`. Business-фабрика получает adapter factory через `deps`, передаёт initial domain state и получает concrete port.
Локальный UI-state является другим случаем. Состояние раскрытия sidebar, выбранной вкладки или шага локального UI-flow может использовать concrete state manager непосредственно внутри владеющего composition module, если оно не подменяет доменное состояние и product data boundary.
### Доменные ошибки
Из любого публичного метода, command, query или hook business-модуля выходят только собственные доменные ошибки.
Business обязан преобразовать:
- rejected promise dependency;
- synchronous throw dependency;
- source hook error;
- ошибку store/storage/browser API;
- невалидный успешный ответ;
- неизвестную runtime-ошибку.
Domain error содержит стабильный `code`. Исходная ошибка может сохраняться только в `cause` для диагностики.
Потребитель не ориентируется на:
- `message` внешней ошибки;
- HTTP status;
- `response`;
- SDK error class;
- stack или transport code.
Adapter не импортирует и не создаёт domain error. Error mapping всегда выполняет business.
Rejected promise, synchronous throw, source error и malformed response всегда превращаются в domain error. Fallback допустим только для валидного доменного исхода, явно представленного dependency contract, например корректного отсутствия данных. Fallback не используется для поглощения technical failure или contract drift.
### Чистый builder
`compositions/business/{domain}/create-{domain}-business.ts` только:
1. явно создаёт или получает runtime instances нужного lifecycle без I/O;
2. создаёт adapters поверх этих instances;
3. передаёт adapters и API других доменов в factory;
4. возвращает готовый `{Domain}Api`.
Builder не содержит:
- inline SDK calls;
- `window`/storage operations;
- domain mapping;
- domain errors;
- Zustand/SWR setup вперемешку с другими dependencies;
- product request при создании API;
- скрытую subscription без cleanup contract.
Если capability имеет lifecycle, business API предоставляет domain-level `start`/`subscribe` operation, которая использует dependency и возвращает cleanup wrapper. Business преобразует ошибки регистрации, callback и cleanup в собственные domain errors. Builder остаётся без side effects, а владелец scope запускает operation после mount/commit и вызывает cleanup при завершении lifecycle.
Browser/application builder без cross-domain dependencies вызывается без аргументов. Для request scope integration module определяет отдельный `requestScopeInput` только с framework/request data, например headers, cookies, request ID и abort signal. Concrete client factory импортируется и вызывается внутри integration module. Request input используется только для создания adapters, не передаётся в business как raw client и не экспортируется consumer compositions.
### Graph и lifecycle
Per-domain builder не является владельцем полного graph.
Владелец graph:
- выбирает application-lifetime composition/route/page/request/test scope;
- собирает домены в ацикличном порядке;
- создаёт ровно необходимый набор API;
- использует точный graph type;
- управляет cleanup subscriptions/resources;
- не повторяет adapter wiring;
- не импортирует raw infra для «досборки» домена.
Не используй generic `Partial<Business>` с последующим `as Business`. Если scope содержит только Auth, его contract должен обещать только Auth.
### Cross-domain dependencies
Один business-домен не импортирует runtime другого домена. Он объявляет нужную capability в своих `Deps` через type-only API другого домена, по возможности суженный `Pick`.
Готовый API передаётся builder-у при сборке graph:
```text
createAuthBusiness()
→ createUserBusiness({ authApi })
→ createOrdersBusiness({ userApi })
```
Runtime-цикл означает ошибочную границу доменов. Не скрывай цикл service locator, lazy import или глобальным event bus.
Подробный контракт фабрики находится в [Business-фабрике](./reference/canons/business-factory.md). Практическая сборка показана в [Business composition](./reference/examples/business-composition.md).
## Архитектурная проверка
Не считай задачу завершённой только потому, что код компилируется или UI отображается. Проверь архитектурное решение, runtime-цепочку и обязательные тестовые границы.
### Проверка владельца
- У каждой ответственности один явный владелец.
- Product state и сценарии принадлежат business-домену.
- Page/route UI-state принадлежит соответствующему composition module.
- Concrete technical service принадлежит infra-модулю.
- Concrete business dependency adapter принадлежит `compositions/business/{domain}`.
- Provider находится у владельца scope, а не в generic infra-модуле.
- Код не поднят в общий слой или package без реального consumer.
### Проверка runtime-цепочки
Для каждого `{Domain}Api` проследи цепочку сборки:
```text
graph owner
→ per-domain builder
→ dependency adapters + business factory
→ {Domain}Api
→ provider/entry
```
И отдельно цепочку вызова:
```text
consumer
→ {Domain}Api
→ business scenario
→ {Domain}Deps
→ dependency adapter
→ source/runtime
```
Если отсутствует хотя бы одно необходимое звено, домен не подключён. Тип будущего API, пустой provider или неиспользуемый builder не заменяет runtime-сборку.
Для каждого route/page entry проверь, что он достигает готового composition module. `app` не должен реализовывать screen/layout/product wiring самостоятельно.
### Проверка business imports
В production-коде `business/**` не должно быть прямых runtime или type-only imports из concrete runtimes:
- `infra`, `compositions`, `app`;
- SDK/generated clients;
- SWR/TanStack Query/Apollo;
- Zustand/Redux/MobX/RxJS store runtime;
- React state/effect runtime;
- storage/browser API;
- env/event bus/clock/random implementations.
Разрешены собственные файлы, type-only business contracts, `shared` без runtime-capabilities и чистые детерминированные библиотеки.
Проверь не только import paths, но и re-export/barrel/helper, который может скрывать запрещённую зависимость.
### Проверка deps
- Каждая runtime-capability присутствует в `{Domain}Deps`.
- Контракт принадлежит business и назван доменным языком.
- В `Deps` нет client/SDK/generated operation/StoreApi/query-library types.
- Dependency hook возвращает business-owned source result.
- State dependency использует business-owned state port.
- External result имеет `unknown`, если требует runtime validation.
- Subscription возвращает cleanup.
- Cross-domain API сужен до реально используемых методов.
### Проверка adapters
- Для каждой runtime-capability есть явный adapter.
- Adapter находится в `compositions/business/{domain}`.
- Builder не содержит inline integration logic.
- Adapter не формирует domain error.
- Adapter не выполняет domain normalization.
- Adapter не выбирает business fallback.
- Private adapters отсутствуют в public `index.ts`.
- Concrete transport imports не протекают в обычные consumer compositions.
### Проверка product data
- Page/layout/screen/widget получает product data через `{Domain}Api`.
- Нет прямого вызова SDK/client/generated operation из потребительской composition.
- Нет product storage access в UI/component/composition service.
- DTO не используется как domain/view contract без business normalization.
- Один источник не имеет параллельного прямого и business-пути.
### Проверка ошибок
Для каждого public runtime operation проверь:
- rejected dependency;
- synchronous throw dependency;
- `undefined`/`null`/empty body;
- объект неправильной формы;
- source hook error;
- invalid state/storage value;
- неизвестную runtime-ошибку.
Во всех случаях наружу выходит только domain error со стабильным `code`. Source error сохраняется в `cause`, но не становится consumer contract. Technical failure и malformed response нельзя превращать в fallback; fallback разрешён только для валидного доменного исхода.
Не оставляй формулировку «domain error, если контракт это обещает». Business public contract всегда обещает только domain errors.
### Проверка state и hooks
- Business владеет domain state model, но не concrete state manager.
- Zustand/Redux/MobX store создаётся adapter-ом, не factory.
- SWR/Query hook создаётся adapter-ом, не business-модулем.
- Dependency hook является non-throwing/non-Suspense и передаёт technical error через business-owned result.
- Business wrapper вызывает dependency hook и возвращает собственный result type.
- Business wrapper преобразует error result и ошибки callbacks в domain errors.
- Store/query library types отсутствуют в public API и `Deps`.
- Определены creator, scope, количество instances и cleanup.
- Module singleton используется только при явно доказанном application/process lifetime.
- Provider не создаёт ложное впечатление владения instance, созданным на module scope.
### Проверка graph
- Per-domain builders собирают только свои фабрики.
- Graph owner назван и соответствует lifecycle.
- Домены создаются в топологическом порядке.
- Runtime-циклы отсутствуют.
- Один и тот же graph не копируется по нескольким providers без обоснования scope.
- Screen/widget не собирает graph самостоятельно.
- Provider value имеет точный тип.
- Нет `Partial<Graph>` с unchecked cast к полному graph.
- Subscription, timer, socket и другие resources имеют cleanup/dispose.
- Pending operation не может записать stale state после invalidation/unmount без явно принятой политики.
### Проверка public API
- Межмодульные импорты идут через реальный public entrypoint.
- Import alias/package export существует физически.
- Group не имеет `index.ts`.
- Business `index.ts` экспортирует runtime только factory, остальное через `export type`.
- Integration module экспортирует builder и необходимые type-only integration input contracts.
- Raw context, raw store, mutable singleton, adapter, generated operation и persistence key закрыты.
- Каждый export имеет реального внешнего consumer.
- Deep imports отсутствуют, включая tests уровня public contract.
### Проверка тестов
Business-модуль не завершён без factory-level tests.
Обязательный минимум:
1. Форма public API фабрики.
2. Happy path каждого runtime method/hook.
3. Invalid dependency response.
4. Rejected dependency.
5. Synchronous throw каждого обычного method/callback/state/lifecycle dependency. Dependency hooks проверяются отдельным non-throwing contract.
6. Domain error `code` и `cause`.
7. Порядок side effects и остановка после ошибки.
8. State transitions и concurrent calls.
`compositions/business/{domain}` не завершён без assembly tests:
1. Factory получает правильные adapters.
2. Каждый adapter вызывает нужный runtime source с правильным payload.
3. Builder не делает I/O при создании API.
4. Cross-domain API передан в правильном виде.
5. Private adapters не раскрыты public API.
6. Adapter пробрасывает source error без создания domain error.
7. Dependency hook не бросает и не использует Suspense/throw-on-error mode.
8. Lifecycle cleanup проверен, если есть subscriptions/resources.
Colocated tests обязательны для mappers, normalizers, type guards, domain errors и другой внутренней runtime-safe логики. Они дополняют, но не заменяют factory-level tests. Подробная матрица находится в [Тестировании business-модулей](./reference/examples/business-testing.md).
Проверь наличие исполняемого test script и test runner именно в изменяемом workspace. Root task без локального script не является выполненной тестовой инфраструктурой.
### Проверка целостности репозитория
- Все imports разрешаются.
- Все упомянутые modules и public entrypoints существуют.
- Direct runtime packages объявлены в package текущего workspace.
- Старый provider/store/source path удалён после миграции, если больше не используется.
- Нет speculative scaffold с пустым graph, несуществующими доменами или placeholder contracts.
- Template исправлен, если именно он системно создаёт нарушение.
- Выполнены доступные typecheck, tests, lint/build и `git diff --check`.
### Формат архитектурного ревью
Для каждого нарушения укажи:
1. Путь и строку.
2. Нарушенный invariant.
3. Runtime или maintenance риск.
4. Минимальную корректную границу.
5. Необходимые tests.
Отделяй обязательное нарушение от необязательного улучшения. Не предлагай большую миграцию, если нарушение можно устранить локально без создания второй архитектуры.
### Финальный gate
Перед завершением ответь «да» на все вопросы:
- Архитектурная роль изменения определена?
- Владелец ответственности и state определён?
- Все runtime-capabilities проходят через правильную границу?
- Product data проходит через business API?
- Business вызывает только переданные deps и собственную детерминированную логику?
- Наружу выходят только domain errors?
- Adapters существуют и закрыты?
- Graph и lifecycle определены?
- Public API минимален и разрешим?
- Обязательные tests созданы и запущены?
- Изменение не оставило старый параллельный путь?
Если хотя бы один ответ «нет», задача не завершена.

View File

@@ -0,0 +1,12 @@
# SLM Design
`docs/` - файлы документации по SLM-архитектуре.
Используй эту документацию как источник истины для задач по SLM Design: слоям, модулям, сегментам, публичному API, business factory, composition modules и монорепозиториям.
## Структура
- `canons/` - основные каноны SLM Design.
- `examples/` - дополнительные примеры реализации.
Точка входа: `canons/index.md`.

View File

@@ -0,0 +1,331 @@
---
title: Business-фабрика
description: Контракт business-модуля, runtime-зависимости, logic API, доменные ошибки и место сборки фабрик
---
# Business-фабрика
Раздел фиксирует архитектурный контракт business-модуля: фабрика описывает доменный logic API приложения и не знает о конкретном backend, SDK, storage, state/query runtime, browser API или React tree.
## Главный принцип
Business-модуль описывает домен приложения, а не форму backend API, SDK, storage, external hook, state manager или внешнего сервиса.
Фабрика должна отвечать на вопрос: какой API нужен приложению для работы с доменом. Она не должна отвечать на вопрос: какие методы есть у конкретного REST-клиента, SDK или browser API.
## Когда создавать business-модуль
Полный контракт business-модуля — фабрика, `deps`, доменные типы, доменные ошибки, adapters, сборка в `compositions/business/{domain}`, обязательные factory/assembly tests и colocated tests для внутренней runtime-safe логики. Он применяется к любому модулю, созданному в `business`.
Создавай business-модуль, когда выполняется хотя бы одно условие:
- сценарии нужны нескольким страницам, composition modules или другим доменам;
- у логики есть собственная доменная модель и доменные ошибки, которые нельзя выразить типами внешнего API;
- доменный сценарий зависит от внешних runtime-capabilities (backend, SDK, product storage, source hook, domain store, event, browser API), которые нужно изолировать за `deps`;
- домен нужно тестировать независимо от UI и конкретного backend.
Не создавай business-модуль заранее, если логика нужна одной странице, не имеет доменной модели, product I/O и domain state. Presentation-only store или browser interaction, принадлежащие UI scope, сами по себе не создают business-домен. Такая page-local orchestration живёт в соответствующем composition module.
Наличие product I/O отменяет page-local исключение. Даже если источник нужен одной странице, consumer composition не обращается к нему напрямую: объяви business-контракт, dependency adapter и domain errors.
«Облегчённого» business-модуля не существует: если модуль создан в `business/`, контракт применяется целиком. Подъём вызревшей логики из composition module в business-модуль — обычный рефакторинг: объяви доменные типы и `deps`, перенеси сценарии в services и hooks, собери фабрику и покрой её factory-level тестами.
## Обязательные правила
- Фабрика лежит в корне business-модуля: `business/{domain}/{domain}.factory.ts`.
- Фабрика принимает runtime-зависимости только через `deps`.
- Фабрика возвращает только logic API домена: hooks, selectors, command/query methods, scenario services.
- Фабрика не возвращает React-компоненты, layouts, guards, boundaries, providers или page-level wrappers.
- Business-модуль не содержит React-компоненты. UI-решения домена размещаются в `compositions`; полностью универсальные UI-контролы размещаются в `ui`.
- Business-модуль не импортирует реальные SDK, generated operations, HTTP-клиенты, storage, env, browser API или composition-сборку.
- Business-модуль не импортирует React state/effect runtime, SWR/query runtime, Zustand/Redux/MobX store runtime или event bus implementation.
- Source/query hooks, domain state stores, subscriptions, clock, random и technical services передаются через business-owned `deps`.
- Public contract business-модуля использует собственные доменные типы, а не DTO внешнего API.
- `index.ts` business-модуля экспортирует только фабрику и type-only экспорты. Исключений нет.
- Runtime-сборка business-фабрики выполняется вне `business`, обычно в `compositions/business/{domain}`.
- Для каждой runtime-capability создаётся явный dependency adapter. Adapter не пишется inline внутри builder.
- Из public API business выходят только собственные domain errors со стабильным `code`.
- Factory-level и assembly tests обязательны и входят в критерий завершения модуля.
## Проектирование фабрики
Проектирование начинается со сценариев домена, а не с API-клиента.
Порядок работы:
1. Опиши публичные сценарии, которые нужны приложению.
2. Опиши доменные типы, которыми должен оперировать UI и другие business-модули.
3. Проведи inventory всех runtime-capabilities: data sources, hooks, stores, events, browser APIs, env, clock и cross-domain APIs.
4. Опиши минимальные внешние возможности в `{Domain}Deps`.
5. Назови внешние возможности бизнес-языком, а не языком backend endpoint'ов и библиотек.
6. Опиши domain error codes для каждого публичного сценария.
7. Собери фабрику из внутренних services, hook wrappers, mappers и helpers поверх переданных deps.
8. Верни наружу только `{Domain}Api`.
9. Реализуй каждую dependency отдельным adapter в `compositions/business/{domain}`.
Правильные вопросы:
- Не `какой endpoint дернуть?`, а `какой бизнес-сценарий выполняем?`.
- Не `какой DTO пришёл?`, а `какую доменную модель отдаём наружу?`.
- Не `как называется метод SDK?`, а `какая внешняя возможность нужна домену?`.
- Не `как устроен backend сейчас?`, а `какой стабильный контракт нужен приложению?`.
## Контракт зависимостей
`{Domain}Deps` описывает не технические инструменты, а возможности, которые нужны домену.
Правила:
- имя зависимости отражает бизнес-возможность: `phoneAuth`, `session`, `profile`, `agreements`, `notifications`;
- методы внутри зависимости называют сценарное действие без повторения endpoint names: `phoneAuth.requestCode`, `phoneAuth.verifyCode`, `profile.getCurrentUser`, `agreements.saveUserAgreements`;
- параметры используют доменные типы business-модуля;
- результат внешней границы принимается как `unknown`, если данные требуют runtime-нормализации;
- пустой успешный ответ описывается как `Promise<void>`, если body не нужен;
- dependency не должна возвращать generated DTO как доменный тип;
- mapper, normalizer или domain error не передаётся через `deps`, если это часть доменной логики.
- source/query hook описывается business-owned result type, без `SWRConfiguration`, `UseQueryResult` и других library types;
- domain state описывается business-owned port, без `StoreApi` и concrete state manager types;
- subscription возвращает cleanup function;
- недетерминированные clock/random/id capabilities передаются явно;
Плохо:
```ts
import type { ApiRequestClient, BoundApi } from '@vendor/api-sdk'
import type { v1PatientProfileList } from '@vendor/api-sdk/operations'
type UserApiTree = {
patient: {
profile: typeof v1PatientProfileList
}
}
export type UserDeps = {
api: BoundApi<UserApiTree, ApiRequestClient>
}
```
Проблема: business-модуль знает про SDK, generated operation и форму внешнего клиента.
Хорошо:
```ts
import type { AuthState } from './auth-state.type'
import type { VerifyPhoneCodeData } from './verify-phone-code-data.type'
export type AuthDeps = {
phoneAuth: {
requestCode: (phone: string) => Promise<unknown>
resendCode: (challengeId: string) => Promise<unknown>
verifyCode: (data: VerifyPhoneCodeData) => Promise<unknown>
}
session: {
setToken: (token?: string | null) => void
useToken: () => string | null | undefined
}
sessionEvents: {
onInvalidated: (listener: () => void) => () => void
}
state: {
create: (initialState: AuthState) => {
get: () => AuthState
set: (state: AuthState) => void
useState: () => AuthState
}
}
}
```
Конкретный SDK, storage, source hook, store или mock подключается в `compositions/business/auth` через адаптер. Business-модуль знает только о своём `AuthDeps`.
## Публичный API фабрики
`{Domain}Api` описывает logic API домена, который фабрика возвращает наружу в runtime.
Правила:
- API говорит на языке домена;
- API не повторяет endpoint names;
- API не отдаёт DTO внешнего сервиса;
- API не раскрывает внутренние `create*` services;
- API остаётся стабильным при замене backend, SDK или storage;
- API может возвращать hooks и selectors, созданные поверх переданных dependency hooks/state ports; business не импортирует concrete hook/store runtime;
- API не возвращает React-компоненты, UI-компоненты, layouts, guards, boundaries или page-level wrappers.
Плохо:
```ts
export type AuthApi = {
AuthGuard: ComponentType<AuthGuardProps>
LoginButton: ComponentType<LoginButtonProps>
useAuth: ReturnType<typeof createAuthHook>
}
```
Проблема: factory output смешивает доменную логику с UI и начинает собирать React tree.
Хорошо:
```ts
export type AuthApi = {
requestPhoneCode: ReturnType<typeof createRequestPhoneCode>
resendPhoneCode: ReturnType<typeof createResendPhoneCode>
verifyPhoneCode: ReturnType<typeof createVerifyPhoneCode>
useAuth: ReturnType<typeof createAuthHook>
useIsAuthenticated: ReturnType<typeof createIsAuthenticatedHook>
startSessionInvalidationTracking: () => () => void
}
```
Такой API сообщает composition-слою доменное состояние и действия, но не навязывает UI-решение.
## Реализация фабрики
Фабрика связывает внутренние части business-модуля с переданными зависимостями.
```ts
import { createCurrentUserHook } from './hooks/use-current-user.hook'
import { createStoredUserAgreementsGetter } from './services/get-stored-user-agreements.service'
import { createUpdateCurrentUserProfile } from './services/update-current-user-profile.service'
import type { UserFactory } from './types/user-factory.type'
export const userFactory: UserFactory = (deps) => {
const { authApi, profile, storage } = deps
return {
getStoredUserAgreements: createStoredUserAgreementsGetter(storage),
updateCurrentUserProfile: createUpdateCurrentUserProfile(profile),
useCurrentUser: createCurrentUserHook({ authApi, profile }),
}
}
```
Фабрика не должна:
- создавать API-клиент;
- выбирать backend endpoint;
- читать env;
- обращаться к browser storage напрямую;
- делать запросы при создании API;
- импортировать composition-сборку;
- подстраивать свой API под конкретный внешний сервис;
- создавать или возвращать React-компоненты;
- импортировать React state/effect APIs;
- импортировать SWR, query library или state manager runtime;
- создавать concrete store, query client или event bus;
- подписываться на external event без dependency contract и явного cleanup.
## Runtime-границы и ошибки
Любая dependency фабрики считается ненадёжной runtime-границей.
Business-модуль защищает публичный контракт от таких случаев:
- dependency вернула `null`, `undefined`, пустой body или объект неправильной формы;
- dependency вернула rejected promise;
- dependency синхронно выбросила исключение;
- storage содержит устаревшие или битые данные;
- внешний сервис поменял форму ответа без изменения TypeScript-типов.
Защита выполняется внутри business-модуля через mappers, normalizers, type guards и domain errors. Fallback допустим только для валидного доменного исхода, явно представленного dependency contract, а не для technical failure или malformed response.
Наружу не должны протекать ошибки SDK, HTTP-клиента, storage, query hook, store, generated API или browser API. Потребитель business API всегда получает только собственную domain error со стабильным `code`, а не `message`, `status`, `response`, `stack` или форму внешней ошибки.
```ts
if (isDomainError<AuthErrorCode>(error) && error.code === 'AUTH_PHONE_CODE_VERIFY_FAILED') {
showInvalidCodeMessage()
}
```
Business всегда преобразует rejected promise, synchronous throw, source hook error и невалидную успешную структуру в собственную domain error.
## Сборка business-фабрики
Реальные runtime-зависимости подключаются в composition module `compositions/business/{domain}`.
```text
src/
├── business/
│ └── auth/
│ ├── auth.factory.ts
│ ├── types/
│ └── index.ts
└── compositions/
└── business/
└── auth/
├── create-auth-business.ts
├── adapters/
├── types/
└── index.ts
```
Если business-домены сгруппированы, сборка повторяет тот же относительный путь: `business/app/auth` соответствует `compositions/business/app/auth`, `business/cms/content` соответствует `compositions/business/cms/content`. Группировка не является default-структурой.
Правила:
- `business/{domain}` объявляет фабрику и контракт `deps`.
- `compositions/business/{domain}` отдельными adapters адаптирует SDK, storage, infra-клиенты, source hooks, stores, events и другие runtime-capabilities к этому контракту.
- Builder явно создаёт или получает scoped runtime instances без I/O, создаёт adapters поверх них, передаёт adapters фабрике и возвращает API; integration logic не пишется inline.
- `create{Domain}Business()` возвращает готовый `{Domain}Api`.
- Browser/application `create{Domain}Business()` принимает аргументы только для API других уже собранных business-фабрик.
- SDK, storage, env, HTTP-клиенты и browser API не передаются в `create{Domain}Business()` как deps.
- Если домен не зависит от других business API, `create{Domain}Business()` вызывается без аргументов.
- Request-scoped builder отделяет cross-domain API от `requestScopeInput`. В input находятся только framework/request data; concrete client factory импортируется внутри integration module. Request input используется только для создания adapters и не передаётся factory как raw dependency.
- Конечный граф API собирается в месте, которое владеет lifecycle: page composition, route composition, provider, request scope или test setup.
- `infra` не собирает business-фабрики, потому что не должен импортировать `business`.
- `app` не реализует business-сборку, а только подключает готовые composition modules к фреймворку.
```ts
import { createAuthBusiness } from '@/compositions/business/auth'
import { createUserBusiness } from '@/compositions/business/user'
const authApi = createAuthBusiness()
const userApi = createUserBusiness({ authApi })
```
Подробный пример см. в [Business composition](../examples/business-composition.md).
## Public API файла index.ts
Жёсткое правило без исключений: `business/{domain}/index.ts` экспортирует только фабрику и type-only экспорты.
```ts
export { userFactory } from './user.factory'
export type { User } from './types/user.type'
export type { UserApi } from './types/user-api.type'
export type { UserDeps } from './types/user-deps.type'
export type { UserFactory } from './types/user-factory.type'
export type { UserErrorCode } from './types/user-error-code.type'
```
## Тестирование
Business-модуль тестируется через public API фабрики. Factory-level тесты обязательны, импортируют модуль только через `business/{domain}` и не используют deep imports во внутренние `services`, `hooks`, `mappers` или `lib`.
Сборка в `compositions/business/{domain}` обязательно тестируется отдельно: эти тесты проверяют adapters, корректность передачи deps, отсутствие I/O при создании API и lifecycle cleanup, но не заменяют сценарные тесты business-модуля.
Colocated unit tests обязательны там, где есть mappers, normalizers, type guards, domain errors или другая внутренняя runtime-safe логика. Они являются дополнительным уровнем, а не заменой factory-level и assembly tests.
Подробный пример см. в [Business testing](../examples/business-testing.md).
## Чеклист
- Фабрика лежит в корне business-модуля.
- Фабрика принимает все runtime-зависимости через `deps`.
- В `deps` нет SDK, generated operations, HTTP-клиентов, backend DTO, StoreApi и query-library types.
- Контракты зависимостей названы бизнес-языком.
- Source/query hooks, stores, events и platform APIs переданы через `deps`.
- Business не импортирует React/SWR/query/store runtime.
- Все public types принадлежат business-модулю.
- Внешние ответы нормализуются перед попаданием в public API.
- Все source errors без исключений заменяются собственными domain errors.
- Public API фабрики не повторяет внешний API.
- Public API фабрики не возвращает React-компоненты.
- Business-модуль не содержит React-компоненты.
- `index.ts` экспортирует только фабрику и type-only экспорты, без исключений.
- Runtime-сборка живёт в `compositions/business/{domain}`.
- Для каждой runtime-capability существует private adapter.
- Builder не содержит inline integration logic.
- Business-модуль можно подключить к другому backend через адаптер без изменения фабрики.
- Factory-level и assembly tests созданы и выполняются.

View File

@@ -0,0 +1,293 @@
---
title: Runtime-граница business
description: Строгая изоляция business-фабрики от источников данных, state/query runtime, infra, browser API и внешних ошибок
---
# Runtime-граница business
## Главный инвариант
Business-модуль выполняет доменную композицию только над:
- собственными типами и детерминированной логикой;
- capabilities, переданными фабрике через `{Domain}Deps`.
Business не вызывает runtime-возможность, если она не была передана фабрике. Это относится не только к данным и `infra`, но и к hooks, stores, subscriptions, browser API и другим concrete runtime-механизмам.
Фабрика отвечает на вопрос «какой стабильный доменный API нужен приложению», а не «какими библиотеками и источниками он реализован».
## Что разрешено внутри business
Business может напрямую использовать:
- собственные domain types;
- собственные services, mappers, normalizers, validators и type guards;
- собственные domain errors;
- детерминированные вычисления без I/O, runtime-state и скрытого окружения;
- чистые библиотеки вроде schema validators, decimal/date utilities, если результат определяется только явными аргументами и типы библиотеки не становятся public contract;
- type-only контракты других business API для cross-domain dependencies.
## Что передаётся через deps
Через `{Domain}Deps` передавай любую runtime-capability:
| Capability | Примеры concrete implementation |
|---|---|
| Product source | REST SDK, GraphQL client, CMS, storage |
| Source/query hook | SWR, TanStack Query, Apollo hook |
| Domain state runtime | Zustand, Redux, MobX, RxJS store |
| Technical service | logger, telemetry, notifications, i18n engine |
| Platform API | `window`, navigation, clipboard, geolocation |
| Lifecycle event | unauthorized event, socket event, subscription |
| Environment | env/config provider, feature runtime configuration |
| Nondeterminism | clock, timer, random, ID generator |
| Cross-domain behavior | ограниченный API другой business-фабрики |
Не импортируй concrete implementation в business даже в том случае, если библиотека используется только в одном внутреннем файле.
## Запрещённые imports
Production-код `business/**` не импортирует напрямую ни runtime values, ни types из concrete runtimes:
- `infra`, `compositions`, `app`;
- SDK, generated operations и HTTP clients;
- storage implementation;
- React state/effect APIs;
- SWR, TanStack Query, Apollo и другие query runtimes;
- Zustand, Redux, MobX, RxJS stores;
- browser и framework runtime APIs;
- event bus implementation;
- env и process-specific configuration.
Запрет нельзя обойти через `import type`, alias, barrel, type cast или helper в `shared`. Type-only разрешены собственные contracts, детерминированный `shared`, чистые libraries и суженные public API других business-доменов.
## Доменный шлюз данных
Business API является единственной продуктовой границей для потребительского кода.
```text
page / layout / screen / widget
→ {Domain}Api
→ business scenario
→ {Domain}Deps
→ private dependency adapter
→ infra client / SDK / storage / external source
```
Внешний сервис остаётся физическим источником данных. Business является единственным источником доменной истины: он определяет модель, сценарий, нормализацию, fallback и ошибки.
Обычный consumer composition не создаёт параллельный продуктовый контракт поверх DTO или client. Единственная зона concrete product integration внутри `compositions``compositions/business/{domain}`.
## Business-owned deps
`{Domain}Deps` принадлежит business-модулю и описывает необходимые возможности доменным языком.
Требования:
- группируй методы по capability, а не по имени SDK/client;
- принимай доменные аргументы;
- принимай внешние результаты как `unknown`, если нужна runtime-проверка;
- описывай собственную минимальную форму dependency hook/result;
- описывай собственный state port, а не `StoreApi` конкретной библиотеки;
- возвращай cleanup из subscription capability;
- не передавай mapper, normalizer или domain error через deps;
- не используй generated DTO как доменную модель;
- не передавай целый client, если домену нужны два конкретных действия.
Плохо:
```ts
import type { StoreApi } from 'zustand'
import type { AdminApiClient } from '@vendor/admin-sdk'
export type AuthDeps = {
api: AdminApiClient
store: StoreApi<AuthState>
}
```
Хорошо:
```ts
import type { AuthState } from './auth-state.type'
import type { VerifyPhoneCodeData } from './verify-phone-code-data.type'
export type SourceHookResult = {
data: unknown
error: unknown
isLoading: boolean
refresh: () => Promise<void>
}
export type AuthDeps = {
phoneAuth: {
requestCode: (phone: string) => Promise<unknown>
resendCode: (challengeId: string) => Promise<unknown>
verifyCode: (data: VerifyPhoneCodeData) => Promise<unknown>
}
session: {
setToken: (token?: string | null) => void
useToken: () => string | null | undefined
}
sessionEvents: {
onInvalidated: (listener: () => void) => () => void
}
state: {
create: (initialState: AuthState) => {
get: () => AuthState
set: (state: AuthState) => void
useState: () => AuthState
}
}
}
```
## Dependency adapters
Adapter находится снаружи business и реализует конкретную часть `{Domain}Deps`.
```text
compositions/business/auth/
├── create-auth-business.ts
├── adapters/
│ ├── admin-auth-session.adapter.ts
│ ├── browser-auth-navigation.adapter.ts
│ ├── zustand-auth-state.adapter.ts
│ └── admin-auth-session-events.adapter.ts
└── index.ts
```
Правила adapter:
- импортирует type-only business contract;
- импортирует concrete infra/runtime implementation;
- преобразует доменные аргументы в transport arguments;
- возвращает raw/unknown runtime result, если business должен его проверить;
- пробрасывает source error без создания domain error;
- не реализует бизнес-правило;
- не выбирает доменный fallback;
- не экспортируется через public API integration module;
- тестируется на wiring, payload и соответствие dependency contract.
Каждая runtime-capability получает явный adapter. Не скрывай несколько разных integrations как inline-функции внутри builder.
## Dependency hooks
Если business должен предоставить hook, concrete hook передаётся через deps.
```text
SWR/TanStack hook
→ dependency adapter
→ business-owned source hook contract
→ business wrapper hook
→ domain result / domain error
```
Business wrapper может:
- вызвать переданный dependency hook;
- нормализовать `data` в доменную модель;
- заменить source error доменной ошибкой;
- вычислить domain state и selectors;
- вернуть собственный стабильный result type.
Dependency hook обязан быть non-throwing и non-Suspense: technical failure возвращается в `error: unknown`, а не выбрасывается во время вызова hook. Business wrapper обязан заменить этот `error` собственной domain error. Публичные callbacks dependency result, например `refresh`, также оборачиваются business и не могут выдать source error наружу.
Business wrapper не импортирует query library. В public contract не протекают `SWRConfiguration`, `UseQueryResult`, query keys, cache implementation или library-specific mutate API без отдельного domain contract.
## Domain state
Business владеет:
- моделью доменного state;
- допустимыми переходами;
- commands и selectors;
- реакцией на dependency results и events.
Business не владеет concrete state manager. Business выбирает initial domain state и передаёт его в `deps.state.create(initialState)`; adapter только создаёт concrete store с переданным значением.
Zustand/Redux/MobX adapter реализует business-owned state adapter factory и создаётся в `compositions/business/{domain}`. Business-фабрика получает adapter factory через `deps`, передаёт initial domain state и получает concrete port.
Локальный UI-state является другим случаем. Состояние раскрытия sidebar, выбранной вкладки или шага локального UI-flow может использовать concrete state manager непосредственно внутри владеющего composition module, если оно не подменяет доменное состояние и product data boundary.
## Доменные ошибки
Из любого публичного метода, command, query или hook business-модуля выходят только собственные доменные ошибки.
Business обязан преобразовать:
- rejected promise dependency;
- synchronous throw dependency;
- source hook error;
- ошибку store/storage/browser API;
- невалидный успешный ответ;
- неизвестную runtime-ошибку.
Domain error содержит стабильный `code`. Исходная ошибка может сохраняться только в `cause` для диагностики.
Потребитель не ориентируется на:
- `message` внешней ошибки;
- HTTP status;
- `response`;
- SDK error class;
- stack или transport code.
Adapter не импортирует и не создаёт domain error. Error mapping всегда выполняет business.
Rejected promise, synchronous throw, source error и malformed response всегда превращаются в domain error. Fallback допустим только для валидного доменного исхода, явно представленного dependency contract, например корректного отсутствия данных. Fallback не используется для поглощения technical failure или contract drift.
## Чистый builder
`compositions/business/{domain}/create-{domain}-business.ts` только:
1. явно создаёт или получает runtime instances нужного lifecycle без I/O;
2. создаёт adapters поверх этих instances;
3. передаёт adapters и API других доменов в factory;
4. возвращает готовый `{Domain}Api`.
Builder не содержит:
- inline SDK calls;
- `window`/storage operations;
- domain mapping;
- domain errors;
- Zustand/SWR setup вперемешку с другими dependencies;
- product request при создании API;
- скрытую subscription без cleanup contract.
Если capability имеет lifecycle, business API предоставляет domain-level `start`/`subscribe` operation, которая использует dependency и возвращает cleanup wrapper. Business преобразует ошибки регистрации, callback и cleanup в собственные domain errors. Builder остаётся без side effects, а владелец scope запускает operation после mount/commit и вызывает cleanup при завершении lifecycle.
Browser/application builder без cross-domain dependencies вызывается без аргументов. Для request scope integration module определяет отдельный `requestScopeInput` только с framework/request data, например headers, cookies, request ID и abort signal. Concrete client factory импортируется и вызывается внутри integration module. Request input используется только для создания adapters, не передаётся в business как raw client и не экспортируется consumer compositions.
## Graph и lifecycle
Per-domain builder не является владельцем полного graph.
Владелец graph:
- выбирает application-lifetime composition/route/page/request/test scope;
- собирает домены в ацикличном порядке;
- создаёт ровно необходимый набор API;
- использует точный graph type;
- управляет cleanup subscriptions/resources;
- не повторяет adapter wiring;
- не импортирует raw infra для «досборки» домена.
Не используй generic `Partial<Business>` с последующим `as Business`. Если scope содержит только Auth, его contract должен обещать только Auth.
## Cross-domain dependencies
Один business-домен не импортирует runtime другого домена. Он объявляет нужную capability в своих `Deps` через type-only API другого домена, по возможности суженный `Pick`.
Готовый API передаётся builder-у при сборке graph:
```text
createAuthBusiness()
→ createUserBusiness({ authApi })
→ createOrdersBusiness({ userApi })
```
Runtime-цикл означает ошибочную границу доменов. Не скрывай цикл service locator, lazy import или глобальным event bus.
Подробный контракт фабрики находится в [Business-фабрике](./business-factory.md). Практическая сборка показана в [Business composition](../examples/business-composition.md).

View File

@@ -0,0 +1,216 @@
---
title: Процесс архитектурного решения
description: Обязательный порядок классификации задачи, выбора владельца, слоя, scope и стратегии изменений
---
# Процесс архитектурного решения
Не изменяй файлы, пока не принято архитектурное решение. Название папки, существующий похожий код и удобный импорт не доказывают правильность размещения.
## Карточка решения
Перед реализацией определи:
| Вопрос | Что зафиксировать |
|---|---|
| Роль изменения | Framework wiring, продуктовый сценарий, интеграция, композиция интерфейса, технический сервис, UI или чистый фундамент |
| Владелец | Домен, route/page scope, composition module, infra-модуль, UI-модуль или локальный consumer |
| Данные | Продуктовые данные, техническое состояние, framework input, локальное UI-state или отсутствуют |
| Runtime-возможности | Источники данных, hooks, stores, SDK, browser API, events, clock, random, env и другие внешние capabilities |
| Место | Приложение или package, слой, модуль, вложенный модуль и сегмент |
| Публичная граница | Что действительно нужно экспортировать и кто будет consumer |
| Путь данных | От consumer до business API, dependency adapter и конкретного источника |
| Lifecycle | Кто создаёт instance, сколько instances допустимо и кто выполняет cleanup |
| Стратегия | Локальная правка, новый модуль, новый business-контракт, adapter, перенос или исправление public API |
| Проверки | Typecheck, тесты, import graph, public API, lifecycle и архитектурные инварианты |
Карточку не обязательно выводить пользователю, если решение очевидно. Но агент обязан уметь обосновать каждый пункт до изменения файлов.
## Сбор контекста
Перед выбором места:
1. Прочитай локальные инструкции приложения или package.
2. Найди фактическую границу SLM: `src/`, `apps/{app}/src` или другой локальный root.
3. Проверь существующие слои, группы и соседние модули. Не создавай новую параллельную структуру без необходимости.
4. Проверь aliases, package exports и реальную разрешимость импортов.
5. Найди текущих consumers, public API и runtime import graph изменяемой ответственности.
6. Проверь существующие templates или generators после архитектурного выбора. Шаблон не принимает решение за SLM.
7. Отдельно найди product I/O, hooks, stores, subscriptions, browser API и другие runtime-возможности.
8. Проверь, нет ли уже business-домена, которому принадлежит сценарий.
Не считай неиспользуемый provider, пустой context, тип будущего graph или ссылку на несуществующий домен готовой архитектурой. Решение должно быть достижимо из runtime entry point и иметь реальных consumers.
## Выбор роли
Классифицируй ответственность в следующем порядке.
### Framework wiring
Если код существует только из-за фреймворка, размести его в `app`:
- route-файл;
- bootstrap;
- framework error entry;
- подключение глобальных ресурсов;
- тонкое подключение готового composition module.
`app` не реализует продуктовую композицию, business graph, store, provider или экран.
### Продуктовый сценарий
Если код определяет пользовательский сценарий, доменную модель, продуктовый state, бизнес-правило, нормализацию внешних данных, error mapping или доменный переход после ошибки, владелец находится в `business/{domain}`.
Визуальная реакция на готовый domain error принадлежит consumer composition: сообщение, error screen, redirect, retry control и UI fallback выбираются по стабильному доменному `code`.
Любой новый внешний источник продуктовых данных требует business-контракта. Колокация внешних вызовов в page/screen/widget services не является допустимым упрощением.
### Интеграция business-домена
Если код реализует `{Domain}Deps` через SDK, HTTP, storage, browser API, state/query runtime, event bus или другой concrete runtime, размести его в `compositions/business/{domain}`.
Это интеграционный composition module, а не business-домен и не обычная page/screen/widget composition.
### Продуктовая композиция
Если код собирает route/page/layout/screen/widget, управляет UI-state, provider scope или lifecycle готового business graph, размести его в соответствующем composition module.
Потребительский composition module получает продуктовые данные только через `{Domain}Api`. Он не импортирует product SDK, generated operations, product storage adapter или конкретный источник.
### Технический сервис
Если код предоставляет техническую возможность без продуктовой модели и сценариев, размести его в `infra`:
- HTTP client;
- SDK wrapper;
- logger;
- theme engine;
- i18n engine;
- telemetry transport;
- технический realtime client.
Composition может использовать технический infra-сервис напрямую, если сервис не становится обходным путём к продуктовым данным. Если capability нужна business, она всё равно передаётся через business-owned `deps` и adapter.
### Универсальный UI
Если сущность отображает интерфейс, не знает продуктовый сценарий и применима независимо от конкретной composition, размести её в `ui`.
### Чистый фундамент
Если код детерминирован, не имеет runtime-state, не знает продукт и переиспользуется несколькими владельцами, рассмотри `shared`. По умолчанию оставляй код рядом с первым владельцем.
## Выбор scope
Выбирай минимальный scope, который полностью владеет ответственностью:
1. Нужен одному component/module и не имеет самостоятельной ответственности: оставь внутри владельца.
2. Нужен как самостоятельная часть одного module: создай nested module в `parts/`.
3. Нужен нескольким частям одной page/route ветки: подними в общий composition scope этой ветки.
4. Нужен нескольким composition modules и остаётся продуктовой композицией: создай отдельный composition module.
5. Является доменным сценарием или product data boundary: создай или расширь `business/{domain}`.
6. Является техническим сервисом: создай или расширь `infra/{service}`.
7. Является универсальным UI: создай или расширь `ui/{module}`.
8. Выноси в package только `ui`, `infra` или `shared` код с реальным вторым consumer либо явно зафиксированным межприложенческим ownership/reuse-контрактом.
Не поднимай код выше ради короткого импорта. Не создавай `shared`, общий provider, generic business context или package «на будущее».
## Component, module и group
Применяй решение последовательно:
1. Только отображает готовые props и не владеет зависимостями: component в `ui/` родительского module.
2. Владеет сценарием, данными, state, dependency, lifecycle или внутренней декомпозицией: самостоятельный module.
3. Самостоятельный module, локальный для владельца: nested module в `parts/`.
4. Папка только классифицирует конечные modules: group без `index.ts`, state и runtime logic.
5. `ui/`, `parts/`, `hooks/`, `types/`, `services/` и другие служебные папки внутри module: segments, а не modules.
Если component начинает получать данные, выбирать источник, вызывать сценарный hook или управлять процессом, не добавляй логику в component. Измени архитектурную форму сущности.
## Выбор стратегии
### Новый продуктовый сценарий
1. Найди домен-владелец.
2. Спроектируй `{Domain}Api`, доменные типы и доменные ошибки.
3. Опиши минимальные runtime-capabilities в `{Domain}Deps`.
4. Реализуй детерминированную доменную логику.
5. Создай отдельные adapters в `compositions/business/{domain}`.
6. Собери фабрику чистым builder.
7. Подключи API во владельце lifecycle graph.
8. Используй API из потребительских compositions.
9. Добавь factory-level и assembly tests.
### Прямой product I/O вне business boundary
Не расширяй существующее нарушение.
1. Определи сценарий и домен.
2. Перенеси контракт данных в business-owned `Deps`.
3. Перенеси нормализацию, fallback и error mapping в business.
4. Оставь concrete source call в dependency adapter.
5. Замени прямой вызов на `{Domain}Api`.
6. Закрой adapter и source details из public API.
### Новый store или dependency hook
Сначала определи, является state локальным UI-state или доменным state.
- State является локальным UI-state, если сбрасывается вместе с UI scope, управляет только представлением и не хранит продуктовый факт или product data cache. Такой state может принадлежать composition module и использовать выбранный state manager внутри владельца.
- State является доменным, если выражает продуктовый факт, инвариант, доступен через business API или участвует в бизнес-сценарии.
- Доменный state принадлежит business-контракту. Фабрика получает state adapter factory через `deps`, выбирает initial domain state и создаёт concrete port через adapter.
- Source/query hook реализуется adapter-ом; business вызывает только dependency hook и возвращает собственный доменный hook/result.
### Сборка graph
1. Собери каждый домен отдельным `compositions/business/{domain}` builder.
2. Определи DAG cross-domain зависимостей.
3. Выбери один явный lifecycle scope: application-lifetime composition, route, page, request или test. Слой `app` только подключает application composition.
4. Создавай graph у владельца scope, а не в случайном screen/widget или на module scope без обоснования.
5. Передавай consumers точный graph type. Не используй `Partial<Graph>` с приведением к полному типу.
6. Для subscriptions, timers и resources зафиксируй cleanup/dispose.
### Архитектурное ревью
Проверяй не только пути файлов, но и семантику:
- business-shaped код вне `business`;
- product graph в `infra`;
- type-only imports, которые фактически переносят ownership;
- provider, который не создаёт и не получает instance от явного владельца;
- orphan modules и providers, недостижимые из entry point;
- public API, раскрывающий raw store, context, adapter или generated types;
- отсутствующие tests обязательного business-контракта.
## Условия остановки
Останови реализацию и сначала исправь решение, если:
- владелец ответственности не определён;
- один state или source имеет несколько конкурирующих владельцев;
- business требует прямого runtime или type-only import concrete runtime;
- graph создаёт runtime-цикл;
- lifecycle instance или cleanup не определён;
- public API нужен только для обхода границы;
- шаблон генерирует архитектуру, противоречащую принятому решению;
- изменение требует незапрошенной миграции нескольких независимых областей.
## Локальные материалы
Основной процесс достаточен для типового решения. Открывай только материал, который нужен текущей ветке задачи.
| Ситуация | Материал |
|---|---|
| Нужна полная карта допустимых файлов, root entries, segments и tests | [Атлас файлов SLM](./file-atlas.md) |
| Задача затрагивает product I/O, source hook, domain store, event, lifecycle или external errors | [Runtime-граница business](./business-runtime-boundary.md) |
| Выполняется архитектурное ревью или финальная проверка реализации | [Архитектурная проверка](./validation.md) |
| Неясен layer, направление import или роль `app/compositions/business/infra/ui/shared` | [Слои](./layers.md) |
| Нужно отличить module, component, group, nested module или спроектировать public API | [Модули](./modules.md) |
| Проектируется factory, Api, Deps, domain error или сборка домена | [Business-фабрика](./business-factory.md) |
| Неясно размещение hook/store/service/mapper/provider/type/style | [Сегменты](./segments.md) |
| Решается вынос из `apps/*/src` в `packages/*` | [Монорепозитории](./monorepo.md) |
| Нужен полный пример adapters, builder, state runtime и graph lifecycle | [Business composition](../examples/business-composition.md) |
| Нужна матрица factory-level, assembly и colocated tests | [Тестирование business-модулей](../examples/business-testing.md) |
| Нужен page/route provider, локальный UI store и доступ к готовому graph | [Композиция через Provider](../examples/react/composition-provider.md) |
| Команда выбирает организацию groups внутри `compositions` | [Структуры compositions](../examples/react/composition-structures.md) |
Не используй карту как scaffold checklist. Наличие возможной папки не означает, что её нужно создать.

View File

@@ -0,0 +1,508 @@
---
title: Атлас файлов SLM
description: Карта слоёв, типов modules, root files, segments, public API, tests и запрещённых файловых сочетаний
---
# Атлас файлов SLM
Используй атлас после того, как определены роль изменения и владелец ответственности. Не выбирай архитектуру по желаемому имени файла.
Атлас исчерпывает стандартные архитектурные роли SLM, но не является закрытым списком framework-файлов. Команда может добавить локальный segment или suffix, если его ответственность не дублирует существующую, не нарушает направление зависимостей и закреплена в локальных инструкциях.
## Единицы структуры
| Единица | Что означает | Имеет public API | Владеет runtime |
|---|---|---|---|
| Layer | Верхнеуровневая область ответственности внутри SLM root | Нет общего требования | Зависит от layer |
| Group | Навигационная папка для modules или других groups | Нет, `index.ts` запрещён | Нет |
| Module | Самостоятельный владелец одной ответственности | Да | Может |
| Segment | Папка внутри module по назначению файлов | Нет отдельного внешнего API | Только как часть владельца |
| Component | Презентационная часть родительского module в `ui/` | Локальный `index.ts` допустим | Нет архитектурного runtime |
| Root file | Главный entry/contract конкретного module | Экспортируется module `index.ts` | Зависит от типа module |
Сначала определи module, затем root file, затем необходимые segments. Не создавай все папки из атласа заранее.
## Карта SLM root
```text
src/
├── app/ # framework wiring
├── compositions/ # product tree, graph owners и business integrations
├── business/ # доменные контракты и сценарии
├── infra/ # технические runtime-сервисы
├── ui/ # универсальные UI modules
└── shared/ # детерминированный фундамент
```
В monorepo вместо `src/` границей приложения обычно является `apps/{app}/src/`. Packages находятся выше SLM root и не являются дополнительными слоями.
## Root files modules
| Pattern | Роль | Где допустим |
|---|---|---|
| `{name}.page.tsx` | Готовая page composition | `compositions` |
| `{name}.layout.tsx` | Product layout composition | `compositions` |
| `{name}.screen.tsx` | Уникальный screen leaf/branch | `compositions` |
| `{name}.widget.tsx` | Самостоятельный composition block | `compositions` |
| `{name}.route.tsx` | Route composition и route lifecycle | `compositions` |
| `{name}.entry.tsx` | Готовая точка подключения product tree | `compositions` |
| `{scope}-business-composition.ts` | Non-visual сборка business graph/scope | `compositions` |
| `{name}.ts` | Другой non-visual root, названный по ответственности | `compositions`, nested modules |
| `{name}.tsx` | Root UI/module component без специальной роли | `compositions`, `ui`, nested modules |
| `{domainName}.factory.ts` | Единственный runtime entry business-домена | `business/{domainPath}` |
| `create-{domainName}-business.ts` | Builder одной business-фабрики | `compositions/business/{domainPath}` |
| `{name}.client.ts` | Технический client | `infra/{service}` |
| `{name}.service.ts` | Технический root service, если service и есть module entry | `infra/{service}` |
| `index.ts` | Public API конечного module | В module; запрещён у group |
Root suffix не определяет owner автоматически. Например, `profile.store.ts` остаётся domain store или page UI store в зависимости от смысла state.
## Layer App
`app` содержит только файлы, требуемые framework/runtime entry.
Возможные файлы:
| Файл или pattern | Назначение |
|---|---|
| `main.tsx`, `bootstrap.tsx` | Запуск приложения |
| `app.tsx` | Тонкое подключение application entry/providers |
| `app-router.tsx`, `router.tsx` | Framework route registry |
| `page.tsx`, `layout.tsx`, `route.ts`, `error.tsx`, `not-found.tsx` | Framework-convention files |
| `middleware.ts` | Framework middleware boundary |
| framework metadata/config files | Только framework contract |
Правила:
- framework file импортирует готовый entry/route/page composition через public API;
- product tree собирается в `compositions`, не в `app`;
- business graph, domain store, screen, widget и product provider в `app` запрещены;
- у `app` нет SLM modules и общего `index.ts`;
- framework-specific server/client правила определяет профильный framework skill.
Минимальный пример:
```text
src/app/
├── app-router.tsx
├── app.tsx
└── main.tsx
src/compositions/entries/profile/
├── profile.entry.tsx
└── index.ts
```
## Consumer composition module
Consumer composition собирает product UI и использует готовые `{Domain}Api`.
```text
compositions/{group}/{name}/
├── {name}.{page|layout|screen|widget|route|entry}.tsx # visual entry, если нужен
├── {name}-business-composition.ts # business graph, если module владеет им
├── {name}.ts # другой non-visual entry, если нужен
├── ui/ # presentation components
├── parts/ # nested modules
├── providers/ # provider владельца scope
├── guards/ # route/UI guards над готовым DomainApi
├── hooks/ # access/orchestration hooks
├── stores/ # только локальный UI-state
├── services/ # orchestration готовых API
├── mappers/ # domain result -> ViewModel
├── types/ # module-owned types
├── errors/ # только composition/UI errors, не domain errors
├── lib/ # локальные deterministic helpers
├── config/ # module configuration
├── styles/ # styles module
├── tests/ # scope/integration tests при необходимости
└── index.ts # public API
```
Не все segments обязательны. Создавай только используемые.
Composition module не обязан иметь `.tsx`: request/application business graph использует `{scope}-business-composition.ts`, а другая non-visual orchestration может иметь root `.ts`, названный по ответственности, и public `index.ts`.
Guard принадлежит composition scope, использует готовый `{Domain}Api` и выбирает route/UI outcome. Guard не получает product data напрямую, не создаёт business graph и не импортирует source runtime.
Consumer composition не содержит:
- product SDK/client/generated operation;
- product storage adapter;
- source/query adapter домена;
- domain store implementation;
- domain mapper/error;
- business factory implementation.
Product data поступает только через `{Domain}Api`. `stores/` хранит presentation-only state: sidebar, tab, local step, transient form UI. Product entities и domain state туда не копируются.
### Page/route graph owner
Если composition владеет business graph и lifecycle, возможна структура:
```text
compositions/routes/profile/
├── profile.route.tsx
├── profile-business-composition.ts
├── providers/
│ ├── profile-business.provider.tsx
│ └── profile-business.context.ts
├── hooks/
│ └── use-profile-business.hook.ts
├── types/
│ └── profile-business.type.ts
├── tests/
│ └── profile-business-lifecycle.test.tsx
└── index.ts
```
Правила graph owner:
- graph type перечисляет только реально доступные API;
- `Partial<Business>` с cast к полному graph запрещён;
- graph создаётся в lifecycle владельца;
- domain lifecycle operation запускается после commit и возвращает cleanup;
- raw SDK/client/event bus не импортируется для досборки домена;
- screen/widget не создаёт тот же graph повторно.
## Integration module business
`compositions/business/{domainPath}` является единственным module, который знает одновременно business dependency contract и concrete runtime.
`{domainPath}` означает полный относительный путь конечного business module, а `{domainName}` — имя его последней папки. При groups integration path зеркалирует business path:
```text
business/app/auth/
compositions/business/app/auth/
business/cms/content/
compositions/business/cms/content/
```
```text
compositions/business/{domainPath}/
├── create-{domainName}-business.ts # обязательный builder
├── create-{domainName}-business.test.ts # обязательный assembly test
├── adapters/ # обязательны при runtime dependencies
│ ├── {source}.adapter.ts
│ ├── {storage}.adapter.ts
│ ├── {query}-hook.adapter.ts
│ ├── {state-manager}-state.adapter.ts
│ ├── {event-source}-events.adapter.ts
│ └── {platform}-navigation.adapter.ts
├── types/ # builder/cross-domain/request input types
│ ├── create-{domainName}-business-deps.type.ts
│ └── create-{domainName}-business-request-input.type.ts
├── testing/ # assembly fixtures, не public API
└── index.ts # builder и type-only integration inputs
```
Builder:
1. Явно создаёт или получает runtime instances нужного lifecycle без I/O.
2. Создаёт adapters поверх runtime instances.
3. Передаёт adapters и cross-domain API фабрике.
4. Возвращает готовый `{Domain}Api`.
Browser/application builder без cross-domain dependencies вызывается без аргументов. Request-scoped builder отдельно принимает `requestScopeInput` с request data; concrete client factory импортируется integration module.
Integration module не содержит:
- domain mapper/normalizer;
- domain error;
- business scenario;
- React provider/layout/screen/widget;
- full application graph;
- exported private adapter.
## Business module
Каждый `business/{domainPath}` имеет полный factory contract.
```text
business/{domainPath}/
├── {domainName}.factory.ts # обязательно
├── index.ts # обязательно
├── types/ # обязательно для contracts
│ ├── {domainName}-api.type.ts
│ ├── {domainName}-deps.type.ts
│ ├── {domainName}-factory.type.ts
│ ├── {domainName}-error.type.ts
│ ├── {domainName}-error-code.type.ts
│ ├── {entity}.type.ts
│ ├── {source}-hook-result.type.ts
│ └── {domainName}-state.type.ts
├── errors/
│ └── {domainName}-business.error.ts
├── services/
│ └── {scenario}.service.ts
├── hooks/
│ └── use-{scenario}.hook.ts # wrapper над dependency hook
├── mappers/
│ └── map-{entity}.ts # unknown -> domain
├── lib/
│ └── {domain-helper}.ts
├── config/
│ └── {domainName}.config.ts # deterministic domain constants
└── tests/
└── {domainName}-factory/
├── public-api.test.ts
├── {scenario}.test.ts
└── testing/
└── create-{domainName}-deps.mock.ts
```
Обязательный минимум:
- `{domainName}.factory.ts`;
- `{Domain}Api`, `{Domain}Deps`, `{Domain}Factory`;
- domain error codes и собственная domain error для runtime failure;
- `index.ts` с одним runtime export фабрики и type-only exports;
- factory-level tests каждого public runtime operation;
- integration builder и assembly test для каждого business-модуля, включая dependency-free factory.
Условные файлы:
- `services/` только при выделенном сценарии;
- `hooks/` только для wrapper над dependency hook;
- `mappers/`/`normalizers/`/type guards при внешнем `unknown`;
- `errors/` при runtime operations;
- colocated tests обязательны для каждого mapper, normalizer, type guard и domain error;
- colocated tests services/hooks обязательны при самостоятельной branching, race или другой runtime-safe логике.
В business запрещены:
- `ui/`, React components, providers, layouts и guards;
- concrete `stores/` Zustand/Redux/MobX;
- `adapters/` concrete runtime;
- SDK/client/generated DTO;
- SWR/TanStack Query/Apollo runtime или types;
- React state/effect runtime или types;
- storage/browser/event/env implementation;
- raw external error в public API.
## Infra module
Infra module владеет техническим сервисом без продуктовой модели.
```text
infra/{service}/
├── {service}.client.ts # если module является client
├── {service}.service.ts # если module является service
├── client.ts # допустимый technical entry
├── config/
├── clients/
├── services/
├── transports/
├── hooks/ # technical hooks
├── providers/ # provider technical service
├── ui/ # только technical UI, например theme tooling
├── errors/ # transport/technical errors
├── types/ # technical contracts/DTO
├── lib/
├── tests/
└── index.ts
```
Различай два вида adapter:
| Вид | Где живёт | Что знает |
|---|---|---|
| Transport adapter/client | `infra` | Протокол, SDK, HTTP, transport types |
| Domain dependency adapter | `compositions/business/{domainPath}` | Business `Deps` и конкретный infra runtime |
Infra не содержит business graph, domain state, product provider или domain error. Type-only imports business API не дают infra права агрегировать product graph.
## UI module
UI module предоставляет универсальный UI без business logic и product I/O.
```text
ui/{name}/
├── {name}.tsx # обязательный root component
├── ui/ # внутренние presentation components
├── parts/ # nested UI modules при самостоятельной роли
├── hooks/ # presentation behavior
├── stores/ # только локальный UI-state
├── providers/ # UI scope provider
├── types/ # props и UI contracts
├── styles/
├── lib/ # presentation helpers
├── tests/
└── index.ts
```
UI module может строиться на других UI modules и `shared`. Он не импортирует business, infra или compositions, не получает product data самостоятельно и не выбирает источник.
## Shared
`shared` содержит только детерминированный фундамент без знания о продукте и runtime-state.
```text
shared/
├── lib/
│ └── {utility}/
│ ├── {utility}.ts
│ ├── {utility}.test.ts
│ └── index.ts
├── types/
├── styles/
├── config/ # только product-agnostic constants
├── assets/ # product-agnostic assets при локальном соглашении
└── sprites/ # специализированная группа assets, если используется
```
Shared не содержит:
- product/domain types;
- stores и mutable singletons;
- SDK/client wrappers;
- React providers;
- environment-dependent services;
- imports из других SLM layers.
## Components внутри ui segment
Presentation component родительского module имеет плоскую структуру:
```text
{module}/ui/{component}/
├── {component}.tsx
├── types/
│ └── {component}-props.type.ts
├── styles/
│ └── {component}.module.css
└── index.ts
```
В папке component запрещены:
- `hooks/`, `stores/`, `services/`, `providers/`, `parts/`;
- source calls и scenario hooks;
- imports project code вне parent module, кроме разрешённых UI modules;
- nested components как отдельные architectural folders.
Если это требуется, сущность становится module и перемещается в `parts/` либо на общий composition/UI уровень.
## Nested modules в parts
Каждый элемент `parts/` является полноценным module:
```text
{parent}/parts/{part}/
├── {part}.tsx # visual root, если нужен
├── {part}.ts # non-visual root, если нужен
├── ui/
├── parts/
├── hooks/
├── stores/ # только state ответственности part
├── types/
├── styles/
├── tests/
└── index.ts
```
Одиночные `.tsx`, `.ts` или style files непосредственно в `parts/` запрещены. Если nested module нужен за пределами parent, подними его в минимальный общий scope.
## Segment matrix
| Segment | Consumer composition | Business integration | Business | Infra | UI | Shared |
|---|---|---|---|---|---|---|
| `ui/` | Да | Нет | Нет | Условно, technical UI | Да | Нет |
| `parts/` | Да | Нет | Нет | Условно | Да | Нет |
| `providers/` | Да | Нет | Нет | Да | Условно | Нет |
| `guards/` | Да, над готовым DomainApi | Нет | Нет | Нет | Нет | Нет |
| `hooks/` | Да | Нет | Только wrappers над deps | Technical hooks | Presentation hooks | Нет |
| `stores/` | Только UI-state | Нет, используй `adapters/` | Нет concrete stores | Technical state | Только UI-state | Нет |
| `services/` | Orchestration готовых API | Нет, используй `adapters/` | Domain scenarios над deps | Technical services | Presentation-only | Нет |
| `adapters/` | Нет product adapters | Да | Нет | Только transport adapters по локальному соглашению | Нет | Нет |
| `mappers/` | Domain -> ViewModel | Нет, transport adaptation остаётся в adapter | `unknown` -> domain | Transport mapping | View mapping | Чистые generic transforms |
| `errors/` | UI/composition errors | Нет domain errors | Domain errors | Technical/transport errors | UI errors | Только generic errors |
| `types/` | Module contracts | Integration input types | Domain contracts | Technical contracts/DTO | Props/UI contracts | Product-agnostic types |
| `styles/` | Да | Нет | Нет | Условно | Да | Global/foundation styles |
| `lib/` | Local helpers | Assembly helpers | Domain deterministic helpers | Technical helpers | Presentation helpers | Generic deterministic helpers |
| `config/` | Composition constants | Runtime assembly config | Domain constants | Technical config/env | UI constants | Product-agnostic constants |
| `tests/` | Scope/integration tests | Обязательные assembly tests | Обязательные factory tests | Technical tests | UI tests | Unit tests |
## Имена обычных файлов
| Pattern | Назначение |
|---|---|
| `{name}.type.ts` | Module-owned type |
| `{name}-props.type.ts` | Component props |
| `{name}.hook.ts`, `use-{name}.hook.ts` | Hook владельца |
| `{name}.store.ts` | Concrete store только допустимого owner/scope |
| `{name}.service.ts` | Scenario или technical service по layer |
| `{name}.adapter.ts` | Adapter с явно определённым видом |
| `map-{name}.ts`, `normalize-{name}.ts` | Mapper/normalizer владельца |
| `{name}.provider.tsx` | Provider владельца scope |
| `{name}.guard.tsx` | Route/UI guard consumer composition |
| `{name}.context.ts`, `{name}.context.tsx` | Private context implementation |
| `{name}.error.ts` | Error соответствующего layer |
| `{name}.config.ts` | Configuration владельца |
| `{name}.constant.ts` | Константа владельца |
| `{name}.module.css` | Styles конкретного module/component |
| `{name}.test.ts`, `{name}.test.tsx` | Colocated test |
Suffix описывает техническую форму, но не переносит ownership. `user.type.ts` не становится shared только потому, что это type; `auth.store.ts` не становится infra только потому, что использует Zustand.
## Public API по типам modules
| Module | Runtime exports | Type exports | Не экспортировать |
|---|---|---|---|
| Consumer composition | Entry/provider/access hooks | Props, state/view types | Raw context/store factory/internal parts |
| Business integration | `create{Domain}Business` | Cross-domain/request input types | Adapters, clients, mocks |
| Business | Только `{domainName}Factory` | Api, Deps, Factory, domain/error types | Services, hooks, mappers, error class |
| Infra | Минимальный technical API | Technical contracts | Mutable internals и generated tree без необходимости |
| UI | Root component и доказанные UI helpers | Props/UI types | Internal components/store/context |
| Nested module | Root entry | Props/module types | Parent internals |
Если дочерние layout/screen/widget импортируют access hooks владельца scope, не экспортируй из того же public API готовый entry, который импортирует эти дочерние modules. Раздели scope API и ready entry на отдельные composition modules, чтобы не создать runtime-цикл.
## Tests map
| Проверяемая граница | Размещение | Обязательность |
|---|---|---|
| Business public contract | `business/{domainPath}/tests/{domainName}-factory/` | Обязательно |
| Business mapper/normalizer/type guard/domain error | Рядом с файлом | Обязательно для каждого такого файла |
| Business service/hook wrapper | Рядом с файлом | При самостоятельной branching/race/runtime-safe логике |
| Adapter и builder wiring | `compositions/business/{domainPath}/*.test.ts` | Обязательно для каждого business-модуля |
| Graph lifecycle/provider | Tests graph owner module | При state/subscriptions/resources |
| Consumer composition | Рядом или `tests/` module | По поведению scope |
| Infra transport/service | Внутри infra module | По technical contract |
| UI module/component | Внутри UI module | По интерактивному поведению |
## Запрещённые структуры
```text
business/auth/ui/ # React UI внутри business
business/auth/stores/auth.store.ts # concrete Zustand/Redux store
business/auth/adapters/backend.adapter.ts # concrete runtime adapter
business/auth/types/sdk-response.type.ts # generated/external DTO contract
compositions/pages/profile/services/api.ts # прямой product source
compositions/screens/profile/store.ts # product/domain cache в screen
infra/business/ # product graph/provider в infra
shared/user.type.ts # product domain type в shared
business/app/index.ts # group с public API
compositions/pages/index.ts # group с public API
{module}/parts/hero.tsx # файл вместо nested module
{module}/ui/card/hooks/ # component с собственной логикой
```
## Новый тип файла или segment
Если нужной роли нет в атласе:
1. Назови ответственность файла без технического suffix.
2. Определи module-владельца и допустимые зависимости.
3. Проверь, не является ли файл существующим `service`, `adapter`, `mapper`, `provider` или nested module.
4. Создай новый segment только для нескольких файлов с одной устойчивой ролью.
5. Не создавай global segment на уровне `src/`.
6. Зафиксируй локальное соглашение, если pattern будет повторяться.
7. Обнови template, если новый pattern стал обязательной повторяемой структурой.
Не подгоняй ответственность под красивое дерево. Минимальный корректный module лучше полного scaffold без реального поведения.

View File

@@ -0,0 +1,148 @@
---
title: SLM Design
description: Назначение архитектуры, ключевые принципы и карта разделов документации
---
# Основы SLM Design
Scoped Layered Module Design — модульная архитектура фронтенд-приложений. Код организован по слоям ответственности, а модуль содержит всё, что ему нужно: компоненты, хуки, сторы, типы, стили.
## Рабочий алгоритм
1. Заполни архитектурную карточку из [процесса принятия решения](./decision-process.md): роль, владелец, данные, runtime-capabilities, место, public API, lifecycle и проверки.
2. Сначала выбери слой ответственности, затем module scope, затем segments и только после этого конкретные файлы.
3. Для product data и domain state примени [runtime-границу business](./business-runtime-boundary.md).
4. Выбирай минимальное корректное место и не создавай общий provider, store, package или business-контракт «на будущее».
5. После реализации пройди [архитектурную проверку](./validation.md). Задача не завершена без обязательных business и assembly tests.
## Дополнительные примеры
Каноны ниже достаточны для базового архитектурного решения. Если нужен подробный пример реализации, открой конкретный файл:
- [Композиция через Provider](../examples/react/composition-provider.md) — page-level provider, store и business composition.
- [Структуры compositions](../examples/react/composition-structures.md) — допустимые структуры слоя `compositions`.
- [Business composition](../examples/business-composition.md) — runtime-сборка business-фабрик в `compositions/business/{domain}`.
- [Тестирование business-модулей](../examples/business-testing.md) — factory-level тесты и тесты сборки.
## Разделы спецификации
Спецификация SLM Design состоит из нескольких связанных разделов. Этот обзор даёт общий контекст, а детальные правила описаны дальше:
- [Слои](./layers.md) — уровни организации `src/`, направление зависимостей и зона ответственности каждого слоя.
- [Модули](./modules.md) — границы ответственности, публичный API, типы модулей и отличие модуля от компонента.
- [Атлас файлов SLM](./file-atlas.md) — root files, segments, структуры всех типов modules, public API и tests.
- [Business-фабрика](./business-factory.md) — контракт business-модуля, logic API, deps, доменные ошибки и сборка фабрик.
- [Runtime-граница business](./business-runtime-boundary.md) — capabilities фабрики, adapters, hooks, stores и безусловные domain errors.
- [Сегменты](./segments.md) — внутренние папки модуля (`ui/`, `parts/`, `hooks/`, `types/` и другие) и правила размещения файлов.
- [Монорепозитории](./monorepo.md) — применение SLM в `apps/` и `packages/`, правила выноса общих слоёв и ограничения для business/compositions.
- [Архитектурная проверка](./validation.md) — блокирующие gates создания, рефакторинга и ревью.
Рекомендуемый порядок чтения: процесс решения → runtime-граница business → архитектурная проверка → атлас или подробности только нужной ветки.
## Преимущества
### Единый слой композиции
Страницы, маршруты и крупные продуктовые части интерфейса собираются в `compositions`. Слой не навязывает жёсткую структуру: команда может использовать `pages/layouts/screens/widgets` или другую организацию под свой фреймворк и продукт.
### Вертикальная организация домена
Бизнес-домен не разбивается по техническим слоям — сценарии, сущности, типы, hooks, services и mappers живут в одном модуле. Это сокращает время навигации и упрощает сопровождение: доменная логика локализована.
### Dependency Injection без фреймворков
Runtime-зависимости business-модуля реализуются через фабрики — модуль декларирует что ему нужно, а composition-сборка предоставляет зависимости. Домены изолированы от SDK, storage и backend-клиентов без DI-контейнеров и шин событий.
### Разделение ответственности без перегрузки слоёв
Композиция приложения (`compositions/`), сервисы приложения (`infra/`), UI-кит (`ui/`) и общие ресурсы (`shared/`) — разные слои с разной природой. Ни один слой не превращается в свалку разнородного кода.
### Графовая композиция там, где она нужна
Внутри `compositions` допускается граф импортов через публичный API. Это позволяет page-level store, provider или сборку business-фабрик использовать одновременно в layout, screen и widget, не перенося продуктовый runtime-state в `infra` или `shared`.
### Горизонтальная инкапсуляция
Вложенные модули (`parts/`) и публичные API позволяют нескольким разработчикам работать над одной областью приложения параллельно, не затрагивая код друг друга.
### Колокация по умолчанию
Код начинает жизнь рядом с местом использования и поднимается в общие слои только при реальной потребности. Глобальные слои не засоряются преждевременными абстракциями.
### Масштабирование через группировку
При росте проекта слои не теряют структуру — модули группируются по естественным признакам: композиции по страницам и маршрутам, бизнес-домены по субдоменам, UI-компоненты по уровню абстракции.
### Адаптация к монорепозиториям
SLM применяется внутри каждого приложения, а `packages/*` используются только для общего кода из слоёв `ui`, `infra` и `shared`. `compositions` и бизнес-домены остаются внутри приложений, чтобы не размывать продуктовые границы.
## Происхождение
SLM Design вырос на основе:
- **Feature-Sliced Design** — слоистая структура, публичный API модуля, направление зависимостей
- **Vertical Slice Architecture** — модуль как вертикальный срез, содержащий всё необходимое
- **Screaming Architecture** — структура проекта «кричит» о назначении: открыл `business/auth` — видишь авторизацию
- **Colocation Principle** — код живёт рядом с местом использования
## Пример структуры проекта
```text
src/
├── app/
├── compositions/
│ ├── business/
│ │ ├── auth/
│ │ └── user/
│ ├── pages/
│ │ ├── home/
│ │ ├── profile/
│ │ └── product-detail/
│ ├── layouts/
│ │ ├── main/
│ │ └── dashboard/
│ ├── screens/
│ │ ├── home/
│ │ └── profile/
│ └── widgets/
│ ├── page-heading/
│ └── promo-banner/
├── business/
│ ├── auth/
│ ├── catalog/
│ ├── orders/
│ └── chat/
├── infra/
│ ├── theme/
│ ├── i18n/
│ ├── backend-api/
│ └── logger/
├── ui/
│ ├── button/
│ ├── input/
│ ├── modal/
│ ├── toast/
│ └── dropdown/
└── shared/
├── lib/
├── types/
└── styles/
```
## Принципы
- **Композиция — отдельный слой.** Страницы, маршруты и крупные продуктовые части интерфейса собираются в `compositions`.
- **Структура композиции свободна.** Команда сама выбирает организацию внутри `compositions`; базовая рекомендация — `pages/layouts/screens/widgets`.
- **Домен — единое целое.** Доменная модель, сценарии, типы, services и mappers живут в одном business-модуле. Concrete data/state/query hooks передаются фабрике через adapters.
- **Колокация.** Код рождается рядом с местом использования и поднимается только при необходимости.
- **Зависимости однонаправлены за пределами compositions.** `app` подключает `compositions`; `compositions` связывает `business`, `infra`, `ui` и `shared`; `business` вызывает concrete runtime-возможности только через переданные фабрике `deps`.
- **Product data проходит через business.** Page, layout, screen и widget не обращаются к product source напрямую.
- **Ошибки принадлежат домену.** Из business API выходят только собственные domain errors со стабильным `code`.
- **Внутри compositions допустим граф.** Composition modules могут импортировать друг друга через public API.
- **Архитектура — каркас, не клетка.** Правила фиксируют границы ответственности и public API, а внутреннюю форму композиции определяет команда.

View File

@@ -0,0 +1,285 @@
---
title: Слои
description: Иерархия слоёв от app до shared, правила зависимостей и зона ответственности каждого слоя
---
# Слои
Раздел описывает слои SLM: что такое слой, какие бывают, как между ними направлены зависимости и какие правила действуют на каждом.
## Определение
**Слой — уровень организации кода внутри `src/`. Каждый слой отвечает за свою область и задаёт правила для кода внутри: направление импортов, именование, допустимые связи между модулями.**
## Группы слоёв
Слои делятся на три группы:
| Группа | Слои | Описание |
|--------|------|----------|
| Композиция | `app`, `compositions` | Подключают приложение к фреймворку и собирают страницы, маршруты и крупные продуктовые части интерфейса |
| Ядро | `business`, `infra`, `ui` | Реализация продукта: бизнес-домены, техсервисы, UI-кит |
| Фундамент | `shared` | Общие ресурсы: утилиты, хелперы, стили, конфиги |
## Направление зависимостей
Любой импорт между модулями — только через публичный API.
```text
app → compositions
compositions → business | infra | ui | shared
business → shared
infra → infra | shared
ui → ui | shared
shared -/→ SLM-слои
```
- `app` подключает приложение к фреймворку и импортирует готовые composition modules
- `compositions` импортирует `business`, `infra`, `ui`, `shared`
- `business` импортирует только собственные файлы, детерминированный `shared`, чистые библиотеки и type-only контракты других business-модулей
- `infra` импортирует `infra` и `shared`
- `ui` импортирует `ui` и `shared`
- `shared` не импортирует другие SLM-слои
- `business`, `infra`, `ui`, `shared` не импортируют `compositions`
- Внутри `compositions` направление импортов между composition modules не фиксируется, но импорты разрешены только через публичный API и не должны создавать runtime-циклы
- Модули `business` вызывают любую runtime-capability только через `deps` фабрики; source/query hooks, stores, events и platform APIs также являются dependencies
- `import type` не разрешает переносить ownership: business не импортирует generated DTO, SDK/client/store/query types, а infra не объявляет product graph через type-only business imports
- Pure deterministic third-party libraries допустимы внутри business, если не имеют I/O/state/hidden environment и не протекают в public contract
## Слой App
Точка входа приложения. Отвечает за запуск, роутинг и подключение composition modules к фреймворку.
В отличие от остальных слоёв, `app/` не содержит модулей SLM. Здесь живут только инфраструктурные файлы, которые не могут быть никаким другим слоем: файлы фреймворка роутинга, точка запуска и код инициализации.
### Требования
- Не содержит модулей SLM — только файлы фреймворка, роутинг, инициализация
- Содержит: файлы маршрутов, bootstrap, обработку ошибок верхнего уровня (404, error boundary), подключение глобальных стилей и ассетов
- Провайдеры, guards, layouts, screens и страницы — только подключает готовые из `compositions`, не реализует
- Не содержит бизнес-логику, UI-компоненты, хуки, сторы, сервисы
- Никем не импортируется
## Слой Compositions
`compositions/` — слой сборки страниц, маршрутов и крупных продуктовых частей интерфейса.
На этом слое собираются page, layout, screen, widget и другие composition modules. Они связываются между собой и с нижними слоями: `business`, `infra`, `ui`, `shared`.
SLM не фиксирует жёсткую структуру внутри `compositions`. Команда выбирает организацию под фреймворк, роутинг, CMS и продуктовую задачу.
Базовая рекомендация:
```text
src/compositions/
├── business/
├── pages/
├── layouts/
├── screens/
└── widgets/
```
`business`, `pages`, `layouts`, `screens` и `widgets` внутри `compositions` не являются отдельными SLM-слоями. Это группы composition modules.
`compositions/business/{domain}` используется для runtime-сборки business-фабрик с реальными зависимостями приложения. Это не business-слой, а composition module, который адаптирует `infra`, SDK, storage и browser API к `deps` business-фабрики.
Внутри `compositions` различай три роли:
| Роль | Ответственность |
|---|---|
| Integration module | `compositions/business/{domain}` реализует adapters и собирает одну business-фабрику |
| Graph owner | Page/route/provider/request scope собирает готовые business API и управляет lifecycle |
| Consumer composition | Page/layout/screen/widget вызывает `{Domain}Api` и не знает concrete product source |
Consumer composition получает продуктовые данные только через business API. Прямые SDK/HTTP/generated/storage вызовы разрешены только dependency adapters интеграционного модуля домена.
Технический infra-сервис без product data можно использовать в composition напрямую. Если такой сервис нужен business, он всё равно описывается business-owned capability и передаётся фабрике через adapter.
Если business-домены сгруппированы, `compositions/business` повторяет ту же группировку: `business/app/auth` соответствует `compositions/business/app/auth`, `business/cms/content` соответствует `compositions/business/cms/content`. Это не default-структура, а способ сохранить навигацию в крупных проектах.
Composition module может содержать обычные сегменты SLM: `ui/`, `parts/`, `hooks/`, `stores/`, `services/`, `mappers/`, `types/`, `styles/`, `lib/`, `config/`, `providers/`.
Page-level store, provider, guard или business composition размещаются внутри page composition module, если они нужны всей странице.
```text
compositions/pages/profile/
├── profile.page.tsx
├── profile-business-composition.ts
├── providers/
├── hooks/
├── stores/
├── types/
└── index.ts
```
Layout, screen и widget могут получать через public API page composition локальный UI-state и готовые `{Domain}Api`. Product data не копируется в page store как параллельный источник истины.
```ts
import { useProfilePageStore } from '@/compositions/pages/profile'
```
Внутри `compositions` направление импортов между composition modules не фиксируется. Допустим граф, но все импорты идут только через public API.
```ts
// Хорошо
import { useProfilePageStore } from '@/compositions/pages/profile'
// Плохо
import { useProfilePageStore } from '@/compositions/pages/profile/hooks/use-profile-page-store.hook'
```
### Требования
- `compositions` содержит composition modules страниц, маршрутов и крупных продуктовых частей интерфейса
- Структура внутри `compositions` выбирается командой
- Базовая рекомендация: `business/`, `pages/`, `layouts/`, `screens/`, `widgets/`
- `business`, `pages`, `layouts`, `screens`, `widgets` внутри `compositions` являются группами composition modules
- `compositions/business/{domain}` собирает конкретную business-фабрику с runtime-зависимостями; при группировке используется зеркальный путь `compositions/business/{group}/{domain}`
- Concrete product sources, source/query hooks, domain stores, browser APIs и events подключаются только adapters интеграционного module
- Page/layout/screen/widget используют product data только через `{Domain}Api`
- Builder одной фабрики явно создаёт или получает scoped runtime instances без I/O, создаёт adapters поверх них и передаёт adapters factory; integration logic не пишется inline
- Providers, stores, guards и business composition размещаются внутри того composition module, которому они принадлежат
- Внутри `compositions` импорты между composition modules разрешены в любую сторону, но только через public API
- Runtime-циклы между composition modules запрещены
- Deep imports внутрь composition modules запрещены
- `business`, `infra`, `ui` и `shared` не импортируют `compositions`
## Слой Business
Бизнес-домены приложения: auth, catalog, orders, checkout, chat. Каждый домен — отдельный модуль со своими типами, hooks, services, mappers и доменной логикой.
Слой входит в группу «Ядро». Импортирует собственные файлы, детерминированный `shared/`, чистые библиотеки и type-only контракты других business-модулей. Каждый бизнес-модуль создаёт публичный API фабрики в корне. Любые runtime-capabilities передаются через аргументы фабрики.
Business объединяет то, что в FSD разделено на `features` и `entities`: пользовательские сценарии и бизнес-сущности живут вместе, внутри одного домена. Внутри домена сегменты разделяют ответственность: `types/` — доменная модель и dependency contracts, `hooks/` и `services/` — wrappers над переданными capabilities, `mappers/` — доменная нормализация, `lib/` — детерминированные доменные утилиты.
Business-модуль не содержит React-компоненты, layouts, guards, providers и page-level wrappers. Визуальный fallback, route boundary и привязка logic API к React tree размещаются в `compositions`; error mapping и допустимый доменный fallback остаются в business.
```text
src/business/
├── auth/
├── catalog/
├── orders/
├── checkout/
└── chat/
```
Когда количество доменов затрудняет навигацию — можно ввести группировку по крупным предметным областям. Группа — папка для организации, не модуль и не public API.
```text
src/business/
├── app/
│ ├── auth/
│ ├── profile/
│ └── orders/
└── cms/
├── content/
├── media/
└── navigation/
```
`app` и `cms` здесь являются группами доменов. Модулями остаются конечные папки: `auth`, `profile`, `content`, `media`, `navigation`.
### Требования
- Один модуль = один бизнес-домен
- Business-домены можно группировать при необходимости; группа не является модулем и не содержит `index.ts`
- Циклические зависимости между доменами запрещены
- Публичный API фабрики — через фабрику в корне модуля (`{name}.factory.ts`). `index.ts` экспортирует только фабрику и type-only экспорты, без исключений
- Фабрика возвращает только logic API: hooks, selectors, command/query methods, scenario services
- Business-модуль не содержит React-компоненты и не возвращает компоненты из фабрики
- Любые runtime-capabilities — только через `deps` фабрики: product sources, hooks, stores, events, technical services, env и browser APIs
- Business не импортирует React/SWR/query/store runtime; concrete hooks и stores реализуются adapters
- Реальные SDK, API-клиенты, storage, state/query runtime, env и browser API подключаются в `compositions/business/{domain}` или зеркальном пути при группировке
- Business нормализует внешние результаты и подставляет только собственные domain errors
- Доменные типы (`User`, `Product`) живут здесь, не в `shared/`
## Слой infra
Техсервисы приложения: theme, i18n, API-адаптеры, logger, realtime. Каждый сервис — отдельный модуль.
Слой входит в группу «Ядро». Импортирует `infra/` и `shared/`.
Отличие от `shared/`: infra — инфраструктура приложения (сервисы, темы, адаптеры к API), `shared/` — общие ресурсы (утилиты, хелперы, стили, конфиги).
```text
src/infra/
├── theme/
├── i18n/
├── backend-api/
├── maps-api/
├── logger/
├── feature-flags/
└── realtime/
```
### Требования
- Один модуль = один техсервис
- Импортирует `infra/` и `shared/`
- Не содержит продуктовые composition modules конкретных страниц или маршрутов
## Слой UI
UI-кит без бизнес-логики: button, carousel, toast, modal.
Слой входит в группу «Ядро». Импортирует `ui/` и `shared/`.
Компоненты строятся друг на друге: `button` использует `icon`, `carousel` использует `button`.
```text
src/ui/
├── button/
├── input/
├── icon/
├── carousel/
├── modal/
├── toast/
├── dropdown/
├── tabs/
└── tooltip/
```
Когда количество компонентов затрудняет навигацию — вводится группировка на примитивы и композиции. Примитивы (`button`, `icon`, `input`) не импортируют композиции. Композиции (`carousel`, `modal`, `dropdown`) строятся на примитивах.
```text
src/ui/
├── primitives/
│ ├── button/
│ ├── input/
│ ├── icon/
│ └── badge/
└── composites/
├── carousel/
├── modal/
├── dropdown/
├── tabs/
└── tooltip/
```
### Требования
- Не содержит бизнес-логику
- Импортирует только `ui/` и `shared/`
## Слой Shared
Общие ресурсы: утилиты, хелперы, стили, конфиги. Не знает о бизнес-домене.
Слой входит в группу «Фундамент» — ни о ком не знает, никого не импортирует.
Отличие от `infra/`: infra — инфраструктура приложения (сервисы, темы, адаптеры к API), `shared/` — общие ресурсы (утилиты, хелперы, стили, конфиги).
Отличие от `ui/`: UI-компоненты (button, carousel, modal) живут в слое `ui/`, а не здесь.
```text
src/shared/
├── lib/
├── types/
├── styles/
└── sprites/
```
### Требования
- Не имеет runtime-состояния
- Не знает о продуктовых composition modules

View File

@@ -0,0 +1,300 @@
---
title: Модули
description: Структура модуля, типы (композиционный, UI, бизнес, инфра), публичный API, отличие модуля от компонента
---
# Модули
Раздел описывает модуль как границу ответственности в SLM: что считается модулем, что такое компонент внутри модуля и как модуль взаимодействует с остальным кодом.
## Определение
**Модуль — минимальная архитектурная единица SLM. Он живёт на одном из слоёв, владеет конкретной областью ответственности и предоставляет наружу только публичный API.**
Модуль может содержать всё, что нужно этой области: компоненты, вложенные модули, хуки, сторы, сервисы, типы, стили, конфиги и утилиты. Набор сегментов не фиксирован — модуль включает только то, что реально нужно.
Модуль не обязан быть UI-блоком. Это может быть page composition, layout composition, screen composition, widget composition, бизнес-домен, инфраструктурный сервис или UI-kit сущность.
Главная граница модуля — не папка, а ответственность.
## Компонент
**Компонент — презентационная единица модуля, которая находится только в `ui/` своего родительского модуля и отвечает за отображение части интерфейса.**
Компонент не является архитектурной единицей: он не владеет сценарием, зависимостями, данными или внутренней структурой. Он работает только внутри границы родительского модуля.
> Компонент отображает. Модуль организует.
Компонент не может:
- Импортировать код проекта за пределами родительского модуля. Единственное исключение — компоненты слоя `ui`, если правила слоёв разрешают родительскому модулю импортировать `ui`.
- Владеть архитектурными зависимостями.
- Содержать вложенные компоненты: папка компонента включает только `{name}.tsx`, `index.ts`, `styles/`, `types/`.
- Содержать вложенные модули.
- Делать внешние запросы.
- Самостоятельно получать данные.
- Выбирать источник данных.
- Композировать данные.
- Вызывать сценарные хуки.
- Оркестрировать сценарий.
- Композировать модули.
- Решать, как устроен процесс.
- Содержать бизнес-логику.
- Содержать сценарную логику.
Компонент может рендерить другие компоненты: соседние компоненты из `ui/` своего модуля, компоненты слоя `ui` и элементы, переданные через props. Запрет «содержать» относится к структуре папки, а не к JSX-разметке: декомпозиция компонентов остаётся плоской внутри `ui/` родительского модуля.
Если компоненту требуется что-то из запрещённого списка, он перестаёт быть компонентом и должен быть оформлен как модуль.
```text
auth/
├── ui/
│ └── logout-button/
│ ├── logout-button.tsx
│ ├── styles/
│ │ └── logout-button.module.css
│ ├── types/
│ │ └── logout-button-props.type.ts
│ └── index.ts
└── index.ts
```
## Что считается модулем
Модулем считается папка, которая представляет самостоятельную область ответственности и имеет публичную границу.
## Группы модулей
Слой может содержать не только модули, но и группы модулей. По умолчанию модуль лежит прямо в слое; группу вводят только когда модулей много и нужна явная классификация.
Группа — навигационная папка внутри слоя или другой группы. Она классифицирует модули по типу, предметной области, продуктовой зоне или runtime-назначению, но сама не является модулем.
Жёсткие правила группы:
- группа не имеет public API;
- группа не содержит `index.ts`;
- группа не импортируется внешним кодом;
- группа не владеет логикой, состоянием, deps или runtime-сборкой;
- группа может содержать другие группы и конечные модули.
Модулем считается конечная папка с самостоятельной ответственностью и публичной границей.
```text
src/business/
├── app/ # группа
│ ├── auth/ # business-модуль
│ └── profile/ # business-модуль
└── cms/ # группа
├── content/ # business-модуль
└── media/ # business-модуль
```
Примеры модулей:
- `compositions/pages/home/` — модуль page composition.
- `compositions/layouts/main/` — модуль layout composition.
- `compositions/screens/profile/` — модуль screen composition.
- `compositions/widgets/page-heading/` — модуль widget composition.
- `business/auth/` — модуль бизнес-домена.
- `infra/theme/` — модуль инфраструктурного сервиса.
- `ui/button/` — модуль UI-kit сущности.
- `compositions/pages/home/parts/hero-section/` — вложенный модуль page composition.
Не считаются модулями:
- `ui/`, `parts/`, `hooks/`, `types/`, `styles/`, `config/`, `providers/` — это сегменты.
- `compositions/pages/`, `business/app/`, `business/cms/` — это группы, если в них нет `index.ts`.
- `compositions/pages/home/ui/user-card/` — это компонент, если он находится в `ui/` и соблюдает ограничения компонента.
## Типы модулей
Тип модуля определяет обязательный корневой файл и стартовую структуру.
### Композиционный модуль
Композиционный модуль — модуль внутри `compositions`, который участвует в сборке страниц, маршрутов и крупных продуктовых частей интерфейса.
Он может быть page, layout, screen, widget, block, entry-point, CMS-entry, route segment или другим типом композиции, выбранным командой.
```text
compositions/pages/profile/
├── profile.page.tsx
├── profile-business-composition.ts
├── providers/
├── hooks/
├── stores/
├── parts/
├── types/
└── index.ts
```
Композиционный модуль может импортировать другие composition modules через public API. Это отличие слоя `compositions`: внутри него допускается графовая композиция.
При этом deep imports запрещены.
```ts
// Хорошо
import { useProfilePageStore } from '@/compositions/pages/profile'
// Плохо
import { useProfilePageStore } from '@/compositions/pages/profile/hooks/use-profile-page-store.hook'
```
### UI-модуль
Модуль строится вокруг основного UI-компонента и обязан иметь основной `.tsx` файл в корне:
```text
button/
├── button.tsx
└── index.ts
```
`ui/` внутри такого модуля используется только для компонентов, которые помогают корневому `.tsx` файлу.
### Бизнес-модуль
Бизнес-модуль — модуль, который строится вокруг публичного API фабрики.
Business-модуль содержит доменную логику, типы, hooks, services, mappers и helpers. Он не содержит React-компоненты и не возвращает компоненты из фабрики.
Бизнес-модуль обязан иметь фабрику в корне:
```text
auth/
├── auth.factory.ts
├── index.ts
└── types/
```
Фабрика возвращает публичный API модуля для использования в runtime.
### Инфраструктурный модуль
Инфраструктурный модуль — модуль, который строится вокруг технического сервиса или интеграции.
Инфраструктурный модуль не обязан иметь фиксированный корневой файл. Его структура определяется природой сервиса.
```text
theme/
├── index.ts
├── config/
├── hooks/
├── styles/
└── ui/
```
```text
backend-api/
├── backend-api.client.ts
├── config/
├── types/
└── index.ts
```
## Структура
Модуль состоит из сегментов. Ни один сегмент не обязателен — модуль включает только те части, которые нужны его ответственности.
```text
{module-name}/
├── {module-name}.factory.ts # фабрика (для business-модулей)
├── {module-name}.tsx # корневой файл модуля (опционален)
├── ui/ # компоненты модуля, кроме business-модулей
├── parts/ # вложенные модули
├── providers/ # провайдеры модуля
├── hooks/ # хуки
├── stores/ # сторы состояния
├── services/ # сценарии и операции модуля
├── mappers/ # трансформация на границе ответственности
├── types/ # типы
├── styles/ # стили
├── lib/ # утилиты модуля
├── config/ # константы и конфигурация
└── index.ts # публичный API
```
Подробное описание сегментов — в разделе [Сегменты](./segments.md).
## Публичный API
Внешний код импортирует модуль только через публичный API.
```ts
// Хорошо
import { customerFactory } from '@/business/customer'
import type { Customer } from '@/business/customer'
```
```ts
// Плохо
import { validateToken } from '@/business/auth/lib/tokens'
```
`index.ts` модуля не обязан экспортировать всё содержимое. Он экспортирует только то, что действительно нужно снаружи.
Внутренние сегменты модуля остаются деталями реализации.
Business-модуль экспортирует из `index.ts` только фабрику и type-only экспорты. Это жёсткое правило без исключений.
```ts
// business/customer/index.ts
export { customerFactory } from './customer.factory'
export type { Customer } from './types/customer.type'
export type { CustomerApi } from './types/customer-api.type'
export type { CustomerDeps } from './types/customer-deps.type'
export type { CustomerFactory } from './types/customer-factory.type'
```
Composition module экспортирует через `index.ts` только безопасный контракт, который нужен другим composition modules или `app`: page/layout/screen/widget, provider, hooks доступа, типы. Внутренние stores, context objects и функции создания состояния не экспортируются без необходимости.
Stateful module по умолчанию не экспортирует raw context, `StoreApi`, mutable singleton, persistence key или concrete adapter. Экспортируй domain/technical commands, selectors и access hooks. Каждый mutable export требует реального внешнего consumer и отдельного обоснования.
Если layout, screen или widget импортируют hooks из page composition, не смешивайте в одном public API готовую page composition и hooks для дочерних модулей: это может создать runtime-цикл.
```ts
// compositions/pages/profile/index.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'
```
## Фабрика
Business-модуль всегда экспортирует фабрику. Фабрика лежит в корне модуля (`{name}.factory.ts`), типизируется через `{Name}Factory` и возвращает публичный logic API фабрики.
Всё, что нужно внешнему коду в runtime, должно быть частью API, который возвращает фабрика.
Фабрика не возвращает React-компоненты, layouts, guards, boundaries, providers или page-level wrappers. Business-модуль не содержит React-компоненты: UI-решения домена размещаются в `compositions`, а полностью универсальные UI-компоненты — в `ui`.
Модуль без runtime-capabilities экспортирует фабрику без аргументов. Модуль с зависимостями экспортирует фабрику, принимающую `deps`: API других доменов и внешние возможности, описанные бизнес-языком. Source/query hooks, state stores, subscriptions, browser API, clock и другие runtime-механизмы также считаются dependencies. Типы всегда экспортируются напрямую через `export type`, но `import type` не разрешает протащить в business generated DTO, SDK/store/query contracts.
Runtime-сборка фабрики с реальными SDK, storage, infra-клиентами, source hooks, stores, events и browser API происходит в `compositions/business/{domain}` через отдельные adapters. Builder явно создаёт или получает scoped runtime instances без I/O, создаёт adapters поверх них и передаёт adapters factory. Конечный граф business API собирается в месте, которое владеет lifecycle: page composition, route composition, application-lifetime composition provider, request scope или test setup.
### Примеры
Подробные правила фабрики см. в [Business-фабрика](./business-factory.md).
Пример runtime-сборки business-фабрик см. в [Business composition](../examples/business-composition.md).
Пример page-level Provider в React см. в [Композиция через Provider](../examples/react/composition-provider.md).
Примеры разных структур слоя `compositions` см. в [Структуры compositions](../examples/react/composition-structures.md).
## Жизненный цикл
Модуль рождается на самом низком уровне использования и поднимается выше только при реальной потребности.
- Нужен одной странице, route branch или крупной продуктовой части интерфейса → внутри соответствующего composition module.
- Нужен нескольким частям одной страницы → внутри page composition или другого общего composition scope.
- Нужен нескольким страницам или маршрутам → отдельный composition module внутри `compositions`.
- Абстрактный UI без бизнес-логики → `ui/`.
- Сценарий, product data contract, domain state, тип или доменная логика → `business/{domain}/`.
- Concrete implementation business dependency → adapter в `compositions/business/{domain}/`.
- Технический сервис → `infra/`.
- Общая чистая утилита → `shared/`.
Подъём — обычный рефакторинг в рамках задачи, а не отдельная активность.

View File

@@ -0,0 +1,229 @@
---
title: Монорепозитории
description: Правила применения SLM Design для frontend-проектов, находящихся в монорепозитории
---
# Монорепозитории
Раздел описывает, как применять SLM Design, когда фронтенд-проекты находятся в одном монорепозитории. В нём показано, что остаётся внутри приложений, что можно выносить в `packages/` и какие ограничения действуют для общих пакетов.
## Определение
**Монорепозиторий — внешний уровень организации нескольких фронтенд-приложений и общих пакетов. SLM применяется внутри каждого приложения, а frontend-пакеты, относящиеся к SLM, содержат переиспользуемый код, вынесенный из слоёв `ui`, `infra` и `shared`.**
## Базовая структура
Каждое приложение внутри `apps/` сохраняет собственную SLM-структуру в `src/`.
```text
repo/
├── apps/
│ ├── web/
│ │ └── src/
│ │ ├── app/
│ │ ├── compositions/
│ │ ├── business/
│ │ ├── infra/
│ │ ├── ui/
│ │ └── shared/
│ └── admin/
│ └── src/
│ └── ...
└── packages/
├── ui/
│ ├── button/ # самостоятельный пакет UI-модуля
│ ├── input/ # самостоятельный пакет UI-модуля
│ └── modal/ # самостоятельный пакет UI-модуля
├── infra/
│ ├── theme/ # самостоятельный пакет infra-модуля
│ ├── backend-api/ # самостоятельный пакет infra-модуля
│ └── logger/ # самостоятельный пакет infra-модуля
└── shared/ # единый shared-пакет
├── package.json
└── src/
├── lib/ # переиспользуемые утилиты
├── helpers/ # переиспользуемые helpers
└── index.ts
```
`apps/{app}/src` — граница SLM-приложения. `packages/*` находятся выше SLM и не добавляют новые архитектурные слои.
## Группировка frontend-пакетов
Frontend-пакеты, вынесенные из SLM-приложений, рекомендуется группировать по источнику кода: `ui`, `infra`, `shared`.
```text
packages/ui/* # пакеты UI-модулей
packages/infra/* # пакеты infra-модулей
packages/shared # единый shared-пакет
```
Эта группировка повторяет названия SLM-слоёв для навигации, но сама не является слоистой архитектурой внутри `packages/`. Монорепозиторий может содержать другие пакеты: tooling, конфиги, SDK, схемы, e2e и другие технические пакеты вне SLM.
## Пакет и модуль
Пакет не равен SLM-модулю: модуль — архитектурная единица внутри слоя приложения, package — единица монорепозитория для переиспользования, владения, сборки и публикации.
В `packages/ui/*` размещаются пакеты самостоятельных UI-модулей. В `packages/infra/*` размещаются пакеты самостоятельных инфраструктурных модулей. `packages/shared` устроен иначе: это единый пакет для переиспользуемых утилит, helpers и другого фундаментального кода без привязки к конкретному приложению.
```text
packages/ui/button/
packages/ui/modal/
packages/infra/theme/
packages/infra/backend-api/
packages/shared/
```
## Что остаётся в приложении
Слои `app`, `compositions` и `business` остаются внутри конкретного приложения.
```text
apps/web/src/app/
apps/web/src/compositions/
apps/web/src/business/
```
`app` привязан к фреймворку и entry points приложения.
`compositions` привязан к страницам, маршрутам и крупным продуктовым частям интерфейса конкретного приложения. Этот слой не выносится в `packages/*`, потому что отражает продуктовую сборку приложения.
`business` не выносится в `packages/*`. Домены остаются рядом со сценариями приложения, чтобы не превращать монорепозиторий в общий бизнес-слой.
## Что можно выносить
В пакеты выносится только код из `ui`, `infra` и `shared`, который уже нужен двум фронтенд-приложениям либо имеет явно зафиксированный межприложенческий ownership/reuse-контракт.
| Группа | Что выносить | Пример |
|--------|--------------|--------|
| `packages/ui/*` | Самостоятельные UI-модули без бизнес-логики | `packages/ui/button` |
| `packages/infra/*` | Самостоятельные технические сервисы | `packages/infra/backend-api` |
| `packages/shared` | Общие утилиты, helpers и фундаментальный код | `packages/shared` |
По умолчанию начинай внутри приложения. Пакет можно создать до второго consumer только при явном архитектурном решении, известном consumer и стабильном межприложенческом контракте. Абстрактная «общая природа» сама по себе недостаточна.
## UI-пакеты
В `packages/ui/*` размещаются переиспользуемые UI-модули.
```text
packages/ui/button/
├── package.json
└── src/
├── button.tsx
├── styles/
├── types/
└── index.ts
```
UI-пакет не содержит бизнес-логику, обращения к API, сценарные хуки приложения и композицию страниц.
## Infra-пакеты
В `packages/infra/*` размещаются переиспользуемые инфраструктурные модули.
```text
packages/infra/backend-api/
├── package.json
└── src/
├── clients/
├── config/
├── types/
└── index.ts
```
Привязанные к конкретному приложению сервисы остаются в `apps/{app}/src/infra`. Например, локализация со словарями конкретного продукта остаётся в приложении; общим пакетом может быть только переиспользуемый i18n-движок.
## Shared-пакет
`packages/shared` является единым пакетом.
```text
packages/shared/
├── package.json
└── src/
├── lib/
├── helpers/
└── index.ts
```
В `packages/shared` сразу выносится общий фундаментальный код: чистые функции, helpers, утилиты, независимые константы и другой код без знания о продукте.
Проектные стили, типы приложения, продуктовые конфиги и ресурсы, завязанные на одно приложение, в общий `shared` не выносятся.
## Имена пакетов и импорты
Путь импорта задаётся `name` в `package.json`, а не расположением директории.
```json
{
"name": "@repo/theme"
}
```
```text
packages/infra/theme/package.json
```
```ts
import { ThemeProvider } from '@repo/theme'
```
Пакеты должны импортироваться только через публичный API. Deep imports внутрь пакета запрещены.
```ts
// Хорошо
import { Button } from '@repo/button'
// Плохо
import { Button } from '@repo/button/src/button'
```
## Зависимости
На уровне монорепозитория приложения зависят от пакетов, а пакеты не зависят от приложений.
```text
apps → packages
packages -/→ apps
```
Внутри приложения продолжает действовать обычное направление зависимостей SLM: `app` подключает `compositions`, `compositions` связывает `business`, `infra`, `ui` и `shared`, а `business` вызывает concrete runtime-capabilities только через переданные фабрике `deps`.
Пакеты не должны нарушать природу своей группы: `packages/ui/*` не импортирует `packages/infra/*`, `packages/shared` не импортирует другие группы, а `packages/infra/*` не знает о приложениях.
## Когда не выносить
Не выносите код в пакет, если он не может быть использован в двух и более фронтенд-приложениях, зависит от роутинга или страниц, содержит бизнес-логику, отражает продуктовую композицию конкретного интерфейса или не имеет стабильного публичного API.
Фактическое использование в одном приложении допустимо для package только при зафиксированном втором consumer или внешнем ownership/reuse-контракте. Иначе сохрани локальную колокацию.
```text
# Плохо
apps/web/src/compositions/pages/home/parts/promo-section/
packages/ui/promo-section/
```
Если блок нужен только одной странице или отражает продуктовую композицию конкретного приложения, он остаётся локальным composition module.
## Конфигурационные пакеты
Конфигурационные пакеты не относятся к SLM-архитектуре.
Если в монорепозитории есть общие настройки TypeScript, ESLint, сборки или форматирования, они относятся к tooling-инфраструктуре репозитория. Такие пакеты могут находиться в `packages/`, но их структура зависит от выбранного инструментария и не участвует в правилах слоёв внутри `src/`.
## Правила
- SLM применяется внутри каждого `apps/{app}/src`.
- Frontend-пакеты, вынесенные из SLM-приложений, группируются в `packages/ui`, `packages/infra`, `packages/shared`.
- Группы `packages/ui`, `packages/infra`, `packages/shared` не являются SLM-слоями.
- В `packages/ui/*` размещаются пакеты самостоятельных UI-модулей.
- В `packages/infra/*` размещаются пакеты самостоятельных инфраструктурных модулей.
- `packages/shared` является единым пакетом для переиспользуемых утилит и helpers.
- Модуль можно размещать в package при реальном втором consumer или явном межприложенческом ownership/reuse-контракте.
- `app`, `compositions` и `business` не выносятся в пакеты.
- Проектные стили, типы приложения и продуктовые конфиги не выносятся в `packages/shared`.
- Пакеты не импортируют приложения.
- Межпакетные импорты идут только через публичный API.
- Deep imports внутрь пакетов запрещены.
- Локальная колокация важнее преждевременного выноса в `packages/*`.

View File

@@ -0,0 +1,222 @@
---
title: Сегменты
description: Сегменты внутри модуля (ui/, parts/, hooks/ и др.), назначение и правила размещения файлов
---
# Сегменты
Раздел описывает сегменты SLM: что такое сегмент, какие бывают и что в каждом из них лежит.
## Определение
**Сегмент — папка внутри модуля, которая группирует файлы по назначению. Набор сегментов не фиксирован — модуль включает только те, которые ему нужны. Команда сама определяет какие сегменты используются в проекте — архитектура даёт рекомендацию.**
## Обзор
| Сегмент | Содержимое |
|---------|------------|
| `ui/` | Презентационные компоненты родительского модуля |
| `parts/` | Вложенные модули со своими сегментами |
| `providers/` | Провайдеры модуля |
| `hooks/` | React-хуки |
| `stores/` | Сторы состояния |
| `services/` | Сценарии и операции владельца module |
| `mappers/` | Трансформация данных между форматами |
| `types/` | TypeScript-типы и интерфейсы |
| `styles/` | Стили |
| `lib/` | Утилиты и хелперы модуля |
| `config/` | Константы и конфигурация |
Сегменты не являются обязательными. Например, `providers/` нужен только модулю, который владеет провайдерами. Если provider, store или guard относится к конкретной странице или маршруту, он размещается внутри соответствующего composition module, а не в `infra` или `shared`.
Business-модули не используют `ui/` для React-компонентов. Доменный logic API живёт в factory/services/hook wrappers/mappers. React tree, guards, layouts и visual fallbacks размещаются в consumer compositions; concrete domain source hooks/stores — в adapters `compositions/business/{domain}`.
## Сегмент ui/
Презентационные компоненты родительского модуля. `ui/` содержит только компоненты, которые отвечают за отображение части интерфейса и не выходят за границы своего модуля.
Компонент в `ui/`:
- Находится в собственной папке.
- Может содержать только `{name}.tsx`, `index.ts`, `styles/`, `types/`.
- Не содержит вложенные компоненты и модули — папка компонента остаётся плоской.
- Может рендерить соседние компоненты из `ui/` своего модуля и компоненты слоя `ui`, если правила слоёв разрешают родительскому модулю импортировать `ui`.
- Не импортирует другой код проекта за пределами родительского модуля.
- Не делает внешние запросы.
- Не вызывает сценарные хуки.
- Не получает данные самостоятельно, не выбирает источник данных и не композирует данные.
- Не содержит бизнес-логику или сценарную логику.
Если UI-сущности нужно что-то за пределами этих ограничений, она должна быть оформлена как модуль. Полная граница описана в разделе [Компонент](./modules.md#компонент).
Корневой файл модуля в `ui/` не размещается. Он лежит в корне модуля: `{module-name}.tsx`.
```text
user/
├── ui/
│ ├── user-avatar/
│ │ ├── user-avatar.tsx
│ │ ├── styles/
│ │ │ └── user-avatar.module.css
│ │ ├── types/
│ │ │ └── user-avatar-props.type.ts
│ │ └── index.ts
│ └── user-status/
│ ├── user-status.tsx
│ └── index.ts
├── types/
├── hooks/
├── user.tsx
└── index.ts
```
Если UI-сущности нужна внутренняя декомпозиция, сценарная логика, получение данных или собственные архитектурные зависимости — это уже не компонент в `ui/`, а модуль в `parts/`.
## Сегмент parts/
Вложенные модули со своими сегментами. `parts/` содержит только модули: каждый элемент `parts/` — папка полноценного модуля с собственным публичным API. Отдельные `.tsx`, стили, хуки или произвольные файлы в `parts/` не размещаются.
```text
compositions/pages/home/
├── parts/
│ ├── hero-section/
│ │ ├── hero-section.tsx
│ │ ├── styles/
│ │ ├── parts/
│ │ │ └── top-banner/
│ │ │ ├── top-banner.tsx
│ │ │ └── index.ts
│ │ └── index.ts
│ └── features-section/
│ ├── features-section.tsx
│ ├── hooks/
│ └── index.ts
├── home.page.tsx
└── index.ts
```
Отличие от `ui/`: элемент `parts/` — модульная папка со своими сегментами. Элемент `ui/` — компонент родительского модуля без собственной архитектурной ответственности.
Вложенность `parts/` инкапсулирует область разработки горизонтально: каждый разработчик работает в своём `parts/`-модуле, не затрагивая чужие. Это снижает конфликты при параллельной разработке.
Если вложенный модуль обрастает своими `parts/` — это сигнал, что он достаточно самостоятельный для подъёма на уровень выше.
## Сегмент providers/
Провайдеры модуля: React Context providers, провайдеры scope-состояния, провайдеры композиции фабрик или другие обёртки, которые принадлежат модулю.
```text
providers/
├── profile-page.provider.tsx
└── profile-business-composition.provider.tsx
```
Provider размещается в том модуле, который владеет соответствующим состоянием или композицией. Page-level provider живёт в page composition module; application-level provider, завязанный на фреймворк, подключается в `app`, но реализуется в нижнем подходящем слое.
## Сегмент hooks/
React-хуки модуля. Инкапсулируют логику, состояние, подписки, побочные эффекты.
```text
hooks/
├── use-auth.hook.ts
├── use-session.hook.ts
└── use-permissions.hook.ts
```
В business-модуле `hooks/` содержит только wrappers, созданные поверх dependency hooks, переданных фабрике. Business не импортирует React state/effect APIs, SWR, TanStack Query, Apollo или другой hook runtime напрямую.
Concrete source hook реализуется adapter-ом в `compositions/business/{domain}` и возвращает business-owned result type. Business wrapper нормализует данные и заменяет source error собственной domain error.
## Сегмент stores/
Сторы состояния composition/infra/UI module. Конкретная реализация зависит от выбранного state manager (Zustand, MobX, Redux и т.д.).
```text
stores/
├── auth.store.ts
└── session.store.ts
```
Если состояние нужно всей странице, concrete store живёт в page composition module. Если состояние относится к бизнес-домену, business владеет state model, transitions и state port. Concrete Zustand/Redux/MobX adapter factory реализуется в `compositions/business/{domain}`, передаётся через `deps`, принимает initial domain state от business-фабрики и возвращает concrete port.
Для каждого store определи creator, scope, количество instances и cleanup. Module singleton допустим только для явно доказанного application/process lifetime.
## Сегмент services/
Сценарии и операции module. Содержимое зависит от слоя и владельца.
```text
services/
├── auth.service.ts
└── token.service.ts
```
Правила по слоям:
- `business/services` реализует доменные сценарии только поверх `deps` фабрики;
- `compositions/business/{domain}/adapters` реализует concrete product/runtime dependencies, а не `services/` обычной composition;
- composition `services/` может оркестрировать готовые business API и технические infra-сервисы, но не обращаться к product source напрямую;
- `infra/services` реализует технический сервис или transport;
- `ui` и `shared` не выполняют product I/O.
Business service не импортирует SDK, generated API, HTTP-клиент, storage, env, browser API, React/SWR/query runtime или concrete store напрямую.
## Сегмент mappers/
Функции трансформации данных на границе ответственности module.
```text
mappers/
├── map-user.ts
├── map-product.ts
└── map-order-to-dto.ts
```
В business-модулях mappers защищают public contract от ненадёжной runtime-границы: преобразуют `unknown` в доменную модель, отклоняют невалидные структуры и не импортируют concrete DTO SDK.
Dependency adapter может преобразовать доменные аргументы в transport payload, но не создаёт доменную модель из ответа, domain error или business fallback. Domain-to-ViewModel mapping принадлежит потребительской composition, если описывает только представление.
## Сегмент types/
TypeScript-типы и интерфейсы модуля. Доменные типы, DTO, пропсы компонентов.
```text
types/
├── user.type.ts
└── session.type.ts
```
В business-модулях `types/` содержит собственные доменные типы, `{Domain}Api`, `{Domain}Deps`, `{Domain}Factory`, dependency hook/state result types и доменные error codes. Generated DTO, SDK-типы, `StoreApi`, query-library types и типы HTTP-клиента не входят в контракт business-модуля.
## Сегмент styles/
Стили модуля. Формат зависит от выбранного подхода (CSS Modules, SCSS, CSS-in-JS и т.д.).
```text
styles/
├── auth.module.css
└── login-form.module.css
```
## Сегмент lib/
Утилиты и хелперы, специфичные для модуля. Чистые функции без побочных эффектов.
```text
lib/
├── validate-email.ts
└── format-phone.ts
```
Отличие от `shared/lib/`: здесь лежат утилиты, нужные только этому модулю. Общие утилиты — в `shared/lib/`.
## Сегмент config/
Константы и конфигурация модуля: маршруты, лимиты, дефолтные значения.
```text
config/
├── routes.ts
└── constants.ts
```

View File

@@ -0,0 +1,214 @@
---
title: Архитектурная проверка
description: Блокирующие проверки SLM перед завершением создания, рефакторинга и архитектурного ревью
---
# Архитектурная проверка
Не считай задачу завершённой только потому, что код компилируется или UI отображается. Проверь архитектурное решение, runtime-цепочку и обязательные тестовые границы.
## Проверка владельца
- У каждой ответственности один явный владелец.
- Product state и сценарии принадлежат business-домену.
- Page/route UI-state принадлежит соответствующему composition module.
- Concrete technical service принадлежит infra-модулю.
- Concrete business dependency adapter принадлежит `compositions/business/{domain}`.
- Provider находится у владельца scope, а не в generic infra-модуле.
- Код не поднят в общий слой или package без реального consumer.
## Проверка runtime-цепочки
Для каждого `{Domain}Api` проследи цепочку сборки:
```text
graph owner
→ per-domain builder
→ dependency adapters + business factory
→ {Domain}Api
→ provider/entry
```
И отдельно цепочку вызова:
```text
consumer
→ {Domain}Api
→ business scenario
→ {Domain}Deps
→ dependency adapter
→ source/runtime
```
Если отсутствует хотя бы одно необходимое звено, домен не подключён. Тип будущего API, пустой provider или неиспользуемый builder не заменяет runtime-сборку.
Для каждого route/page entry проверь, что он достигает готового composition module. `app` не должен реализовывать screen/layout/product wiring самостоятельно.
## Проверка business imports
В production-коде `business/**` не должно быть прямых runtime или type-only imports из concrete runtimes:
- `infra`, `compositions`, `app`;
- SDK/generated clients;
- SWR/TanStack Query/Apollo;
- Zustand/Redux/MobX/RxJS store runtime;
- React state/effect runtime;
- storage/browser API;
- env/event bus/clock/random implementations.
Разрешены собственные файлы, type-only business contracts, `shared` без runtime-capabilities и чистые детерминированные библиотеки.
Проверь не только import paths, но и re-export/barrel/helper, который может скрывать запрещённую зависимость.
## Проверка deps
- Каждая runtime-capability присутствует в `{Domain}Deps`.
- Контракт принадлежит business и назван доменным языком.
- В `Deps` нет client/SDK/generated operation/StoreApi/query-library types.
- Dependency hook возвращает business-owned source result.
- State dependency использует business-owned state port.
- External result имеет `unknown`, если требует runtime validation.
- Subscription возвращает cleanup.
- Cross-domain API сужен до реально используемых методов.
## Проверка adapters
- Для каждой runtime-capability есть явный adapter.
- Adapter находится в `compositions/business/{domain}`.
- Builder не содержит inline integration logic.
- Adapter не формирует domain error.
- Adapter не выполняет domain normalization.
- Adapter не выбирает business fallback.
- Private adapters отсутствуют в public `index.ts`.
- Concrete transport imports не протекают в обычные consumer compositions.
## Проверка product data
- Page/layout/screen/widget получает product data через `{Domain}Api`.
- Нет прямого вызова SDK/client/generated operation из потребительской composition.
- Нет product storage access в UI/component/composition service.
- DTO не используется как domain/view contract без business normalization.
- Один источник не имеет параллельного прямого и business-пути.
## Проверка ошибок
Для каждого public runtime operation проверь:
- rejected dependency;
- synchronous throw dependency;
- `undefined`/`null`/empty body;
- объект неправильной формы;
- source hook error;
- invalid state/storage value;
- неизвестную runtime-ошибку.
Во всех случаях наружу выходит только domain error со стабильным `code`. Source error сохраняется в `cause`, но не становится consumer contract. Technical failure и malformed response нельзя превращать в fallback; fallback разрешён только для валидного доменного исхода.
Не оставляй формулировку «domain error, если контракт это обещает». Business public contract всегда обещает только domain errors.
## Проверка state и hooks
- Business владеет domain state model, но не concrete state manager.
- Zustand/Redux/MobX store создаётся adapter-ом, не factory.
- SWR/Query hook создаётся adapter-ом, не business-модулем.
- Dependency hook является non-throwing/non-Suspense и передаёт technical error через business-owned result.
- Business wrapper вызывает dependency hook и возвращает собственный result type.
- Business wrapper преобразует error result и ошибки callbacks в domain errors.
- Store/query library types отсутствуют в public API и `Deps`.
- Определены creator, scope, количество instances и cleanup.
- Module singleton используется только при явно доказанном application/process lifetime.
- Provider не создаёт ложное впечатление владения instance, созданным на module scope.
## Проверка graph
- Per-domain builders собирают только свои фабрики.
- Graph owner назван и соответствует lifecycle.
- Домены создаются в топологическом порядке.
- Runtime-циклы отсутствуют.
- Один и тот же graph не копируется по нескольким providers без обоснования scope.
- Screen/widget не собирает graph самостоятельно.
- Provider value имеет точный тип.
- Нет `Partial<Graph>` с unchecked cast к полному graph.
- Subscription, timer, socket и другие resources имеют cleanup/dispose.
- Pending operation не может записать stale state после invalidation/unmount без явно принятой политики.
## Проверка public API
- Межмодульные импорты идут через реальный public entrypoint.
- Import alias/package export существует физически.
- Group не имеет `index.ts`.
- Business `index.ts` экспортирует runtime только factory, остальное через `export type`.
- Integration module экспортирует builder и необходимые type-only integration input contracts.
- Raw context, raw store, mutable singleton, adapter, generated operation и persistence key закрыты.
- Каждый export имеет реального внешнего consumer.
- Deep imports отсутствуют, включая tests уровня public contract.
## Проверка тестов
Business-модуль не завершён без factory-level tests.
Обязательный минимум:
1. Форма public API фабрики.
2. Happy path каждого runtime method/hook.
3. Invalid dependency response.
4. Rejected dependency.
5. Synchronous throw каждого обычного method/callback/state/lifecycle dependency. Dependency hooks проверяются отдельным non-throwing contract.
6. Domain error `code` и `cause`.
7. Порядок side effects и остановка после ошибки.
8. State transitions и concurrent calls.
`compositions/business/{domain}` не завершён без assembly tests:
1. Factory получает правильные adapters.
2. Каждый adapter вызывает нужный runtime source с правильным payload.
3. Builder не делает I/O при создании API.
4. Cross-domain API передан в правильном виде.
5. Private adapters не раскрыты public API.
6. Adapter пробрасывает source error без создания domain error.
7. Dependency hook не бросает и не использует Suspense/throw-on-error mode.
8. Lifecycle cleanup проверен, если есть subscriptions/resources.
Colocated tests обязательны для mappers, normalizers, type guards, domain errors и другой внутренней runtime-safe логики. Они дополняют, но не заменяют factory-level tests. Подробная матрица находится в [Тестировании business-модулей](../examples/business-testing.md).
Проверь наличие исполняемого test script и test runner именно в изменяемом workspace. Root task без локального script не является выполненной тестовой инфраструктурой.
## Проверка целостности репозитория
- Все imports разрешаются.
- Все упомянутые modules и public entrypoints существуют.
- Direct runtime packages объявлены в package текущего workspace.
- Старый provider/store/source path удалён после миграции, если больше не используется.
- Нет speculative scaffold с пустым graph, несуществующими доменами или placeholder contracts.
- Template исправлен, если именно он системно создаёт нарушение.
- Выполнены доступные typecheck, tests, lint/build и `git diff --check`.
## Формат архитектурного ревью
Для каждого нарушения укажи:
1. Путь и строку.
2. Нарушенный invariant.
3. Runtime или maintenance риск.
4. Минимальную корректную границу.
5. Необходимые tests.
Отделяй обязательное нарушение от необязательного улучшения. Не предлагай большую миграцию, если нарушение можно устранить локально без создания второй архитектуры.
## Финальный gate
Перед завершением ответь «да» на все вопросы:
- Архитектурная роль изменения определена?
- Владелец ответственности и state определён?
- Все runtime-capabilities проходят через правильную границу?
- Product data проходит через business API?
- Business вызывает только переданные deps и собственную детерминированную логику?
- Наружу выходят только domain errors?
- Adapters существуют и закрыты?
- Graph и lifecycle определены?
- Public API минимален и разрешим?
- Обязательные tests созданы и запущены?
- Изменение не оставило старый параллельный путь?
Если хотя бы один ответ «нет», задача не завершена.

View File

@@ -0,0 +1,390 @@
---
title: Business composition
description: Пример runtime-сборки business-фабрик в compositions/business
---
# Business composition
`compositions/business/{domain}` — composition module, который собирает конкретную business-фабрику с реальными runtime-зависимостями приложения.
Этот модуль не является бизнес-доменом. Он находится на слое `compositions`, потому что связывает `business`, `infra`, SDK, storage, browser API и другие внешние runtime-источники.
Это единственная integration-зона concrete product dependencies. Page, layout, screen и widget не импортируют SDK/client/storage напрямую и получают product data только через готовый `{Domain}Api`.
## Структура
```text
src/compositions/business/
├── auth/
│ ├── create-auth-business.ts
│ ├── create-auth-business.test.ts
│ ├── adapters/
│ │ ├── phone-auth.adapter.ts
│ │ ├── session.adapter.ts
│ │ ├── auth-session-events.adapter.ts
│ │ └── zustand-auth-state.adapter.ts
│ └── index.ts
├── user/
│ ├── create-user-business.ts
│ ├── create-user-business.test.ts
│ ├── adapters/
│ │ ├── user-profile.adapter.ts
│ │ └── user-storage.adapter.ts
│ ├── types/
│ │ └── create-user-business-deps.type.ts
│ └── index.ts
└── content/
├── create-content-business.ts
├── create-content-business.test.ts
├── adapters/
│ └── content-api.adapter.ts
└── index.ts
```
Если business-домены сгруппированы, `compositions/business` повторяет тот же относительный путь. Например: `business/app/auth` соответствует `compositions/business/app/auth`, `business/cms/content` соответствует `compositions/business/cms/content`.
Сегменты добавляются только по необходимости, но каждая concrete business dependency всегда оформляется отдельным файлом в `adapters/`. Не оставляй короткий adapter inline внутри builder.
## Ответственность
`compositions/business/{domain}` отвечает за adapter composition:
- создаёт или получает внешние клиенты из `infra`;
- отдельными adapters адаптирует SDK, API, storage, source/query hooks, state managers, events и browser API к `deps` business-модуля;
- вызывает business-фабрику;
- принимает API других business-модулей, если текущий домен зависит от них;
- экспортирует готовый `{Domain}Api` через builder-функцию;
- тестирует сборку и корректность адаптеров.
`compositions/business/{domain}` не должен содержать доменную логику. Если код описывает бизнес-правило, маппинг доменной модели, доменную ошибку или сценарий, он должен жить в соответствующем `business`-модуле.
`compositions/business/{domain}` не должен содержать React-компоненты, layouts, guards, providers и page-level wrappers. Применение logic API в React tree выполняется в обычных composition modules страниц, layouts, screens или widgets.
Builder не реализует dependencies inline. Он явно создаёт scoped runtime instances без I/O, создаёт adapters поверх них и передаёт adapters фабрике.
## Business-контракт
Business-модуль объявляет dependency contract.
```ts
// business/auth/types/auth-deps.type.ts
import type { AuthState } from './auth-state.type'
import type { VerifyPhoneCodeData } from './verify-phone-code-data.type'
export type AuthDeps = {
phoneAuth: {
requestCode: (phone: string) => Promise<unknown>
resendCode: (challengeId: string) => Promise<unknown>
verifyCode: (data: VerifyPhoneCodeData) => Promise<unknown>
}
session: {
setToken: (token?: string | null) => void
useToken: () => string | null | undefined
}
sessionEvents: {
onInvalidated: (listener: () => void) => () => void
}
state: {
create: (initialState: AuthState) => {
get: () => AuthState
set: (state: AuthState) => void
useState: () => AuthState
}
}
}
```
Business-модуль не знает, через какой SDK, backend или storage реализованы эти возможности.
## Adapter composition
Composition-адаптер знает про конкретный runtime и приводит его к business-контракту.
```ts
// compositions/business/auth/adapters/phone-auth.adapter.ts
import type { AuthDeps } from '@/business/auth'
import type { AuthApiClient } from '@/infra/backend-api'
export const createPhoneAuthAdapter = (authApiClient: AuthApiClient): AuthDeps['phoneAuth'] => ({
requestCode: (phone) => {
return authApiClient.authOtp.phoneStart({ body: { phone } })
},
resendCode: (challengeId) => {
return authApiClient.authOtp.phoneResend({ body: { challengeId } })
},
verifyCode: (data) => {
return authApiClient.authOtp.phoneVerify({ body: data })
},
})
```
Адаптер не формирует доменные ошибки и не выбирает доменный `code`. Он может вернуть результат внешнего вызова или пробросить ошибку dependency. Решение о доменном коде принимает business-модуль.
Плохо:
```ts
export const createVerifyPhoneCode = (
authApiClient: AuthApiClient,
): AuthDeps['phoneAuth']['verifyCode'] => async (data) => {
try {
return await authApiClient.authOtp.phoneVerify({ body: data })
} catch (error) {
throw new AuthBusinessError('AUTH_PHONE_CODE_VERIFY_FAILED', error)
}
}
```
Проблема: composition-адаптер начал владеть доменной ошибкой.
Хорошо:
```ts
export const createVerifyPhoneCode = (
authApiClient: AuthApiClient,
): AuthDeps['phoneAuth']['verifyCode'] => (data) => {
return authApiClient.authOtp.phoneVerify({ body: data })
}
```
## State adapter
Доменное состояние принадлежит business-контракту, но concrete state manager остаётся снаружи business.
```ts
// compositions/business/auth/adapters/zustand-auth-state.adapter.ts
import { useStore } from 'zustand'
import { createStore } from 'zustand/vanilla'
import type { AuthDeps, AuthState } from '@/business/auth'
export const authStateAdapter: AuthDeps['state'] = {
create: (initialState) => {
const store = createStore<AuthState>()(() => initialState)
return {
get: store.getState,
set: (state) => store.setState(state),
useState: () => useStore(store),
}
},
}
```
`authFactory` выбирает initial domain state и вызывает `deps.state.create(initialState)`. Он не импортирует Zustand и не раскрывает `StoreApi` через public contract. Adapter только создаёт concrete store с переданным состоянием и не выбирает доменную политику.
Для SWR, TanStack Query и других source hooks действует то же правило: adapter реализует business-owned hook contract, business wrapper нормализует `data`, заменяет source `error` собственной domain error и возвращает собственный result type.
## Lifecycle adapter
External event также передаётся через business-owned contract.
```ts
// compositions/business/auth/adapters/auth-session-events.adapter.ts
import type { AuthDeps } from '@/business/auth'
import { onAuthSessionInvalidated } from '@/infra/backend-api'
export const authSessionEventsAdapter: AuthDeps['sessionEvents'] = {
onInvalidated: onAuthSessionInvalidated,
}
```
Business API предоставляет domain-level operation `startSessionInvalidationTracking()`. Внутри business она вызывает `deps.sessionEvents.onInvalidated`, выполняет доменный state transition и возвращает cleanup wrapper. Ошибки регистрации, callback и cleanup заменяются `AuthBusinessError`.
Graph owner запускает operation после commit и вызывает возвращённый cleanup при unmount, как показано в полном provider ниже. Provider не импортирует raw infra event и не связывает его с business command самостоятельно.
## Builder одного домена
Builder собирает одну business-фабрику.
```ts
// compositions/business/auth/create-auth-business.ts
import { authFactory } from '@/business/auth'
import { createBackendApiClient } from '@/infra/backend-api'
import { authSessionEventsAdapter } from './adapters/auth-session-events.adapter'
import { authStateAdapter } from './adapters/zustand-auth-state.adapter'
import { createPhoneAuthAdapter } from './adapters/phone-auth.adapter'
import { createSessionAdapter } from './adapters/session.adapter'
export const createAuthBusiness = () => {
const authApiClient = createBackendApiClient()
return authFactory({
phoneAuth: createPhoneAuthAdapter(authApiClient),
session: createSessionAdapter(),
sessionEvents: authSessionEventsAdapter,
state: authStateAdapter,
})
}
```
Browser/application builder без cross-domain зависимостей вызывается без аргументов. Он явно создаёт runtime instances и передаёт их private adapter factories. Client/adapter constructors не выполняют I/O, не читают storage/env неявно и не запускают subscriptions; lifecycle каждого instance соответствует lifecycle builder result.
Request-scoped builder принимает отдельный `requestScopeInput` только с request data, а concrete client factory импортирует сам. Не используй application singleton для request credentials, cookies или tenant context.
## Cross-domain зависимости
Если один business-модуль зависит от API другого business-модуля, builder принимает уже собранный API.
```ts
// compositions/business/user/types/create-user-business-deps.type.ts
import type { AuthApi } from '@/business/auth'
export type CreateUserBusinessDeps = {
authApi: Pick<AuthApi, 'useAuth'>
}
```
```ts
// compositions/business/user/create-user-business.ts
import { userFactory } from '@/business/user'
import { createBackendApiClient } from '@/infra/backend-api'
import { createUserProfileAdapter } from './adapters/user-profile.adapter'
import { createUserStorageAdapter } from './adapters/user-storage.adapter'
import type { CreateUserBusinessDeps } from './types/create-user-business-deps.type'
export const createUserBusiness = (deps: CreateUserBusinessDeps) => {
const apiClient = createBackendApiClient()
return userFactory({
authApi: deps.authApi,
profile: createUserProfileAdapter(apiClient),
storage: createUserStorageAdapter(),
})
}
```
Правила:
- сначала создаются независимые домены;
- затем создаются домены, которым нужны API уже созданных доменов;
- browser/application builder deps содержат только API других business-фабрик;
- request-scoped builder отделяет cross-domain API от `requestScopeInput` с request data;
- зависимость сужается через `Pick`, если нужен один метод;
- циклические runtime-зависимости между business API запрещены;
- если появляется цикл, нужно пересмотреть границы доменов или вынести общий сценарий в отдельный домен.
## Сборка графа в месте использования
Конечный граф создаётся там, где понятен lifecycle: page provider, route composition, application-lifetime composition provider, request scope или test setup. Слой `app` только подключает готовую composition.
```tsx
// compositions/routes/profile/providers/profile-business.provider.tsx
'use client'
import { createContext, useEffect, useState, type ReactNode } from 'react'
import { createAuthBusiness } from '@/compositions/business/auth'
import { createUserBusiness } from '@/compositions/business/user'
type ProfileBusiness = {
authApi: ReturnType<typeof createAuthBusiness>
userApi: ReturnType<typeof createUserBusiness>
}
export const ProfileBusinessContext = createContext<ProfileBusiness | null>(null)
const createProfileBusiness = (): ProfileBusiness => {
const authApi = createAuthBusiness()
const userApi = createUserBusiness({ authApi })
return { authApi, userApi }
}
export const ProfileBusinessProvider = ({ children }: { children: ReactNode }) => {
const [business] = useState(createProfileBusiness)
useEffect(() => {
return business.authApi.startSessionInvalidationTracking()
}, [business.authApi])
return (
<ProfileBusinessContext.Provider value={business}>
{children}
</ProfileBusinessContext.Provider>
)
}
```
Route-level `ProfileBusinessProvider` владеет lifecycle graph. `compositions/business/*` только предоставляет чистые функции сборки. React Strict Mode может повторно вызвать lazy initializer в development, поэтому factory, builder и adapter constructors не выполняют I/O и не запускают subscriptions.
Graph owner импортирует builders, но не raw SDK/client/event bus для «досборки» конкретного домена. Если external event влияет на domain state, event subscription является частью `{Domain}Deps`; business API предоставляет domain-level lifecycle operation, которую provider запускает в effect и cleanup которой вызывает при unmount. Registration и cleanup errors преобразуются business-модулем в domain errors.
## Public API
`index.ts` composition-модуля экспортирует builder и type-only deps, если builder зависит от других business API.
```ts
// compositions/business/auth/index.ts
export { createAuthBusiness } from './create-auth-business'
```
```ts
// compositions/business/user/index.ts
export { createUserBusiness } from './create-user-business'
export type { CreateUserBusinessDeps } from './types/create-user-business-deps.type'
```
Не экспортируй из public API:
- внутренние SDK-клиенты;
- generated operation trees;
- private adapters;
- test mocks;
- helpers, которые нужны только для сборки.
Если адаптер нужен нескольким composition-модулям, сначала проверь, не является ли это infra-сервисом. Не поднимай адаптер в `shared` только ради удобного импорта.
## Как не превратить сборку в кашу
Признаки плохой сборки:
- один файл создаёт все API-клиенты, все dependency-адаптеры и все фабрики;
- рядом лежат unrelated helpers для разных доменов;
- dependency-адаптеры смешаны с domain mappers;
- Zustand/SWR/SDK logic написана прямо внутри builder;
- graph owner напрямую связывает raw infra event с business command;
- business-правила реализованы в `compositions/business`;
- public API экспортирует внутренние адаптеры;
- невозможно протестировать сборку одного домена отдельно.
Что делать вместо этого:
- один домен runtime-сборки — один composition module;
- dependency-адаптеры держать рядом с конкретной сборкой домена;
- большие dependency-адаптеры выносить в `adapters/`;
- типы сборщика выносить в `types/`, если они перестали быть локальными;
- доменные mappers оставлять в `business/{domain}/mappers`;
- тестировать сборку домена отдельно от полной сборки приложения.
## Тестирование сборки
Тесты `compositions/business/{domain}` не заменяют factory-level тесты business-модуля.
Они проверяют только composition-риск:
- правильные dependency-адаптеры переданы в фабрику;
- API другой фабрики передан в нужном виде;
- SDK operation вызывается с ожидаемым payload;
- storage/browser adapter соответствует dependency-контракту;
- state/query adapter соответствует business-owned contract и не раскрывает library types;
- сборка не делает запросы во время создания business API;
- client/adapter constructors не выполняют import-time I/O, storage access или subscriptions;
- lifecycle operation запускается владельцем scope и вызывает cleanup;
- минимальный API-клиент не тянет лишние generated-операции.
Factory-level поведение самого домена тестируется в `business/{domain}/tests/{domain}-factory`.
## Чеклист
- Runtime-сборка находится в `compositions/business/{domain}`.
- Business-модуль не импортирует реальные SDK, API или storage.
- Dependency-адаптер реализован на composition-слое.
- State/query runtime реализован adapter-ом, а не импортирован business-модулем.
- Файлы внутри модуля сборки разнесены по ответственности.
- Runtime-зависимости между доменами передаются через builder deps.
- Builder deps содержат только API других собранных business-фабрик.
- Request-scoped builder отделяет cross-domain API от `requestScopeInput`.
- Public API composition-модуля не раскрывает internal adapters.
- Builder не содержит inline integration logic.
- Lifecycle operation запускается после commit и имеет cleanup.
- Сборка покрыта тестами на корректность связки deps и адаптеров.
- Business-поведение покрыто factory-level тестами в business-модуле.

View File

@@ -0,0 +1,364 @@
---
title: Тестирование business-модулей
description: Factory-level и colocated unit-тесты для business-фабрик SLM
---
# Тестирование business-модулей
Business-модуль тестируется как доменный контракт приложения. Главный контракт business-модуля — фабрика и API, который она возвращает.
Factory-level тесты обязательны для каждого business-модуля. Assembly tests обязательны для `compositions/business/{domain}`. Внутренние colocated unit-тесты добавляются для runtime-safe логики и не заменяют проверку public API фабрики.
## Уровни тестов
Полное изменение домена проверяется на трёх границах:
1. Factory-level тесты.
2. Assembly tests dependency adapters и builder.
3. Colocated unit-тесты внутренней runtime-safe логики.
Factory-level тесты отвечают на вопрос: работает ли домен снаружи через публичный API фабрики.
Assembly tests отвечают на вопрос: правильно ли concrete runtime реализует `Deps` и передан фабрике.
Colocated unit-тесты отвечают на вопрос: надёжна ли внутренняя runtime-safe механика, на которой держится публичный контракт.
## Factory-level тесты
Размещение:
```text
business/{domain}/tests/{domain}-factory/
```
Пример:
```text
business/user/tests/user-factory/
├── public-api.test.tsx
├── use-current-user.test.tsx
├── update-current-user-profile.test.ts
├── get-stored-user-agreements.test.ts
└── testing/
└── create-user-deps.mock.ts
```
Factory-level тесты импортируют модуль только через public API.
```ts
import { userFactory } from '@/business/user'
```
Factory-level тесты не импортируют:
- `services/*`;
- `hooks/*`;
- `mappers/*`;
- `lib/*`;
- `errors/*`;
- любые deep imports business-модуля.
## Что покрывать на factory-level
Каждый runtime-метод, который возвращает фабрика, должен иметь factory-level тесты.
Обязательно проверяются:
- полный публичный runtime API фабрики;
- happy path каждого метода;
- edge cases публичного контракта;
- корректные ответы DI-зависимостей;
- пустые ответы DI-зависимостей;
- невалидные ответы DI-зависимостей;
- rejected promise от dependency;
- синхронное исключение dependency;
- преобразование внешних ошибок в доменные ошибки;
- преобразование ошибок source hooks, stores и subscriptions в доменные ошибки;
- стабильный доменный `code` для каждой ошибки public contract;
- сохранение исходной ошибки в `cause`;
- отсутствие зависимости public contract от `status`, `message`, `response` и других внешних полей ошибки;
- порядок side effects;
- отсутствие следующих side effects после ошибки;
- hooks, если фабрика возвращает hooks.
Пример:
```ts
const deps = createUserDepsMock({
profile: {
useCurrent: createCurrentUserSourceHookMock({ data: sourceUser }),
},
})
const userApi = userFactory(deps)
await userApi.updateCurrentUserProfile(data)
const result = renderHook(() => userApi.useCurrentUser())
```
Тест проверяет поведение `userApi`, а не внутреннее устройство `createUpdateCurrentUserProfile`.
## Public API тест
У каждого business-модуля должен быть тест, который фиксирует публичный runtime API фабрики.
```ts
it('returns stable user business API', () => {
const userApi = userFactory(createUserDepsMock())
expect(Object.keys(userApi).sort()).toEqual([
'getStoredUserAgreements',
'updateCurrentUserProfile',
'useCurrentUser',
])
})
```
Такой тест не заменяет сценарные тесты методов. Он только фиксирует форму API и защищает от случайного удаления или переименования методов.
## DI-границы
Любая dependency фабрики считается ненадёжной runtime-границей.
Для каждой dependency нужно проверить минимум:
- корректный успешный ответ;
- `undefined`;
- `null`;
- пустой объект;
- объект неправильной формы;
- rejected promise;
- синхронный throw обычного method/callback/state/lifecycle dependency;
- повторные вызовы;
- смену результата dependency hook или domain state.
Если dependency является callback'ом, проверяется порядок вызовов и payload.
Если dependency работает с storage, проверяются битые, устаревшие и отсутствующие данные.
## Hooks через фабрику
Hooks, которые возвращает фабрика, тестируются через factory API.
Проверяй:
- hook не делает запрос без готовых входных данных;
- dependency hook получает ожидаемые доменные аргументы;
- hook корректно обрабатывает смену dependency result;
- `data` имеет доменную модель;
- `error` имеет доменный контракт;
- loading/refresh state соответствует собственному API модуля;
- невалидный dependency response не попадает наружу как валидная доменная модель.
```ts
const useCurrent = createCurrentUserSourceHookMock({ data: sourceUser })
const deps = createUserDepsMock({ profile: { useCurrent } })
const userApi = userFactory(deps)
const { result } = renderHook(() => userApi.useCurrentUser())
```
Не тестируй hook business-модуля как отдельную публичную сущность, если он не является public API фабрики.
SWR/Query cache keys, provider wrapper и library-specific revalidation тестируются в assembly tests dependency adapter, а не в business factory tests.
## Command-сценарии
Для command-методов вроде `save`, `update`, `change`, `request`, `verify` проверяются:
- payload передаётся во внешнюю dependency в ожидаемой форме;
- входной payload не мутируется;
- пустой успешный ответ считается успехом, если body не нужен;
- ошибка dependency превращается в доменную ошибку;
- потребитель может принять решение по доменному `code`;
- side effects выполняются в правильном порядке;
- при ошибке одного шага следующие side effects не выполняются;
- повторный вызов не использует устаревшее состояние, если это важно для сценария.
Если сценарий использует несколько зависимостей, тест должен явно фиксировать порядок.
## Colocated unit-тесты
Colocated unit-тесты размещаются рядом с файлом, который владеет runtime-логикой.
```text
business/{domain}/
├── errors/
│ ├── {domain}-business.error.ts
│ └── {domain}-business.error.test.ts
├── lib/
│ ├── normalize-{entity}.ts
│ └── normalize-{entity}.test.ts
├── mappers/
│ ├── map-{entity}.ts
│ └── map-{entity}.test.ts
├── services/
│ ├── update-{entity}.service.ts
│ └── update-{entity}.service.test.ts
└── hooks/
├── use-{scenario}.hook.ts
└── use-{scenario}.hook.test.tsx
```
Colocated unit-тесты нужны для:
- mappers;
- normalizers;
- type guards;
- runtime-safe helpers;
- domain errors;
- сложных services;
- hook wrappers со сложной нормализацией domain result/error;
- storage parsers;
- fallback-логики.
Colocated unit-тесты не нужны для:
- type-only файлов;
- `index.ts` без runtime-логики;
- простых re-export файлов;
- статических config-файлов без branching;
- типов, которые проверяются typecheck'ом.
## Почему colocated тесты не заменяют factory-level
Colocated тест может доказать, что mapper работает правильно, но он не доказывает, что фабрика использует этот mapper в публичном сценарии.
Colocated тест может доказать, что service обрабатывает ошибку, но он не доказывает, что service реально попал в public API фабрики.
Factory-level тест проверяет интеграцию внутренних частей business-модуля как чёрный ящик.
Правило:
- сначала покрывай public API фабрики;
- затем усиливай покрытие colocated тестами там, где есть runtime-safe логика.
## Маппинг и runtime safety
Если business-модуль получает данные с любой dependency boundary, тестируй не только happy path. Boundary включает методы, source hooks, stores, events и browser capabilities.
Проверяй:
- отсутствующие обязательные поля;
- nullable-поля;
- поля неправильного runtime-типа;
- пустые строки;
- пробельные строки;
- странные идентификаторы;
- пустые массивы;
- не-массив вместо массива;
- частично валидные объекты;
- дефолтные значения;
- domain error для malformed response, если модель не может быть безопасно построена.
Fallback допустим только для валидного доменного исхода, например корректно представленного отсутствия данных. Rejection, synchronous throw, source error и malformed response всегда дают domain error.
## Доменные ошибки
Business-модуль никогда не отдаёт наружу сырые ошибки SDK, HTTP-клиента, source hook, store, storage или browser API. Public contract всегда содержит только собственные domain errors.
Factory-level тесты должны доказывать, что потребитель может работать только с доменным `code` и не знает форму внешней ошибки.
Проверяй:
- `error.name`;
- стабильный `error.code`;
- сохранение `cause`;
- отсутствие утечки DTO/HTTP-specific деталей в public contract;
- rejected promise от разных dependencies маппится в ожидаемый доменный код;
- синхронный throw dependency маппится в ожидаемый доменный код;
- невалидный успешный ответ превращается в доменный код ошибки;
- разные технические ошибки дают один код, если для потребителя это один бизнес-сценарий;
- разные пользовательские сценарии дают разные коды, если UI должен реагировать по-разному;
- safe fallback только для валидного доменного исхода, явно представленного dependency contract.
UI и i18n должны ориентироваться на `code`, а не на `message` внешней ошибки.
Пример factory-level проверки:
```ts
it('throws domain error code when phone code verification fails', async () => {
const externalError = new Error('Request failed with status code 500')
const authApi = authFactory({
phoneAuth: {
requestCode: vi.fn(),
resendCode: vi.fn(),
verifyCode: vi.fn().mockRejectedValue(externalError),
},
session,
sessionEvents: createAuthSessionEventsMock(),
state: createAuthStateAdapterMock(),
})
await expect(authApi.verifyPhoneCode(data)).rejects.toMatchObject({
name: 'AuthBusinessError',
code: 'AUTH_PHONE_CODE_VERIFY_FAILED',
cause: externalError,
})
})
```
Не проверяй в потребительских сценариях `externalError.message`, HTTP status или тип ошибки SDK как ожидаемое поведение business API.
## Тестирование compositions/business
Тесты `compositions/business/{domain}` проверяют сборку, а не бизнес-поведение.
Проверяй:
- builder вызывает нужную business-фабрику;
- adapter вызывает SDK operation с ожидаемым payload;
- storage/browser adapter соответствует dependency contract;
- API другого домена передаётся в нужном виде;
- builder deps содержат только API других собранных business-фабрик;
- builder/client/adapter constructors не выполняют I/O, storage/env reads или subscriptions во время создания API;
- state/query runtime находится в adapter и не импортируется business-модулем;
- dependency hook работает без Suspense/throw-on-error и возвращает technical error через result;
- adapter пробрасывает source error без создания domain error;
- lifecycle subscription возвращает и вызывает cleanup;
- public API composition-модуля не экспортирует внутренние adapters.
Не проверяй здесь domain errors, fallback'и и маппинг доменной модели. Это ответственность factory-level и colocated тестов в `business/{domain}`.
## Что не тестировать unit-тестами business-модуля
Unit-тесты business-модуля не проверяют:
- реальные REST-запросы;
- generated-клиенты;
- настоящий backend;
- Next.js routing;
- визуальную вёрстку;
- интеграцию с production storage;
- реальные внешние сервисы;
- e2e-поток целого приложения.
Эти проверки относятся к `infra`, `compositions`, integration или e2e уровням.
## Архитектурные импорты
Проверяй production import graph business-модуля. В `business/**` не должно быть runtime или type-only imports из concrete runtimes:
- SDK/client/infra;
- SWR/TanStack Query/Apollo;
- Zustand/Redux/MobX;
- React state/effect APIs;
- storage/browser/event implementations.
Factory-level test обязан импортировать фабрику через public API business-модуля. Deep import `../../{domain}.factory` не доказывает корректность public boundary.
## Чеклист
- Каждый runtime-метод фабрики имеет factory-level тесты.
- Factory-level тесты импортируют модуль только через public API.
- Public API фабрики зафиксирован отдельным тестом.
- DI-зависимости проверены на корректные, пустые, невалидные и ошибочные ответы.
- Hooks тестируются через API, который вернула фабрика.
- Command-сценарии проверяют порядок side effects.
- После ошибки не выполняются лишние side effects.
- Runtime-safe mappers, normalizers и guards покрыты colocated unit-тестами.
- Type-only файлы не покрываются бессмысленными unit-тестами.
- Доменные ошибки имеют стабильный `code`, сохраняют `cause` и не раскрывают внешнюю ошибку как public contract.
- Тесты `compositions/business/{domain}` проверяют сборку, а не бизнес-логику.
- Business production imports не содержат concrete state/query/source runtime.
- Тесты не требуют backend, network, env и долгоживущих процессов.

View File

@@ -0,0 +1,346 @@
---
title: Композиция через Provider
description: Пример page-level Provider для composition modules в React-проекте
---
# Композиция через Provider
Раздел показывает, как page composition может владеть provider, store и business composition, которые нужны layout, screen и другим composition modules.
## Идея
Page composition хранит состояние и композицию бизнес-доменов на уровне страницы. Layout и screen не импортируют друг друга: они получают доступ к page-level данным через публичный API page composition.
В примере `ProfilePageState` — только локальное UI-state страницы. Это не domain state и не product data cache. Доменное состояние описывается business-модулем, а concrete store/query hook передаётся его фабрике через adapter в `compositions/business/{domain}`.
В примере page composition владеет scope-контрактом страницы, но не экспортирует готовый `ProfilePage`, потому что layout и screen импортируют hooks из `pages/profile`. Дерево страницы собирается в отдельном entry-point composition module, который слой `app` только подключает.
## Принципы
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.
4. **Сборка снаружи business.** Business-модули не используют page-level providers. Page composition вызывает builders из `compositions/business/{domain}` и владеет lifecycle готового графа.
5. **Без deep imports.** Layout и screen импортируют hooks только из public API page composition.
## Структура модулей
```text
compositions/pages/profile/
├── profile-business-composition.ts
├── providers/
│ └── profile-page.provider.tsx
├── hooks/
│ ├── use-profile-page-store.hook.ts
│ └── use-profile-business-composition.hook.ts
├── stores/
│ └── profile-page.store.ts
├── types/
│ └── profile-page-state.type.ts
└── index.ts
compositions/layouts/profile-main/
├── profile-main.layout.tsx
└── index.ts
compositions/screens/profile/
├── profile.screen.tsx
├── ui/
│ ├── profile-error/
│ ├── profile-summary/
│ └── profile-summary-skeleton/
└── index.ts
```
## Тип состояния страницы
Файл: `compositions/pages/profile/types/profile-page-state.type.ts`.
```ts
export type ProfilePageState = {
title: string
isSidebarOpen: boolean
setSidebarOpen: (value: boolean) => void
}
```
## Store страницы
Файл: `compositions/pages/profile/stores/profile-page.store.ts`.
```ts
import { createStore } from 'zustand/vanilla'
import type { ProfilePageState } from '../types/profile-page-state.type'
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 { createAuthBusiness } from '@/compositions/business/auth'
import { createProfileBusiness } from '@/compositions/business/profile'
export const createProfileBusinessComposition = () => {
const authApi = createAuthBusiness()
const profileApi = createProfileBusiness({ authApi })
return { authApi, profileApi }
}
```
Page composition собирает нужный для страницы граф из per-domain builders. Реальные runtime-зависимости остаются в `compositions/business/{domain}`, а не внутри `business`.
Page composition не импортирует SDK, product storage, source hook или raw infra event для дополнительной настройки домена. Такое wiring принадлежит соответствующему integration module.
## Provider страницы
Файл: `compositions/pages/profile/providers/profile-page.provider.tsx`.
```tsx
'use client'
import { createContext, useEffect, useState, 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'
type ProfileBusinessComposition = ReturnType<typeof createProfileBusinessComposition>
type ProfilePageProviderValue = {
store: StoreApi<ProfilePageState>
business: ProfileBusinessComposition
}
export const ProfilePageContext = createContext<ProfilePageProviderValue | null>(null)
type Props = {
children: ReactNode
}
const createProfilePageProviderValue = (): ProfilePageProviderValue => ({
store: createProfilePageStore(),
business: createProfileBusinessComposition(),
})
export const ProfilePageProvider = ({ children }: Props) => {
const [value] = useState(createProfilePageProviderValue)
useEffect(() => {
return value.business.authApi.startSessionInvalidationTracking()
}, [value.business.authApi])
return (
<ProfilePageContext.Provider value={value}>
{children}
</ProfilePageContext.Provider>
)
}
```
Context object остаётся технической деталью provider и не должен использоваться внешними модулями напрямую. Наружу экспортируются hooks доступа.
Lazy initializer может быть повторно вызван React Strict Mode в development. Поэтому store/business constructors не выполняют I/O и не запускают subscriptions. Domain-level lifecycle operations запускаются отдельно в effect и возвращают cleanup.
## Hooks доступа
Файл: `compositions/pages/profile/hooks/use-profile-page-store.hook.ts`.
```ts
'use client'
import { useContext } from 'react'
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)
if (!ctx) {
throw new Error('useProfilePageStore must be used within ProfilePageProvider')
}
return useStore(ctx.store, selector)
}
```
Файл: `compositions/pages/profile/hooks/use-profile-business-composition.hook.ts`.
```ts
'use client'
import { useContext } from 'react'
import { ProfilePageContext } from '../providers/profile-page.provider'
export const useProfileBusinessComposition = () => {
const ctx = useContext(ProfilePageContext)
if (!ctx) {
throw new Error('useProfileBusinessComposition must be used within ProfilePageProvider')
}
return ctx.business
}
```
## Layout использует page-level store
Файл: `compositions/layouts/profile-main/profile-main.layout.tsx`.
```tsx
'use client'
import type { ReactNode } from 'react'
import { useProfilePageStore } from '@/compositions/pages/profile'
type Props = {
children: ReactNode
}
export const ProfileMainLayout = ({ children }: Props) => {
const title = useProfilePageStore((state) => state.title)
const isSidebarOpen = useProfilePageStore((state) => state.isSidebarOpen)
return (
<div data-sidebar-open={isSidebarOpen}>
<header>{title}</header>
<main>{children}</main>
</div>
)
}
```
Layout импортирует hook из public API page composition. Он не импортирует screen и не лезет во внутренние файлы `pages/profile`.
## Screen использует business composition
Файл: `compositions/screens/profile/profile.screen.tsx`.
```tsx
'use client'
import { useProfileBusinessComposition } from '@/compositions/pages/profile'
import { ProfileError } from './ui/profile-error'
import { ProfileSummary } from './ui/profile-summary'
import { ProfileSummarySkeleton } from './ui/profile-summary-skeleton'
export const ProfileScreen = () => {
const { profileApi } = useProfileBusinessComposition()
const currentProfile = profileApi.useCurrentProfile()
if (currentProfile.isLoading) {
return <ProfileSummarySkeleton />
}
if (currentProfile.error) {
return <ProfileError code={currentProfile.error.code} />
}
return currentProfile.data ? <ProfileSummary profile={currentProfile.data} /> : null
}
```
Screen получает готовые доменные API из page composition и не собирает граф фабрик самостоятельно. `ProfileSummary` — компонент screen composition, а не часть `business/profile`.
## Публичный 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.
Готовое дерево собирай в отдельном entry-point composition module. Не смешивай в одном public API готовую page composition и hooks, которые импортируют её дочерние layout/screen modules: это может создать runtime-цикл.
## Подключение в app
Entry composition связывает provider, layout и screen:
```tsx
// compositions/entries/profile/profile.entry.tsx
'use client'
import { ProfilePageProvider } from '@/compositions/pages/profile'
import { ProfileMainLayout } from '@/compositions/layouts/profile-main'
import { ProfileScreen } from '@/compositions/screens/profile'
export const ProfileEntry = () => (
<ProfilePageProvider>
<ProfileMainLayout>
<ProfileScreen />
</ProfileMainLayout>
</ProfilePageProvider>
)
```
React Router config только подключает готовый entry:
```tsx
import { ProfileEntry } from '@/compositions/entries/profile'
export const profileRoute = {
path: '/profile',
element: <ProfileEntry />,
}
```
Для Next App Router создай готовые layout/page entries в `compositions`, а framework files только подключают их.
```tsx
// compositions/entries/profile/profile-layout.entry.tsx
'use client'
import { ProfilePageProvider } from '@/compositions/pages/profile'
import { ProfileMainLayout } from '@/compositions/layouts/profile-main'
import type { ReactNode } from 'react'
export const ProfileLayoutEntry = ({ children }: { children: ReactNode }) => {
return (
<ProfilePageProvider>
<ProfileMainLayout>{children}</ProfileMainLayout>
</ProfilePageProvider>
)
}
```
```tsx
// compositions/entries/profile/profile-page.entry.tsx
'use client'
import { ProfileScreen } from '@/compositions/screens/profile'
export const ProfilePageEntry = () => <ProfileScreen />
```
```tsx
// app/(profile)/layout.tsx
import { ProfileLayoutEntry } from '@/compositions/entries/profile'
export default ProfileLayoutEntry
```
```tsx
// app/(profile)/page.tsx
import { ProfilePageEntry } from '@/compositions/entries/profile'
export default ProfilePageEntry
```
`app` размещает готовые entry composition modules по правилам фреймворка, но не реализует product tree внутри себя.

View File

@@ -0,0 +1,83 @@
---
title: Структуры compositions
description: Примеры организации слоя compositions под разные способы сборки React-приложения
---
# Структуры compositions
Раздел показывает, что SLM не фиксирует жёсткую структуру внутри `compositions`. Команда выбирает организацию под фреймворк, роутинг, CMS и продуктовую задачу.
## Базовая рекомендация
Подходит для большинства приложений, где есть явные страницы, layouts, screens и переиспользуемые композиционные блоки.
```text
src/compositions/
├── business/
│ ├── auth/
│ └── user/
├── pages/
│ ├── home/
│ └── profile/
├── layouts/
│ ├── main/
│ └── dashboard/
├── screens/
│ ├── home/
│ └── profile/
└── widgets/
├── page-heading/
└── promo-banner/
```
`business`, `pages`, `layouts`, `screens` и `widgets` здесь не являются отдельными SLM-слоями. Это группы composition modules внутри одного слоя `compositions`.
`compositions/business/{domain}` используется для runtime-сборки business-фабрик. Он не заменяет `business/{domain}` и не содержит доменную логику.
Только эта группа integration modules знает одновременно business dependency contract и concrete product runtime. Остальные composition modules являются graph owners или consumers готовых business API.
## 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`.
- `compositions/business/{domain}` отдельными adapters собирает конкретную business-фабрику с runtime-зависимостями.
- Page/layout/screen/widget получают product data только через `{Domain}Api`.
- Graph owner импортирует builders, но не raw product SDK/client/event для досборки домена.
- `business`, `infra`, `ui`, `shared` не импортируют `compositions`.
- Импорты между composition modules идут только через public API.
- Deep imports внутрь composition modules запрещены.

View File

@@ -0,0 +1,7 @@
# SLM Design
<!-- include: ../../docs/canons/decision-process.md -->
<!-- include: ../../docs/canons/business-runtime-boundary.md -->
<!-- include: ../../docs/canons/validation.md -->

View File

@@ -0,0 +1,25 @@
export default {
name: 'slm-design',
description: 'Используй при определении архитектурной роли изменения и работе по SLM Design: выборе владельца кода, слоя, модуля, scope, public API, направления зависимостей и пути продуктовых данных. Триггеры: SLM, Scoped Layered Module Design, где разместить или перенести код, business factory, DomainApi, DomainDeps, compositions/business, dependency adapter, inline adapter в builder, прямой вызов API из page/screen/hook, Zustand/SWR/SDK внутри business, domain error, deep import, module vs component, ui vs parts, page-level provider/store, business graph, Partial<Business>, event bus, subscription cleanup, lifecycle, factory-level и assembly tests, архитектура template/scaffold, перенос между apps/*/src и packages/*. НЕ используй для форматирования уже размещённого React/TypeScript/CSS-кода, реализации REST/OpenAPI-клиента, Next.js routing/rendering или механики генерации шаблона без архитектурного выбора. В смешанной задаче сначала зафиксируй SLM-границу, затем применяй профильный skill.',
source: 'SKILL.md',
linkRewrites: [
{ from: './file-atlas.md', to: './reference/canons/file-atlas.md' },
{ from: './business-runtime-boundary.md', to: '#runtime-граница-business' },
{ from: './validation.md', to: '#архитектурная-проверка' },
{ from: './layers.md', to: './reference/canons/layers.md' },
{ from: './modules.md', to: './reference/canons/modules.md' },
{ from: './business-factory.md', to: './reference/canons/business-factory.md' },
{ from: './segments.md', to: './reference/canons/segments.md' },
{ from: './monorepo.md', to: './reference/canons/monorepo.md' },
{
from: '../examples/react/composition-provider.md',
to: './reference/examples/react/composition-provider.md',
},
{
from: '../examples/react/composition-structures.md',
to: './reference/examples/react/composition-structures.md',
},
{ from: '../examples/business-composition.md', to: './reference/examples/business-composition.md' },
{ from: '../examples/business-testing.md', to: './reference/examples/business-testing.md' },
],
};