feat: доменный API

This commit is contained in:
2026-08-02 22:53:05 +03:00
parent 26b59686a5
commit b5db9e5158
43 changed files with 4128 additions and 2080 deletions

View File

@@ -1,15 +1,16 @@
# Доменные пакеты Level 2
Доменный пакет заменяет один выбранный доменный модуль Level 1. Он объединяет SLM-модули одной предметной области, но сам не является модулем, Group или публичным API.
Доменный пакет заменяет один выбранный доменный модуль Level 1. Он объединяет SLM-модули одной предметной области вокруг контролируемого Domain API, но сам не является модулем, Group или публичным API.
```text
domains/auth/
├── business/
├── assemblies/ # Обязательная Group
├── adapters/ # При наличии technical dependencies
├── api/ # Обязательный модуль
├── adapters/ # При наличии dependency ports
├── assemblies/
│ └── default/ # Обязательная штатная assembly
└── react/
├── session/
└── login-form/
└── queries/
```
Другие предметные области того же SLM root могут оставаться доменными модулями Level 1.
@@ -17,12 +18,13 @@ domains/auth/
## Основные границы
- [Доменный пакет](./domain-package.md) определяет предметную и структурную policy boundary.
- [Business](./business.md) владеет Domain API и разделяет public types, factories и необязательный deterministic runtime.
- [Фабрики, зависимости и adapters](./factory-ports-adapters.md) изолируют runtime-capabilities, включая state/query runtime и недетерминизм.
- [Assemblies](./assemblies.md) обязательны и собирают именованный граф нужных API для реального контекста.
- [Состояние и кэш](./state-cache.md) разделяет предметную власть business и технические проекции.
- [Domain API](./domain-api.md) является единственным семантическим шлюзом к данным и операциям домена.
- [Фабрики, ports и adapters](./factory-ports-adapters.md) изолируют SDK, backend, storage, runtime capabilities и provider failures.
- [Assemblies](./assemblies.md) содержат обязательную штатную сборку `default` и дополнительные production-контексты.
- [Состояние и кэш](./state-cache.md) принадлежат framework bindings или compositions: они могут хранить framework metadata и UI-state, но materialize domain payload только из значений Domain API.
- [Framework Groups](./framework-bindings.md) содержат domain-specific SLM-модули фреймворка.
- [Тестирование](./testing.md) проверяет каждого владельца через его публичную границу.
- [Realtime](./realtime.md) задаёт messages, subscriptions, correlation, resync, errors и cleanup.
- [Тестирование](./testing.md) проверяет Domain API через фабрики, adapters через port contracts и assemblies через production wiring.
- [Переход auth](./auth-example.md) показывает локальный переход с Level 1 без big-bang всего root.
- [Открытые вопросы](./open-questions.md) отделяют принятые решения от ещё не нормированных деталей.

View File

@@ -1,6 +1,6 @@
# Assemblies и среды выполнения
# Assemblies и production-граф
> Пояснение повторяемой сборки именованного графа Domain API.
> Пояснение обязательной штатной сборки, дополнительных контекстов, environment compatibility и lifecycle.
## Связанные правила
@@ -8,163 +8,257 @@
- [`SLM-L2-ASSEMBLY-R011`](../../rules/level-2.md#slm-l2-assembly-r011)
- [`SLM-L2-DEPENDENCY-A012`](../../rules/level-2.md#slm-l2-dependency-a012)
- [`SLM-L2-ENVIRONMENT-A013`](../../rules/level-2.md#slm-l2-environment-a013)
- [`SLM-L2-BUSINESS-A019`](../../rules/level-2.md#slm-l2-business-a019)
- [`SLM-L2-API-A019`](../../rules/level-2.md#slm-l2-api-a019)
- [`SLM-L2-ASSEMBLY-A020`](../../rules/level-2.md#slm-l2-assembly-a020)
- [`SLM-L2-ADAPTER-R021`](../../rules/level-2.md#slm-l2-adapter-r021)
- [`SLM-L2-BUSINESS-A022`](../../rules/level-2.md#slm-l2-business-a022)
- [`SLM-L2-API-A022`](../../rules/level-2.md#slm-l2-api-a022)
- [`SLM-L2-ASSEMBLY-R023`](../../rules/level-2.md#slm-l2-assembly-r023)
- [`SLM-L2-ASSEMBLY-R030`](../../rules/level-2.md#slm-l2-assembly-r030)
- [`SLM-L2-ASSEMBLY-R031`](../../rules/level-2.md#slm-l2-assembly-r031)
## Назначение
Assembly является SLM-модулем в Group `assemblies`. Она создаёт явный граф одного или нескольких Domain API пакета для конкретного повторяемого контекста выполнения. Каждый доменный пакет содержит минимум одну assembly.
Assembly является SLM-модулем Group `assemblies`. Она выбирает production adapters своего домена, вызывает фабрики `api` и возвращает готовый именованный граф Domain API для одного объявленного production-контекста.
```text
business/factory
├── assemblies/browser → { session: AuthSessionApi }
├── assemblies/request → { session, administration }
└── assemblies/server-action → { administration }
api/factory + adapters + cross-domain APIs
assembly
→ named Domain API graph
```
Архитектура не требует `base` или изоморфную assembly и не ограничивает их максимальное количество. Обязательная assembly соответствует реальному поддерживаемому контексту, а не существует только для заполнения структуры.
Assembly не добавляет предметные методы, модели, transitions или ошибки. Она также не владеет framework state: готовый API передаётся framework binding или composition.
Место сборки графа в `composition` может вызвать фабрики напрямую для одноразовой конфигурации. Такая сборка не отменяет обязательную assembly пакета и при наличии технических зависимостей использует публичные adapter-модули, а не inline implementations.
Импорт assembly не запускает side effects. Граф появляется только после вызова builder.
## Именованный граф API
## Обязательная default assembly
Browser assembly импортирует только фабрики и adapters нужных ей API:
Каждый пакет содержит модуль `assemblies/default`:
```text
auth/assemblies/
├── default/
│ └── index.ts
└── administration/
└── index.ts
```
`default` является штатной production-сборкой домена для одного baseline capability set, объявленного проектом. Она может быть browser-only, server-only, worker-compatible или действительно isomorphic. Имя не сообщает environment compatibility.
Пример metadata:
```yaml
assemblies:
default:
capabilities: [fetch, web-crypto]
conditions: [browser, import]
administration:
capabilities: [node, server-secrets]
conditions: [node, import]
```
Формат metadata не нормирован, но checker должен получать capability set и resolver conditions из явного project mapping, а не угадывать их по имени `default`.
## Дополнительные assemblies
Дополнительная assembly появляется, когда отличается реальная production-граница:
- набор Domain API;
- dependencies или providers;
- trust boundary;
- environment capabilities;
- scope или lifecycle;
- способ аутентификации;
- realtime guarantees.
Хорошие имена описывают контекст: `administration`, `realtime-session`, `worker`, `rsc`. Имя `rsc` оправдано только при отличающемся RSC wiring; само наличие Server Component не требует отдельной assembly.
Не создаётся assembly-заглушка с методами, бросающими `NOT_SUPPORTED`. Контекст возвращает только реально доступные API.
## Штатный граф
```ts
import type { AuthSessionApi } from '@/domains/auth/business'
import { authSessionFactory } from '@/domains/auth/business/factory'
import { createPhoneHttpAdapter } from '@/domains/auth/adapters/phone-http'
import { createBrowserSessionAdapter } from '@/domains/auth/adapters/browser-session'
import type {
AuthSessionApi,
} from '@/domains/auth/api'
export type AuthBrowserGraph = Readonly<{
import {
createAuthSessionApi,
} from '@/domains/auth/api/factory'
import {
createAuthRestAdapter,
} from '@/domains/auth/adapters/identity-rest'
export type AuthGraph = Readonly<{
session: AuthSessionApi
}>
export const createBrowserAuth = (): AuthBrowserGraph => {
const session = authSessionFactory({
phone: createPhoneHttpAdapter(),
session: createBrowserSessionAdapter(),
export const createAuth = (): AuthGraph => {
const session = createAuthSessionApi({
identity: createAuthRestAdapter(),
})
return { session }
}
```
Request assembly может собрать дополнительный API, которого нет в браузере:
Обычный graph owner импортирует только production builder:
```ts
import type {
AuthAdministrationApi,
AuthSessionApi,
} from '@/domains/auth/business'
import {
authAdministrationFactory,
authSessionFactory,
} from '@/domains/auth/business/factory'
createAuth,
} from '@/domains/auth/assemblies/default'
export type AuthRequestGraph = Readonly<{
administration: AuthAdministrationApi
session: AuthSessionApi
}>
const auth = createAuth()
```
Assembly не добавляет методы к этим контрактам и не создаёт общий `AuthApi`. Именованный объект только сообщает, какие независимые API доступны в контексте.
Factory и concrete adapter остаются construction details assembly. Тесты API и adapters импортируют соответствующие границы напрямую.
## React + Vite и Next.js
В React + Vite `default` часто использует browser adapters:
```text
assemblies/default
→ browser REST adapter
→ browser WebSocket adapter
```
В Next.js та же `default` может считаться isomorphic только при совместимом executable graph под всеми заявленными conditions. Runtime branch не делает импорт безопасным:
```ts
// Недостаточное доказательство изоморфности.
if (typeof window === 'undefined') {
return createServerAdapter()
}
return createBrowserAdapter()
```
Если server и client требуют разных concrete dependencies, используются разные assemblies или framework-specific resolver entries, проверяемые отдельно.
## RSC boundary
RSC не переносит API instance с сервера в браузер:
```text
Server Component
→ request-scoped server assembly
→ server Domain API instance
→ public serializable value
→ Client Component boundary
→ separate client assembly
→ separate client Domain API instance
```
Server Component исполняется в server scope. Его импорт Client Component является framework reference, а не обычным executable edge RSC graph. При включённом SSR или prerender сам Client Component дополнительно исполняется в отдельном server render graph, а затем в browser hydration graph; обе фазы проверяются, а browser-only effects объявляются как framework-deferred edges. Server Action создаёт и очищает собственный request graph на каждый вызов.
Через boundary не передаются functions, API objects, ports, adapters, mutable cache clients или request secrets.
## Cross-domain input
Assembly зависимого домена принимает готовый API аргументом. Cross-domain API не является adapter и не размещается в Group `adapters`:
Assembly зависимого домена принимает готовый API аргументом:
```ts
import type { AuthSessionApi } from '@/domains/auth/business'
import type { UserProfileApi } from '@/domains/user/business'
import { userProfileFactory } from '@/domains/user/business/factory'
import { createUserProfileAdapter } from '@/domains/user/adapters/profile'
import type {
AuthSessionApi,
} from '@/domains/auth/api'
export type CreateUserForRequestInput = {
auth: Pick<AuthSessionApi, 'getSnapshot'>
request: UserRequestInput
}
export type UserRequestGraph = Readonly<{
profile: UserProfileApi
export type CreateUserInput = Readonly<{
auth: Pick<AuthSessionApi, 'getSession'>
}>
export const createUserForRequest = ({
export const createUser = ({
auth,
request,
}: CreateUserForRequestInput): UserRequestGraph => {
const profile = userProfileFactory({
}: CreateUserInput): UserGraph => {
const profile = createUserProfileApi({
auth,
profile: createUserProfileAdapter(request),
profile: createUserProfileRestAdapter(),
})
return { profile }
}
```
Assembly делает только type-only импорт `AuthSessionApi`. Runtime-фабрику, assembly или instance Auth она не импортирует.
Место сборки графа выполняет runtime-связь:
Graph owner выполняет runtime-связь:
```ts
const auth = createAuthForRequest(authInput)
const user = createUserForRequest({
const auth = createAuth()
const user = createUser({
auth: auth.session,
request: userInput,
})
```
## Environment entry points
User assembly делает только type-only импорт Auth API. Она не импортирует Auth factory, adapter или assembly. Общий runtime dependency graph остаётся ацикличным.
Server assembly имеет отдельный публичный entry point и marker выбранного framework или bundler:
## Dependency-connected graph
```ts
import 'server-only'
Наличие `assemblies/default` у каждого Level 2 package не требует eager-сборки всех доменов:
export { createAuthForRequest } from './create-auth-for-request'
```text
route A
→ auth/default
→ user/default
route B
→ catalog/default
```
Server entry point не реэкспортируется через `business`, Framework Group, browser assembly или корень доменного пакета. Аналогично client-only код не достигается из server/shared entry point, если выбранная среда запрещает такую зависимость.
Graph owner вызывает только builders, необходимые текущему scope. Module-level вызов `createAuth()` и global registry готовых APIs нарушают явное владение scope.
## Lifecycle
Factory не запускает запрос, subscription, timer или другую скрытую долгоживущую работу во время создания API. Assembly может активировать технический ресурс только с явной передачей cleanup своему caller. Операция Domain API, которая запускает ресурс позже, сама возвращает cleanup:
Factory не запускает запрос, socket, subscription или timer во время создания API. Явная операция, которая позже запускает ресурс, возвращает cleanup:
```ts
const stop = auth.session.startInvalidationTracking()
const subscription = await chat.subscribe(observer)
try {
// Scope использует API.
await runScope()
} finally {
await stop()
await subscription.close()
}
```
Если assembly сама создаёт ресурс, принадлежащий всему возвращённому графу, результат дополнительно предоставляет cleanup handle:
Если assembly создаёт owned resource или получает lifecycle handle adapter-owned resource, результат предоставляет aggregate cleanup:
```ts
export type AuthRequestAssembly = Readonly<{
apis: AuthRequestGraph
export type ChatAssembly = Readonly<{
apis: ChatGraph
dispose: () => Promise<void>
}>
```
```ts
const auth = createAuthForRequest(input)
Cleanup является идемпотентным. После завершившегося cleanup resource не вызывает callbacks.
try {
return await handleRequest(auth.apis)
} finally {
await auth.dispose()
У каждого resource ровно один owner. Adapter, который сам создаёт connection или source cache, остаётся владельцем и экспортирует lifecycle handle; assembly только включает этот handle в aggregate cleanup. Если connection создаёт assembly, adapter получает borrowed capability и не закрывает её самостоятельно.
## Частичная ошибка сборки
Assembly регистрирует cleanup сразу после создания каждого owned resource и сразу после получения adapter lifecycle handle. Если следующий шаг завершается ошибкой, все зарегистрированные obligations выполняются до передачи ошибки caller-у:
```ts
export const createChat = async (): Promise<ChatAssembly> => {
const cleanups: Array<() => Promise<void>> = []
try {
const connection = await createRealtimeConnection()
cleanups.push(connection.close)
const history = createHistoryAdapter(connection)
const messages = createMessagesApi({ history })
return {
apis: { messages },
dispose: createIdempotentReverseCleanup(cleanups),
}
} catch (error) {
await runReverseCleanup(cleanups)
throw error
}
}
```
Assembly без собственного ресурса не обязана возвращать пустой `dispose`. Graph owner вызывает каждый реально предоставленный cleanup не позже завершения application, route, request или test scope.
Реализация helper не нормирована. Нормативны достижимость cleanup на failure path, обратный dependency order и отсутствие callbacks после завершения disposal.
Одноразовое место сборки, которое вызывает фабрики напрямую, подчиняется той же границе: оно не запускает скрытый ресурс в constructor и сохраняет cleanup любого созданного adapter-ресурса до завершения своего scope.
Рекомендуется делать `dispose` идемпотентным, освобождать частично созданные ресурсы при ошибке assembly и закрывать зависимые ресурсы раньше их зависимостей. Детальная политика rollback и поведения API после cleanup остаётся открытым вопросом.
Assembly без cleanup obligations возвращает только API graph и не добавляет пустой `dispose` для симметрии. Наличие adapter-owned resource с переданным handle уже является cleanup obligation, даже если assembly не считается его владельцем.

View File

@@ -1,15 +1,18 @@
# Переход домена auth с Level 1
> Проверочный пример локального перехода от доменного модуля к доменному пакету.
> Проверочный пример локального перехода от доменного модуля к пакету с Domain API, ports, adapters и default assembly.
## Связанные правила
- [`SLM-L2-DEPENDENCY-A012`](../../rules/level-2.md#slm-l2-dependency-a012)
- [`SLM-L2-DOMAIN-A026`](../../rules/level-2.md#slm-l2-domain-a026)
- [`SLM-L2-BUSINESS-A019`](../../rules/level-2.md#slm-l2-business-a019)
- [`SLM-L2-API-R005`](../../rules/level-2.md#slm-l2-api-r005)
- [`SLM-L2-API-A019`](../../rules/level-2.md#slm-l2-api-a019)
- [`SLM-L2-ASSEMBLY-A020`](../../rules/level-2.md#slm-l2-assembly-a020)
- [`SLM-L2-ADAPTER-R021`](../../rules/level-2.md#slm-l2-adapter-r021)
- [`SLM-L2-BUSINESS-A022`](../../rules/level-2.md#slm-l2-business-a022)
- [`SLM-L2-API-A022`](../../rules/level-2.md#slm-l2-api-a022)
- [`SLM-L2-DOMAIN-A026`](../../rules/level-2.md#slm-l2-domain-a026)
- [`SLM-L2-PORT-R027`](../../rules/level-2.md#slm-l2-port-r027)
- [`SLM-L2-STATE-R028`](../../rules/level-2.md#slm-l2-state-r028)
## Исходная форма Level 1
@@ -25,7 +28,7 @@ domains/
└── index.ts
```
Level 1 разрешает business-сценариям, framework hooks, state adapter и локальной сборке Auth находиться внутри одного модуля.
Level 1 разрешает external calls, framework hooks, state и Auth scenarios внутри одной module boundary.
## Целевая форма Auth
@@ -33,107 +36,169 @@ Level 1 разрешает business-сценариям, framework hooks, state a
domains/
├── auth/ # Доменный пакет Level 2
│ ├── README.md
│ ├── business/ # Один SLM-модуль
│ ├── api/ # Один SLM-модуль
│ │ ├── errors/
│ │ ├── factories/
│ │ ├── services/
│ │ ├── types/
│ │ ├── index.ts # Только public types нескольких API
│ │ ├── factory.ts # Public factories entry
│ │ ── runtime.ts # Error codes, guards, public pure runtime
│ │ ├── models/
│ │ ├── operations/
│ │ ├── ports/
│ │ ├── index.ts # Consumer-facing types
│ │ ── ports.ts # Implementer-facing types
│ │ ├── factory.ts # Domain API factories
│ │ └── runtime.ts # Guards и public pure runtime
│ ├── adapters/ # Group
│ │ ├── phone-http/ # SLM-модуль
│ │ ├── browser-session/ # SLM-модуль
│ │ ├── identity-rest/ # SLM-модуль
│ │ ├── identity-realtime/ # SLM-модуль
│ │ └── request-session/ # SLM-модуль
│ ├── assemblies/ # Обязательная Group
│ │ ├── browser/ # Только AuthSessionApi
│ │ └── request/ # Session + Administration API
│ │ ├── default/ # Штатный Auth graph
│ │ └── administration/ # Специальный trusted graph
│ └── react/ # Framework Group
│ ├── session/ # SLM-модуль
── login-form/ # SLM-модуль
│ ├── session/ # Provider готового API
── queries/ # Query/cache projection
│ └── login-form/ # Переиспользуемый domain UI
└── catalog/ # По-прежнему модуль Level 1
└── index.ts
```
Корневой `domains/auth/index.ts` удаляется. Потребители переходят на публичные API конкретных модулей. `catalog` и остальные домены не меняют форму только из-за перехода Auth.
Корневой `domains/auth/index.ts` удаляется. `catalog` и остальные домены не меняют форму только из-за перехода Auth.
## Перенос ответственности
| Исходная часть | Владелец Level 2 | Публичный путь |
|---|---|---|
| Session-сценарии и public types | `auth/business` | `auth/business` |
| Administration-сценарии и public types | `auth/business` | `auth/business` |
| Runtime-фабрики API | `auth/business` | `auth/business/factory` |
| Коды, guards и public pure-функции | `auth/business` | `auth/business/runtime` |
| Browser storage и HTTP adapters | Соответствующий adapter-модуль | `auth/adapters/*` |
| Cookies, request data и server adapters | Соответствующий adapter-модуль | `auth/adapters/*` |
| Browser graph | `auth/assemblies/browser` | `auth/assemblies/browser` |
| Request graph | `auth/assemblies/request` | `auth/assemblies/request` |
| Session operations и public models | `auth/api` | `auth/api` |
| Port contracts и failures | `auth/api` | `auth/api/ports` |
| Runtime factories | `auth/api` | `auth/api/factory` |
| Error guards и public pure-функции | `auth/api` | `auth/api/runtime` |
| REST provider mapping | `auth/adapters/identity-rest` | Adapter API для assembly |
| Realtime protocol и correlation | `auth/adapters/identity-realtime` | Adapter API для assembly |
| Request cookies mapping | `auth/adapters/request-session` | Adapter API для assembly |
| Штатный production graph | `auth/assemblies/default` | `auth/assemblies/default` |
| Trusted administration graph | `auth/assemblies/administration` | `auth/assemblies/administration` |
| Provider и session hooks | `auth/react/session` | `auth/react/session` |
| Query/cache/hydration | `auth/react/queries` | `auth/react/queries` |
| Переиспользуемая форма | `auth/react/login-form` | `auth/react/login-form` |
| Страница, текст и redirect | `compositions` | API конкретной composition |
## Новые импорты
## Domain API и port
```ts
import type {
AuthAdministrationApi,
AuthError,
AuthErrorCode,
AuthSessionApi,
} from '@/domains/auth/business'
import {
authAdministrationFactory,
authSessionFactory,
} from '@/domains/auth/business/factory'
import {
AUTH_ERROR_CODES,
isAuthError,
} from '@/domains/auth/business/runtime'
import { createPhoneHttpAdapter } from '@/domains/auth/adapters/phone-http'
import { createBrowserAuth } from '@/domains/auth/assemblies/browser'
import { AuthSessionProvider } from '@/domains/auth/react/session'
import { LoginForm } from '@/domains/auth/react/login-form'
```
## Cross-domain граф
Если User package зависит от Auth, он получает только type-only API contract и при необходимости deterministic runtime:
```ts
import type { AuthSessionApi } from '@/domains/auth/business'
import { isAuthError } from '@/domains/auth/business/runtime'
export type UserDeps = {
auth: Pick<AuthSessionApi, 'getSnapshot'>
export type AuthSessionApi = {
getSession: () => Promise<AuthSession>
requestPhoneOtp: (
command: RequestPhoneOtpCommand,
) => Promise<RequestPhoneOtpOutcome>
verifyPhoneOtp: (
command: VerifyPhoneOtpCommand,
) => Promise<AuthSession>
}
```
Место сборки создаёт instances:
```ts
const auth = createBrowserAuth()
const user = createBrowserUser({ auth: auth.session })
export type AuthIdentityPort = {
requestPhoneOtp: (
command: AuthIdentityPortCommand,
) => Promise<AuthIdentityPortResult>
verifyPhoneOtp: (
command: VerifyIdentityPortCommand,
) => Promise<VerifyIdentityPortResult>
}
```
User не импортирует Auth factory или assembly, а его React-модули не импортируют `useAuthSession` или Auth components.
REST adapter реализует этот port поверх generated client. API проверяет records и преобразует port failures в `AuthError`.
Если User остаётся модулем Level 1, runtime-связь также выполняется снаружи доменных границ. Для этого его собственный API должен иметь явную точку передачи нужного Auth behavior; иначе именно User требуется рефакторинг или переход, но несвязанные домены не затрагиваются.
## Штатная сборка
```ts
import {
createAuthSessionApi,
} from '@/domains/auth/api/factory'
import {
createIdentityRestAdapter,
} from '@/domains/auth/adapters/identity-rest'
export const createAuth = (): AuthGraph => ({
session: createAuthSessionApi({
identity: createIdentityRestAdapter(),
}),
})
```
Обычный production consumer использует:
```ts
import {
createAuth,
} from '@/domains/auth/assemblies/default'
```
Он не импортирует factory или adapter напрямую.
## Framework state
Старый `auth/stores` не переносится в `api`. React query/store projection принадлежит `auth/react/queries`:
```ts
export const useAuthSessionQuery = () => {
const api = useAuthApi()
return useQuery({
queryKey: ['auth', 'session'],
queryFn: api.getSession,
})
}
```
При Vue или другом framework та же модель и errors Domain API материализуются его собственными средствами.
## Realtime
`identity-realtime` скрывает socket protocol, operation IDs, acknowledgements и reconnect. Domain API возвращает обычный command outcome и публикует проверенные Auth events.
Если disconnect произошёл до acknowledgement, API не утверждает ложный отказ и может вернуть `AUTH_OPERATION_OUTCOME_UNKNOWN`. После gap binding получает `RESYNC_REQUIRED` и повторно вызывает `getSession()`.
## RSC
Server Component создаёт request-scoped Auth graph и передаёт Client Component только сериализуемый `AuthSession` или hydration payload. Client Component создаёт отдельный client graph; при SSR его render должен быть совместим с server prerender, а browser-only capabilities остаются в deferred effects.
`assemblies/default` используется в обоих местах только если её executable graph действительно совместим со всеми declared conditions. Иначе появляется отдельная assembly, например `auth/assemblies/rsc`.
## Cross-domain graph
Если User package зависит от Auth, он импортирует только type contract:
```ts
import type {
AuthSessionApi,
} from '@/domains/auth/api'
```
User assembly принимает готовый API:
```ts
const auth = createAuth()
const user = createUser({
auth: auth.session,
})
```
User не импортирует Auth factory, port, adapter, assembly или React hooks. Если User остаётся модулем Level 1, его public API должен иметь явную точку передачи нужного Auth behavior.
## Порядок перехода
1. Выбрать один доменный модуль и зафиксировать его внешние consumers.
2. Объявить `business` с type-only и factory entry points.
3. Разделить сценарии на независимо собираемые Domain API без дублирования методов.
4. Добавить `business/runtime`, только если внешним consumers нужны codes, guards или pure-функции.
5. Оформить каждую связную production implementation модулем `adapters/*`.
6. Создать минимум одну assembly и перенести туда повторяемый выбор API и adapters.
7. Разделить React-ответственности на модули внутри Group `react`.
8. Перенести страницы, redirects и multi-domain UI в `compositions`.
9. Перевести внешние импорты на разрешённые public paths.
10. Удалить старый root `index.ts` Auth и объявить пакетную форму checker-у.
1. Зафиксировать consumers, external sources, state, errors и lifecycle исходного Auth module.
2. Объявить consumer-facing Domain API и public models.
3. Объявить dependency ports, records и closed failures.
4. Реализовать factory и проверить Domain API через fake ports.
5. Оформить каждую production implementation модулем `adapters/*` и добавить contract tests.
6. Создать `assemblies/default` для штатного production context.
7. Добавить специальные assemblies только для реально отличающихся graphs.
8. Перенести framework state, cache и hydration в modules Group `react`.
9. Перенести страницы, redirects и multi-domain UI в `compositions`.
10. Перевести внешние imports на разрешённые public paths.
11. Обновить dependency-connected graph owners и cross-domain inputs.
12. Удалить старый root `index.ts` Auth и объявить package checker-у.
Завершённость перехода определяется только границей Auth. Наличие других доменных модулей Level 1 не является миграционным долгом.
Завершённость перехода определяется одной формой Auth и отсутствием обходных imports. Наличие других доменных модулей Level 1 не является миграционным долгом.

View File

@@ -1,218 +0,0 @@
# Модуль business
> Пояснение предметного владельца, нескольких Domain API и публичного deterministic runtime.
## Связанные правила
- [`SLM-L2-BUSINESS-R005`](../../rules/level-2.md#slm-l2-business-r005)
- [`SLM-L2-BUSINESS-R006`](../../rules/level-2.md#slm-l2-business-r006)
- [`SLM-L2-BUSINESS-A007`](../../rules/level-2.md#slm-l2-business-a007)
- [`SLM-L2-FACTORY-R008`](../../rules/level-2.md#slm-l2-factory-r008)
- [`SLM-L2-ERROR-R009`](../../rules/level-2.md#slm-l2-error-r009)
- [`SLM-L2-ERROR-R010`](../../rules/level-2.md#slm-l2-error-r010)
- [`SLM-L2-BUSINESS-R018`](../../rules/level-2.md#slm-l2-business-r018)
- [`SLM-L2-BUSINESS-A019`](../../rules/level-2.md#slm-l2-business-a019)
- [`SLM-L2-BUSINESS-A022`](../../rules/level-2.md#slm-l2-business-a022)
- [`SLM-L2-BUSINESS-R024`](../../rules/level-2.md#slm-l2-business-r024)
- [`SLM-L2-BUSINESS-R025`](../../rules/level-2.md#slm-l2-business-r025)
## Роль
`business` является обязательным SLM-модулем доменного пакета. Он владеет:
- публичными предметными сценариями;
- одним или несколькими именованными Domain API;
- одной публичной фабрикой для каждого API;
- типами явных зависимостей фабрик;
- предметными типами и детерминированными правилами;
- контрактами ожидаемых доменных ошибок;
- публичным представлением доменных данных и состояния.
Несколько API остаются частью одного модуля, пока относятся к одной связной предметной области. Они нужны не для копирования use cases, а для независимой сборки разных наборов сценариев, зависимостей и сред.
## Публичные фасеты
Один логический API модуля `business` имеет два обязательных и один необязательный entry point.
### Type-only barrel
Корневой `business/index.ts` экспортирует только типы:
```ts
export type {
AuthAdministrationApi,
AuthAdministrationDeps,
AuthAdministrationFactory,
AuthError,
AuthErrorCode,
AuthSessionApi,
AuthSessionDeps,
AuthSessionFactory,
AuthState,
} from './types'
```
Потребитель использует этот путь только через `import type`:
```ts
import type {
AuthSessionApi,
AuthState,
} from '@/domains/auth/business'
```
### Factory entry
`business/factory.ts` экспортирует только именованные runtime-фабрики:
```ts
export { authAdministrationFactory } from './factories/auth-administration.factory'
export { authSessionFactory } from './factories/auth-session.factory'
```
```ts
import {
authAdministrationFactory,
authSessionFactory,
} from '@/domains/auth/business/factory'
```
Каждому API соответствует одна фабрика. Фасет не экспортирует готовые instances, adapters или environment-specific assembly.
### Runtime entry
Необязательный `business/runtime.ts` экспортирует только публичные детерминированные значения и функции:
```ts
export {
AUTH_ERROR_CODES,
isAuthError,
} from './errors/auth-error'
export { normalizeAuthIdentifier } from './lib/normalize-auth-identifier'
```
Здесь допустимы error codes и guards, validators, value constructors, чистые продуктовые функции и immutable-константы. Все public types по-прежнему импортируются из корневого type-only barrel.
`business/runtime` не содержит:
- фабрики и готовые API instances;
- I/O или изменяемое состояние;
- state/query runtime;
- чтение clock, random, environment или platform API;
- сценарии, которым нужны runtime-зависимости.
Если внешним потребителям не нужен детерминированный runtime, файл `runtime.ts` не создаётся. Другие внешние пути внутри `business` являются deep imports.
## Несколько Domain API
```ts
export type AuthSessionApi = {
getCurrentSession: () => Promise<AuthState>
getSnapshot: () => AuthState
requestPhoneOtp: (phone: string) => Promise<void>
startInvalidationTracking: () => () => Promise<void>
verifyPhoneOtp: (code: string) => Promise<void>
}
export type AuthAdministrationApi = {
revokeUserSessions: (userId: string) => Promise<void>
}
```
`AuthSessionApi` может собираться в browser и request contexts, а `AuthAdministrationApi` только в доверенной server assembly. Browser assembly не получает метод-заглушку и не импортирует adapters административного API.
Общий `business/factory` является публичным фасетом, а не гарантией отдельного business chunk для каждой фабрики. Разделение API устраняет обязательное создание лишних adapters и instances. Если самой business-логике нужны независимо поставляемые bundle boundaries, требуется отдельный build/package mechanism за пределами текущего Level 2, а не искусственное разделение предметной области.
Один публичный сценарий принадлежит ровно одному API. Если два API постоянно требуют одинаковых методов, состояния и lifecycle, их граница пересматривается вместо дублирования.
Assembly может вернуть именованный граф нескольких API:
```ts
export type AuthBrowserGraph = Readonly<{
session: AuthSessionApi
}>
export type AuthRequestGraph = Readonly<{
administration: AuthAdministrationApi
session: AuthSessionApi
}>
```
Такой граф является составом готовых контрактов для контекста, а не новым предметным API.
## Предметная власть и состояние
Business API остаются единственной границей, определяющей предметную модель, validation, переходы и семантику результатов. Это не означает, что только `business` физически хранит байты.
Adapter или framework binding может использовать Zustand, TanStack Query, SWR, Apollo либо другой runtime для хранения и доставки значений. Такое хранилище является технической реализацией или проекцией, если:
- значения получены или проверены business API либо `business/runtime`;
- предметные переходы выполняются через business API;
- внешний DTO не становится публичной моделью напрямую;
- optimistic value создаётся или проверяется предметным владельцем;
- библиотечные cache/store types не становятся Domain API.
Подробная граница описана в [Состоянии и кэше](./state-cache.md).
## Потребители фасетов
| Потребитель | `business` | `business/factory` | `business/runtime` |
|---|---|---|---|
| Adapter своего домена | Type-only | Нет | Обычно нет |
| Assembly своего домена | Type-only | Да | При необходимости |
| Framework binding своего домена | Type-only | Нет | Да, если нужен public runtime |
| `composition` или `app` | Type-only | Для одноразовой сборки | Да |
| Код другого домена при связи с Level 2 | Type-only | Нет | Да |
| Тест | Type-only | По границе тестируемого API | По границе тестируемого владельца |
Runtime-импорт `business/runtime` другого домена остаётся архитектурным ребром и участвует в общей проверке циклов.
## Контракт ошибок
Ожидаемая ошибка имеет устойчивую безопасную readonly-форму:
```ts
export type AuthErrorCode =
| 'AUTH_PHONE_INVALID'
| 'AUTH_OTP_REQUEST_FAILED'
| 'AUTH_OTP_CODE_INVALID'
export type AuthError = Readonly<{
code: AuthErrorCode
}>
```
Если runtime-потребителям нужны constants или guard, они публикуются через `business/runtime`:
```ts
export const AUTH_ERROR_CODES = {
PHONE_INVALID: 'AUTH_PHONE_INVALID',
OTP_REQUEST_FAILED: 'AUTH_OTP_REQUEST_FAILED',
OTP_CODE_INVALID: 'AUTH_OTP_CODE_INVALID',
} as const
export const isAuthError = (value: unknown): value is AuthError => {
return isSafeCodedError(value, Object.values(AUTH_ERROR_CODES))
}
```
Guard не является обязательной частью каждого домена. При discriminated `Result` типизированному потребителю может быть достаточно error union; при exception, RPC или неизвестной runtime-границе guard часто нужен. Выбор `throw` или `Result` не меняет набор обязательных фасетов.
## Изоляция технических и чужих ошибок
Ошибки SDK, HTTP, database, storage и adapters не пересекают Domain API в форме, доступной приложению. `business` преобразует обрабатываемый технический сбой в собственный код:
```text
SDK error
→ adapter failure
→ business mapping
→ AuthErrorCode
→ приложение
```
Публичная форма не включает исходные `message`, `status`, `payload`, `cause`, SDK class или сам объект ошибки. Диагностические данные остаются во внутреннем observability-механизме.
То же относится к cross-domain вызову. Если User API использует Auth API, сбой, который становится результатом публичного сценария User, представлен собственным `UserError`. При exception-модели зависимый business может импортировать `isAuthError` и error codes из `auth/business/runtime`, после чего преобразовать ожидаемую ошибку в собственный контракт.
Ошибки программирования и нарушенные внутренние инварианты не обязаны маскироваться под ожидаемые доменные ошибки.

View File

@@ -0,0 +1,260 @@
# Модуль api и Domain API
> Пояснение семантического шлюза домена, его публичных фасетов, моделей, операций и ошибок.
## Связанные правила
- [`SLM-L2-API-R005`](../../rules/level-2.md#slm-l2-api-r005)
- [`SLM-L2-API-R006`](../../rules/level-2.md#slm-l2-api-r006)
- [`SLM-L2-API-A007`](../../rules/level-2.md#slm-l2-api-a007)
- [`SLM-L2-FACTORY-R008`](../../rules/level-2.md#slm-l2-factory-r008)
- [`SLM-L2-ERROR-R009`](../../rules/level-2.md#slm-l2-error-r009)
- [`SLM-L2-ERROR-R010`](../../rules/level-2.md#slm-l2-error-r010)
- [`SLM-L2-API-R018`](../../rules/level-2.md#slm-l2-api-r018)
- [`SLM-L2-API-A019`](../../rules/level-2.md#slm-l2-api-a019)
- [`SLM-L2-API-A022`](../../rules/level-2.md#slm-l2-api-a022)
- [`SLM-L2-API-R024`](../../rules/level-2.md#slm-l2-api-r024)
- [`SLM-L2-API-R025`](../../rules/level-2.md#slm-l2-api-r025)
- [`SLM-L2-PORT-R027`](../../rules/level-2.md#slm-l2-port-r027)
- [`SLM-L2-STATE-R028`](../../rules/level-2.md#slm-l2-state-r028)
## Роль
`api` является обязательным SLM-модулем доменного пакета. Для прикладного consumer предметная область доступна только через объявленные им Domain API, public models, outcomes и errors.
Модуль `api` владеет:
- именованными Domain API;
- публичными командами, запросами и подписками;
- public domain models;
- validation внешних и port values;
- семантикой outcomes и expected errors;
- dependency ports и port failures;
- одной фабрикой для каждого Domain API;
- необходимыми consumers deterministic guards и pure-функциями.
Модуль не владеет framework store, query cache, hydration runtime, SDK, transport client или production adapter. Он может координировать одну операцию и замыкать переданные ports, но не хранит скрытую mutable projection данных приложения между вызовами.
## Domain API как шлюз
```text
consumer command
→ Domain API
→ dependency port
→ adapter
→ provider
provider record/failure
→ adapter mapping
→ port record/failure
→ Domain API validation and semantics
→ public model/outcome/error
→ consumer
```
Framework hook, store или composition не импортирует concrete SDK и не читает предметный внешний источник напрямую. Это позволяет менять endpoint, provider и transport, сохраняя публичный контракт, пока не изменилась продуктовая семантика.
Domain API не обязан скрывать реальное предметное изменение. Если backend изменил правило, которое влияет на публичный outcome приложения, контракт домена пересматривается явно.
## Публичные фасеты
Один логический публичный API модуля `api` разделён по аудиториям.
### Consumer types
Корневой `api/index.ts` экспортирует только типы, необходимые прикладным consumers:
```ts
export type {
AuthError,
AuthErrorCode,
AuthSession,
AuthSessionApi,
RequestPhoneOtpCommand,
VerifyPhoneOtpCommand,
} from './types'
```
```ts
import type {
AuthSession,
AuthSessionApi,
} from '@/domains/auth/api'
```
Port contracts, factory dependencies, provider records и technical failures не входят в consumer-facing barrel.
### Implementer types
`api/ports.ts` существует только при наличии dependency ports и экспортирует implementer-facing contracts:
```ts
export type {
AuthIdentityPort,
AuthIdentityPortFailure,
AuthIdentityRecord,
AuthSessionApiDependencies,
} from './ports'
```
```ts
import type {
AuthIdentityPort,
} from '@/domains/auth/api/ports'
```
Этим фасетом пользуются adapters своего домена, assemblies и tests. Прикладной consumer не строит поведение по port records или failures.
### Factory entry
`api/factory.ts` экспортирует только именованные runtime-фабрики:
```ts
export {
createAuthAdministrationApi,
createAuthSessionApi,
} from './factories'
```
```ts
import {
createAuthSessionApi,
} from '@/domains/auth/api/factory'
```
В production этот фасет импортируют только assemblies текущего домена. API-тесты используют его с fake ports.
### Runtime entry
Необязательный `api/runtime.ts` экспортирует только публичный детерминированный runtime:
```ts
export {
AUTH_ERROR_CODES,
isAuthError,
projectSessionEvent,
} from './runtime'
```
Здесь допустимы error codes и guards, validators, value constructors, pure transitions, reconciliation functions и immutable-константы. Фасет не содержит фабрики, API instances, ports, I/O, subscriptions, mutable state или environment-specific код.
Если runtime-потребителей нет, файл не создаётся. Другие внешние пути внутри `api` являются deep imports.
## Stateless runtime boundary
Domain API управляет смыслом данных, а не способом их materialization. Query и command возвращают public values или outcomes, которые framework binding может сохранить в TanStack Query, Zustand, Pinia или другом runtime:
```ts
export type AuthSessionApi = {
getSession: () => Promise<AuthSession>
requestPhoneOtp: (
command: RequestPhoneOtpCommand,
) => Promise<RequestPhoneOtpOutcome>
verifyPhoneOtp: (
command: VerifyPhoneOtpCommand,
) => Promise<AuthSession>
signOut: () => Promise<void>
}
```
API не экспортирует `getState`, mutable store, QueryClient или framework subscription. Operation-local correlation, cancellation и validation допустимы; canonical cache приложения остаётся у framework consumer.
Если клиентский workflow имеет предметное состояние, framework хранит readonly value, а API определяет переход:
```ts
const nextCheckout = checkoutApi.applyCommand(
currentCheckout,
command,
)
```
Или consumer использует pure-функцию `api/runtime`. Framework не применяет предметный merge самостоятельно.
## Несколько Domain API
```ts
export type AuthSessionApi = {
getSession: () => Promise<AuthSession>
signIn: (command: SignInCommand) => Promise<AuthSession>
signOut: () => Promise<void>
}
export type AuthAdministrationApi = {
revokeUserSessions: (
command: RevokeUserSessionsCommand,
) => Promise<void>
}
```
`AuthSessionApi` и `AuthAdministrationApi` могут иметь разные ports, trust boundaries и assemblies. Один публичный сценарий принадлежит ровно одному API.
Разделение не используется только ради файловой декомпозиции. Если APIs не могут быть созданы независимо из-за общей atomicity, состояния или lifecycle, они объединяются либо получают один явно созданный shared capability через assembly.
Assembly возвращает именованный граф готовых контрактов:
```ts
export type AuthGraph = Readonly<{
session: AuthSessionApi
}>
```
Такой граф сообщает доступный набор API, но не является новым предметным API.
## Errors и failure algebra
Ожидаемая публичная ошибка имеет устойчивую readonly сериализуемую форму:
```ts
export type AuthErrorCode =
| 'AUTH_IDENTITY_INVALID'
| 'AUTH_RATE_LIMITED'
| 'AUTH_SERVICE_UNAVAILABLE'
export type AuthError = Readonly<{
code: AuthErrorCode
}>
```
Внешний failure проходит две границы:
```text
provider error
→ adapter
→ closed port failure
→ api
→ stable domain error
```
Например, adapter переводит HTTP `429`, SDK class или socket error frame в `AuthIdentityPortFailure` с типом `RATE_LIMITED`. Domain API решает, что публичная операция завершается `AUTH_RATE_LIMITED`.
Port failure не содержит raw provider object в публично доступной форме. Domain error не включает status, SDK class, source message, payload или `cause`. Диагностические данные остаются в observability-механизме adapter или infra.
Cancellation и `OUTCOME_UNKNOWN` не объединяются с обычным failure, если приложение должно различать их. Ошибка программирования и нарушенный внутренний инвариант не маскируются под expected domain error.
Выбор exception или discriminated `Result` остаётся policy проекта. Архитектурная цепочка provider failure → port failure → domain error не зависит от канала передачи.
## Недетерминизм
Clock, timer, random, ID generator и environment передаются как dependency ports:
```ts
export type AuthRuntimePort = {
now: () => number
createId: () => string
}
```
Модуль `api` не читает `Date.now`, `Math.random`, env или platform globals напрямую, если они влияют на результат операции. Это сохраняет детерминированность API-тестов и явную environment boundary.
## Потребители фасетов
| Потребитель | `api` | `api/ports` | `api/factory` | `api/runtime` |
|---|---|---|---|---|
| Adapter своего домена | Нет | Type-only | Нет | Нет |
| Assembly своего домена | Type-only | Type-only | Да | При необходимости |
| Framework binding своего домена | Type-only | Нет | Нет | При необходимости |
| `composition` или `app` | Type-only | Нет | Нет | При необходимости |
| Код другого домена | Type-only | Нет | Нет | При необходимости |
| API-тест | Type-only | Type-only | Да | По тестируемой границе |
Прикладной production graph создаётся assemblies. `app`, compositions и framework bindings не импортируют factory или concrete adapters.

View File

@@ -7,17 +7,17 @@
- [`SLM-L2-DOMAIN-R002`](../../rules/level-2.md#slm-l2-domain-r002)
- [`SLM-L2-DOMAIN-A003`](../../rules/level-2.md#slm-l2-domain-a003)
- [`SLM-L2-GROUP-R004`](../../rules/level-2.md#slm-l2-group-r004)
- [`SLM-L2-BUSINESS-R005`](../../rules/level-2.md#slm-l2-business-r005)
- [`SLM-L2-API-R005`](../../rules/level-2.md#slm-l2-api-r005)
- [`SLM-L2-DOMAIN-A026`](../../rules/level-2.md#slm-l2-domain-a026)
- [`SLM-L2-BUSINESS-A019`](../../rules/level-2.md#slm-l2-business-a019)
- [`SLM-L2-API-A019`](../../rules/level-2.md#slm-l2-api-a019)
- [`SLM-L2-ASSEMBLY-A020`](../../rules/level-2.md#slm-l2-assembly-a020)
- [`SLM-L2-ADAPTER-R021`](../../rules/level-2.md#slm-l2-adapter-r021)
## Предметная граница
Доменный пакет представляет одну связную предметную область и объединяет только принадлежащие ей SLM-модули. Пакет `auth` может содержать business-сценарии авторизации, её browser/server assemblies и React-модули, но не страницу профиля или общий database client.
Доменный пакет представляет одну связную предметную область и объединяет только принадлежащие ей SLM-модули. Пакет `auth` может содержать Domain API авторизации, production adapters её providers, assemblies и React bindings, но не страницу профиля, общий database client или multi-domain navigation policy.
Пакет не владеет исполняемой ответственностью. Конкретными API, состоянием, зависимостями и lifecycle владеют модули внутри него.
Пакет не владеет исполняемой ответственностью. Domain API, adapters, production graph, framework projection и lifecycle принадлежат конкретным модулям внутри него.
Level 2 применяется к пакету, а не ко всему SLM root. Например, `auth` может быть пакетом Level 2, пока `catalog` и `news` остаются доменными модулями Level 1.
@@ -26,9 +26,9 @@ Level 2 применяется к пакету, а не ко всему SLM root
```text
domains/auth/
├── README.md
├── business/
├── assemblies/
├── api/
├── adapters/
├── assemblies/
└── react/
```
@@ -36,17 +36,18 @@ domains/auth/
- документация;
- ownership metadata;
- декларативный manifest или декларативная конфигурация архитектурной проверки;
- обязательный модуль `business`;
- обязательная непустая Group `assemblies`;
- непустая Group `adapters`, если хотя бы одна фабрика имеет технические зависимости;
- декларативный manifest архитектурной проверки;
- объявления environment capability sets;
- обязательный модуль `api`;
- обязательная непустая Group `assemblies` с модулем `default`;
- непустая Group `adapters`, если хотя бы одна фабрика имеет dependency port;
- Framework Groups при наличии соответствующих модулей.
В корне запрещены:
- `index.ts` или другой агрегирующий executable entry point;
- runtime-файлы и side effects;
- изменяемое состояние и ресурсы lifecycle;
- изменяемое состояние и lifecycle resources;
- реэкспорт API внутренних модулей;
- page-specific компоненты или сборка нескольких доменов.
@@ -58,31 +59,33 @@ Metadata содержит только статические данные. Пр
Отсутствие root barrel намеренно:
- client- и server-entry points не агрегируются в один импорт;
- каждый модуль сохраняет отдельную ответственность и environment boundary;
- переименование публичного модуля является изменением его собственного контракта, а не скрывается пакетом;
- versioning целого publishable package остаётся за пределами Level 2.
- client, server, RSC и worker entry points не агрегируются в один импорт;
- каждый модуль сохраняет отдельные ответственность и environment boundary;
- concrete adapters не становятся частью Domain API;
- Groups не превращаются в скрытые modules;
- versioning publishable package остаётся за пределами Level 2.
## Модули и Groups
`business` размещается непосредственно в пакете. Assemblies находятся в обязательной Group `assemblies`. Production adapters являются самостоятельными модулями Group `adapters`. Framework Group называется по фреймворку: `react`, `vue` и аналогично.
```text
auth/
├── business/ # SLM-модуль
│ ├── index.ts # Только public types
│ ├── factory.ts # Public factories entry
── runtime.ts # Необязательный deterministic runtime
├── adapters/ # Group при наличии technical dependencies
│ └── phone-http/ # SLM-модуль
├── assemblies/ # Обязательная Group
│ └── browser/ # SLM-модуль
── react/ # Framework Group
├── session/ # SLM-модуль
└── login-form/ # SLM-модуль
├── api/ # SLM-модуль
│ ├── index.ts # Consumer-facing types
│ ├── ports.ts # Implementer-facing types
── factory.ts # Runtime factories
│ └── runtime.ts # Необязательный deterministic runtime
├── adapters/ # Group при наличии ports
│ ├── identity-rest/ # SLM-модуль
│ └── identity-realtime/ # SLM-модуль
── assemblies/ # Обязательная Group
├── default/ # Обязательный SLM-модуль
└── administration/ # Дополнительный SLM-модуль
└── react/ # Framework Group
├── session/ # SLM-модуль
└── queries/ # SLM-модуль
```
Groups не имеют `index.ts`. Поэтому публичными путями являются `auth/business`, `auth/business/factory`, опциональный `auth/business/runtime`, `auth/adapters/phone-http`, `auth/assemblies/browser` и `auth/react/session`, но не `auth`, `auth/adapters`, `auth/assemblies` или `auth/react`.
Groups не имеют `index.ts`. Публичными путями являются `auth/api`, `auth/api/ports`, `auth/api/factory`, опциональный `auth/api/runtime`, `auth/adapters/identity-rest`, `auth/assemblies/default` и `auth/react/session`, но не `auth`, `auth/adapters`, `auth/assemblies` или `auth/react`.
## Навигационные Groups
@@ -97,18 +100,20 @@ domains/
Такая Group отличается от Group внутри пакета допустимым составом: она содержит доменные модули, доменные пакеты и другие navigation Groups, а не внутренние модули пакета.
Одна предметная область не представляется одновременно модулем и пакетом. Переход завершается удалением старой границы именно этого домена, а не переводом всех соседних областей.
Одна предметная область не представляется одновременно модулем и пакетом. Переход завершается удалением старой границы выбранного домена, но требует обновить все его входящие imports и production composition roots.
## Границы соседних слоёв
| Ответственность | Владелец |
|---|---|
| Предметные сценарии, Domain API, доменные ошибки | `business` |
| Техническая реализация зависимости одного домена | Adapter внутри пакета |
| Сборка API для именованного контекста | Assembly внутри пакета |
| Публичные модели, Domain API, validation и domain errors | `api` |
| Контракт external capability | `api/ports` |
| Production-реализация dependency port | Adapter внутри пакета |
| Штатный production-граф | `assemblies/default` |
| Специальный production-граф | Дополнительная assembly |
| Domain-specific framework state, cache и bindings | Модуль внутри `react`, `vue` и аналогичной Group |
| Универсальный технический сервис | `infra` |
| Domain-specific framework API | Модуль внутри `react`, `vue` и аналогичной Group |
| Страница, маршрут, redirect, продуктовый текст | `compositions` |
| UI, объединяющий несколько доменов | `compositions` |
Зависимость от React сама по себе не доказывает принадлежность пакету. Решающим остаётся владелец ответственности.
Зависимость от React, WebSocket или SDK сама по себе не определяет владельца. Решающими остаются предметная ответственность, направление dependency inversion и публичная граница.

View File

@@ -1,158 +1,235 @@
# Фабрики, зависимости и adapters
# Фабрики, ports и adapters
> Пояснение границы между `business` и технической средой.
> Пояснение dependency inversion между Domain API и внешними runtime-возможностями.
## Связанные правила
- [`SLM-L2-BUSINESS-A007`](../../rules/level-2.md#slm-l2-business-a007)
- [`SLM-L2-API-A007`](../../rules/level-2.md#slm-l2-api-a007)
- [`SLM-L2-FACTORY-R008`](../../rules/level-2.md#slm-l2-factory-r008)
- [`SLM-L2-ERROR-R010`](../../rules/level-2.md#slm-l2-error-r010)
- [`SLM-L2-DEPENDENCY-A012`](../../rules/level-2.md#slm-l2-dependency-a012)
- [`SLM-L2-BUSINESS-R018`](../../rules/level-2.md#slm-l2-business-r018)
- [`SLM-L2-BUSINESS-A019`](../../rules/level-2.md#slm-l2-business-a019)
- [`SLM-L2-API-R018`](../../rules/level-2.md#slm-l2-api-r018)
- [`SLM-L2-API-A019`](../../rules/level-2.md#slm-l2-api-a019)
- [`SLM-L2-ADAPTER-R021`](../../rules/level-2.md#slm-l2-adapter-r021)
- [`SLM-L2-BUSINESS-A022`](../../rules/level-2.md#slm-l2-business-a022)
- [`SLM-L2-BUSINESS-R024`](../../rules/level-2.md#slm-l2-business-r024)
- [`SLM-L2-API-A022`](../../rules/level-2.md#slm-l2-api-a022)
- [`SLM-L2-API-R024`](../../rules/level-2.md#slm-l2-api-r024)
- [`SLM-L2-PORT-R027`](../../rules/level-2.md#slm-l2-port-r027)
## Одна фабрика на API
## Одна фабрика на Domain API
```text
явные зависимости + business factory → один Domain API
явные ports + cross-domain APIs + factory → один Domain API
```
Модуль `business` предоставляет одну именованную фабрику для каждого объявленного API. Фабрики экспортируются через общий runtime-фасет `business/factory`:
Модуль `api` предоставляет одну именованную фабрику для каждого объявленного Domain API:
```ts
import type {
AuthAdministrationApi,
AuthAdministrationDeps,
AuthSessionApi,
AuthSessionDeps,
} from '@/domains/auth/business'
} from '@/domains/auth/api'
export type AuthSessionFactory = (
deps: AuthSessionDeps,
import type {
AuthIdentityPort,
AuthRuntimePort,
} from '@/domains/auth/api/ports'
export type AuthSessionApiDependencies = Readonly<{
identity: AuthIdentityPort
runtime: AuthRuntimePort
}>
export type AuthSessionApiFactory = (
dependencies: AuthSessionApiDependencies,
) => AuthSessionApi
export type AuthAdministrationFactory = (
deps: AuthAdministrationDeps,
) => AuthAdministrationApi
```
```ts
import {
authAdministrationFactory,
authSessionFactory,
} from '@/domains/auth/business/factory'
createAuthSessionApi,
} from '@/domains/auth/api/factory'
```
Фабрика не выбирает environment, assembly или конкретную production-реализацию зависимости. Разные API могут иметь разные наборы deps и собираться независимо.
Фабрика не выбирает environment, provider, adapter или assembly. Она не открывает connection, не запускает subscription и не создаёт framework state. Разные Domain API могут иметь разные dependency sets и собираться независимо.
## Технические зависимости
## Consumer-owned ports
Зависимости фабрики описывают возможности, необходимые business-сценариям. Они не раскрывают конкретный SDK, framework hook, generated operation или объект платформы.
Port описывает capability с позиции модуля `api`, а не повторяет конкретный provider:
```ts
export type AuthPhoneDependency = {
requestCode: (phone: string) => Promise<unknown>
verifyCode: (code: string) => Promise<unknown>
export type AuthIdentityRecord = Readonly<{
expiresAt: number
subject: string
}>
export type AuthIdentityPortFailure =
| Readonly<{ type: 'FORBIDDEN' }>
| Readonly<{ type: 'RATE_LIMITED' }>
| Readonly<{ type: 'UNAVAILABLE' }>
export type AuthIdentityPortResult =
| Readonly<{
ok: true
value: AuthIdentityRecord
}>
| Readonly<{
ok: false
failure: AuthIdentityPortFailure
}>
export type AuthIdentityPort = {
signIn: (
command: AuthIdentityPortCommand,
) => Promise<AuthIdentityPortResult>
}
```
`unknown` допустим на границе непроверенного внешнего результата. `business` проверяет его до превращения в предметные данные или состояние.
Port не экспортирует generated DTO, SDK error class, HTTP status или concrete client. `AuthIdentityRecord` не становится `AuthSession`: модуль `api` проверяет record и создаёт публичную модель.
Техническими зависимостями также являются:
Не каждый port обязан использовать `Result`. Exception, callback или async iterable допустимы при project policy, если expected failures, cancellation, outcome uncertainty и cleanup остаются типизированными и проверяемыми.
- concrete state/query runtime;
- subscription и event source;
- browser, Node.js и framework capabilities;
- request data и abort signal;
- текущее время и timer;
- random и ID generator;
- environment и runtime configuration provider.
## Гранулярность ports
```ts
export type VerificationDeps = {
clock: { now: () => number }
ids: { create: () => string }
timer: { delay: (ms: number) => Promise<void> }
}
Port соответствует связной capability, а не каждому endpoint и не всему SDK:
```text
AuthIdentityPort
├── requestCode
├── verifyCode
└── revokeSession
```
`Date.now()`, `new Date()` без аргумента, `Math.random()`, `crypto.randomUUID()`, скрытый env и глобальный timer не читаются напрямую business-кодом, если влияют на поведение сценария. Детерминированная арифметика над переданным timestamp остаётся business-safe.
Допустимо разделить capability, если операции имеют разные trust boundaries, lifecycle или providers. Запрещено создавать десятки pass-through ports только ради зеркала transport operations.
Cancellation также описывается business-owned контрактом. Concrete `AbortSignal` или другой platform type остаётся внутри adapter, пока не принят отдельный общий публичный контракт.
Clock, timer, random, ID generator и environment также являются ports, если влияют на результат Domain API. Materialized framework state и query cache ports не являются: они принадлежат framework binding.
Термин и обязательная поведенческая форма технического порта пока не закреплены. В частности, позднее нужно определить cancellation, timeout, retry, concurrency и subscription semantics.
## Failure algebra
## Cross-domain API dependency
Expected failure проходит две явные стадии:
Готовый API другого домена не считается техническим adapter-портом. Это отдельная cross-domain API dependency, выраженная type-only контрактом:
```ts
import type { AuthSessionApi } from '@/domains/auth/business'
export type UserDeps = {
auth: Pick<AuthSessionApi, 'getSnapshot'>
}
```text
provider-specific failure
→ adapter mapping
→ closed port failure
→ api mapping
→ stable domain error or outcome
```
Runtime-значение место сборки графа передаёт через assembly либо напрямую зависимой business-фабрике. `user/business` не импортирует executable factory, assembly или instance Auth.
Port failure должен сохранять различия, которые нужны Domain API. Если adapter сводит `FORBIDDEN`, `CONFLICT` и `UNAVAILABLE` к `unknown`, API не может выбрать корректную публичную семантику. Если adapter передаёт HTTP status или SDK error, concrete provider протекает внутрь API.
Если exception-модели нужен runtime guard чужой ожидаемой ошибки, зависимый business может импортировать его из детерминированного `auth/business/runtime`. Этот импорт остаётся обычным ребром DAG.
Unexpected exception не обязана превращаться в expected failure. Cancellation объявляется отдельно от failure, если caller управляет ею. Disconnect или timeout после отправки неидемпотентной команды может означать `OUTCOME_UNKNOWN`, а не доказанный отказ.
## Adapter module
Adapter module соединяет явную техническую зависимость фабрики с конкретной системой:
Adapter соединяет port с concrete provider:
```text
business dependency ← adapter → SDK / query runtime / platform / request data
api-owned port ← adapter → SDK / REST / storage / platform / realtime
```
Adapter преобразует аргументы и технический результат к контракту зависимости. Он не определяет публичный метод Domain API, предметный fallback или код доменной ошибки.
Ожидаемый исходный сбой возвращается `business`, который выбирает собственный error code. Поэтому приложение не строит поведение по HTTP status, SDK error class или storage exception.
Adapter может использовать TanStack Query, Apollo, Zustand или другой state/query runtime, если реализует business-owned dependency. Library types, query keys и mutable clients не протекают в public types `business`.
## Размещение adapters
Каждая связная production-реализация является отдельным SLM-модулем в Group `adapters`:
```text
auth/adapters/
├── phone-http/
│ └── index.ts
├── browser-session/
│ └── index.ts
└── browser-runtime/
└── index.ts
```
Один adapter-модуль может реализовать несколько тесно связанных зависимостей одного technical provider. Например, `browser-runtime` может предоставить clock, timer и ID generator. Правило не требует отдельного модуля для каждого метода deps.
Adapter module имеет собственные ответственность, публичный API, environment label и тестовую границу. Group `adapters` не имеет `index.ts` и не реэкспортирует дочерние модули.
Production adapter запрещено определять:
- закрытым сегментом assembly;
- inline-функцией в `composition` или `app`;
- частью framework binding module;
- скрытой реализацией внутри `business`.
Assembly и одноразовое место сборки импортируют конкретные adapter-модули через их публичные API:
```ts
import { authSessionFactory } from '@/domains/auth/business/factory'
import { createPhoneHttpAdapter } from '@/domains/auth/adapters/phone-http'
import { createBrowserRuntimeAdapter } from '@/domains/auth/adapters/browser-runtime'
import type {
AuthIdentityPort,
} from '@/domains/auth/api/ports'
const session = authSessionFactory({
phone: createPhoneHttpAdapter(),
runtime: createBrowserRuntimeAdapter(),
export const createAuthRestAdapter = (
client: IdentityClient,
): AuthIdentityPort => ({
async signIn(command) {
try {
const response = await client.signIn({
login: command.identifier,
password: command.secret,
})
return {
ok: true,
value: {
expiresAt: response.expires_at,
subject: response.user_id,
},
}
} catch (error) {
return mapIdentityProviderFailure(error)
}
},
})
```
Если ни одна фабрика не имеет технических зависимостей, Group `adapters` не обязательна. Cross-domain API dependency не считается adapter и передаётся отдельно.
Adapter преобразует protocol arguments, records и expected failures, но не решает, какой `AuthError` получит приложение, не добавляет предметный fallback и не объявляет метод Domain API.
Локальные fake implementations в business-тестах не являются production adapters и не требуют SLM-модулей. Они существуют только внутри тестовой границы и не экспортируются в рабочий код.
## Размещение adapters
Каждая связная production-реализация является отдельным SLM-модулем Group `adapters`:
```text
auth/adapters/
├── identity-rest/
│ └── index.ts
├── identity-realtime/
│ └── index.ts
└── session-cookie/
└── index.ts
```
Один adapter-модуль может реализовать несколько тесно связанных ports одного provider. Group `adapters` не имеет `index.ts` и не реэкспортирует дочерние модули.
Production adapter запрещено определять:
- внутри `api`;
- закрытым сегментом assembly;
- inline-функцией в `app` или composition;
- частью framework binding;
- mutable registry или service locator.
Concrete adapters в production импортируют только assemblies своего домена. Adapter tests импортируют соответствующий module напрямую.
## Универсальный infra service
Adapter может использовать публичный API `infra`, если concrete technical service является универсальным для приложения:
```text
auth adapter
→ infra/http-client
→ external identity provider
```
Совпадение сигнатур `infra` API и port не переносит ownership port в `infra`. Adapter остаётся явной границей provider mapping, failures и environment. Он может быть тонким, но не добавляет фиктивные преобразования ради объёма кода.
## Cross-domain API dependency
Готовый API другого домена не является technical port:
```ts
import type {
AuthSessionApi,
} from '@/domains/auth/api'
export type UserProfileApiDependencies = Readonly<{
auth: Pick<AuthSessionApi, 'getSession'>
profile: UserProfilePort
}>
```
Graph owner создаёт Auth раньше User и передаёт `auth.session` в User assembly. User не объявляет structural copy чужого API и не создаёт bridge adapter без реального преобразования контракта.
Если expected Auth failure становится публичным outcome User, User API преобразует его в собственную `UserError`. При exception-модели он может использовать публичный guard из `auth/api/runtime`.
## Framework-only SDK
Некоторые SDK доступны только как framework Provider, hook или component, например CAPTCHA или payment element. Framework binding может получить opaque token или operation input через такой SDK и передать его команде Domain API:
```text
framework SDK
→ opaque token
→ Domain API command
→ port
→ provider adapter
```
Binding не вызывает предметную provider operation напрямую, SDK type не входит в public Domain API, а generic technical UI при необходимости разделяется между `infra`, `ui` и composition.
## Tests и fake ports
Локальные fake implementations в API-тестах не являются production adapters и не требуют SLM-модулей. Они существуют только внутри test boundary и позволяют детерминированно задавать records, failures, cancellation и realtime события.
Adapter contract tests отдельно доказывают, что concrete provider действительно реализует port. API-тест с идеальным fake не заменяет эту проверку.

View File

@@ -1,15 +1,16 @@
# Framework Groups и модули
> Пояснение domain-specific framework-кода на примере React.
> Пояснение domain-specific framework-кода, materialized state и RSC boundaries на примере React.
## Связанные правила
- [`SLM-L2-BUSINESS-R006`](../../rules/level-2.md#slm-l2-business-r006)
- [`SLM-L2-API-R006`](../../rules/level-2.md#slm-l2-api-r006)
- [`SLM-L2-DEPENDENCY-A012`](../../rules/level-2.md#slm-l2-dependency-a012)
- [`SLM-L2-FRAMEWORK-R014`](../../rules/level-2.md#slm-l2-framework-r014)
- [`SLM-L2-FRAMEWORK-R015`](../../rules/level-2.md#slm-l2-framework-r015)
- [`SLM-L2-BUSINESS-A019`](../../rules/level-2.md#slm-l2-business-a019)
- [`SLM-L2-BUSINESS-A022`](../../rules/level-2.md#slm-l2-business-a022)
- [`SLM-L2-API-A019`](../../rules/level-2.md#slm-l2-api-a019)
- [`SLM-L2-API-A022`](../../rules/level-2.md#slm-l2-api-a022)
- [`SLM-L2-STATE-R028`](../../rules/level-2.md#slm-l2-state-r028)
## Framework Group
@@ -28,44 +29,44 @@ domains/auth/react/ # Framework Group
└── index.ts
```
`react` является Group, а не модулем. У неё нет `index.ts`, состояния, реализации, lifecycle или агрегирующего API. Если пакет поддерживает Vue, рядом появляется отдельная Group `vue`.
`react` является Group, а не модулем. У неё нет `index.ts`, реализации, state, lifecycle или агрегирующего API. Если пакет поддерживает Vue, рядом появляется отдельная Group `vue`.
Каждый прямой дочерний каталог является обычным SLM-модулем со своей ответственностью, публичным API и узлом графа. Он не является вложенным модулем, потому что родительская граница `react` является Group.
Каждый прямой дочерний каталог является обычным SLM-модулем со своей ответственностью, публичным API и узлом графа.
## Framework binding module
Модуль принадлежит Framework Group, если его самостоятельная ответственность состоит в связывании одного или нескольких готовых Domain API своего домена с конкретным framework. Сам факт зависимости от framework недостаточен: framework-specific assembly остаётся в `assemblies`, а page-specific модуль остаётся в `compositions`.
Модуль принадлежит Framework Group, если его самостоятельная ответственность состоит в связывании готового Domain API своего домена с конкретным framework.
Framework binding module может:
Framework binding может:
- передавать готовые API через Provider и context;
- передавать готовый API через Provider и context;
- предоставлять domain-specific hooks;
- отображать состояние и безопасные ошибки домена;
- использовать framework-compatible state/query runtime;
- хранить framework projection в query cache или store;
- отображать public models, outcomes и domain errors;
- реализовывать SSR prefetch и client hydration;
- реализовывать переиспользуемую domain-specific форму или guard;
- связывать framework lifecycle с явными операциями Domain API.
- связывать framework lifecycle с явной realtime subscription.
Он не вызывает business-фабрику или assembly, не выбирает adapters и не создаёт новые предметные сценарии.
Он не вызывает `api/factory` или assembly, не выбирает adapters, не импортирует SDK предметного external source и не определяет новые предметные операции.
Framework binding импортирует типы и deterministic runtime через разные фасеты:
Framework binding импортирует consumer types и deterministic runtime через разные фасеты:
```ts
import type {
AuthError,
AuthSessionApi,
} from '@/domains/auth/business'
} from '@/domains/auth/api'
import {
AUTH_ERROR_CODES,
isAuthError,
} from '@/domains/auth/business/runtime'
} from '@/domains/auth/api/runtime'
```
Импорт `business/factory` запрещён: готовые API передаются модулю извне.
Импорты `api/ports`, `api/factory` и `adapters/*` запрещены.
## Модуль session
## Готовый API
`auth/react/session` может владеть Provider и hooks доступа к уже созданному `AuthSessionApi`:
`auth/react/session` может владеть Provider для уже созданного `AuthSessionApi`:
```tsx
'use client'
@@ -91,66 +92,120 @@ export const AuthSessionProvider = ({
```ts
import {
AuthSessionProvider,
useAuthSession,
useAuthApi,
} from '@/domains/auth/react/session'
```
Импорт `@/domains/auth/react` запрещён, потому что Group не имеет API.
## State/query runtime
## Query и store projection
`auth/react/queries` может использовать TanStack Query, SWR или другой React runtime поверх готового `AuthSessionApi`. Query cache остаётся framework projection, пока данные и transitions проходят через API, а optimistic values создаются или проверяются business-владельцем.
`auth/react/queries` может использовать TanStack Query, SWR, Zustand или другой React runtime поверх готового API:
```ts
export const useAuthSessionQuery = () => {
const api = useAuthSession()
const api = useAuthApi()
return useQuery({
queryKey: ['auth', 'session'],
queryFn: api.getCurrentSession,
queryFn: api.getSession,
})
}
```
Server prefetch и client hook могут быть разными модулями Framework Group с совместимыми environment markers. Общая query policy выносится в отдельный framework-модуль при наличии нескольких потребителей, а не дублируется в compositions.
Query keys, stale time, pending status и hydration принадлежат binding. Значения и ошибки поступают через Domain API. Framework types не становятся частью `AuthSessionApi`.
Подробности описаны в [Состоянии и кэше](./state-cache.md).
Framework projection не импортируется другим доменом. Cross-domain UI собирается в `compositions`.
## Модуль login-form
## Realtime binding
`auth/react/login-form` может владеть переиспользуемой формой, если она работает только с Auth API, состоянием и ошибками своего домена. Она может использовать публичный API соседнего `auth/react/session`, если зависимость остаётся ацикличной.
Binding может запускать subscription готового API в framework lifecycle:
Конкретная страница, продуктовый текст, layout, redirect и выбор маршрута принадлежат `compositions`. Domain-owned guard может решить, разрешено ли действие, но политика перехода на `/login` остаётся у route composition.
```text
component/provider scope
→ Domain API subscribe
→ verified domain events
→ query invalidation or API-owned projection
→ cleanup on scope end
```
Binding не импортирует WebSocket client и не разбирает frames. После cleanup он не принимает late callbacks. Если reconnect создаёт gap, binding обрабатывает публичный `RESYNC_REQUIRED` outcome и повторно загружает snapshot через Domain API.
## Domain-specific UI
`auth/react/login-form` может владеть переиспользуемой формой, если она работает только с Auth API, public models и errors своего домена. Она может использовать публичный API соседнего `auth/react/session`, если статический граф остаётся ацикличным.
Конкретная страница, продуктовый текст, layout, redirect и выбор маршрута принадлежат `compositions`. Domain API может вернуть `AUTH_REQUIRED`, но переход на `/login` выбирает composition.
## Framework-only SDK
SDK, доступный только через Provider, hook или component, может использоваться binding для получения opaque operation input:
```text
CAPTCHA React component
→ opaque token
→ AuthApi command
```
Binding не использует SDK для самостоятельной предметной операции, не превращает SDK response в public domain model и не экспортирует SDK type через Domain API. Если SDK предоставляет reusable technical UI без предметной модели, его generic integration может принадлежать `infra` и `ui`, а composition связывает её с доменом.
## Запрет cross-domain framework imports
Framework binding module не импортирует hooks, contexts, Providers, components или framework state другого домена:
Framework binding module не импортирует hooks, contexts, Providers, stores или components другого домена:
```ts
// Недопустимо: domains/user/react/profile
import { useAuthSession } from '@/domains/auth/react/session'
import {
useAuthSessionQuery,
} from '@/domains/auth/react/queries'
```
Cross-domain UI собирается в `compositions`:
```tsx
const session = useAuthSession()
const session = useAuthSessionQuery()
return (
<UserProfile
userId={session.userId}
canEdit={session.isAuthenticated}
userId={session.data?.userId}
/>
)
```
Передача через props является границей композиции, но не требует prop drilling внутри домена: каждый пакет может использовать собственный Provider и context. Если User business постоянно нуждается в Auth, готовый `AuthSessionApi` передаётся User factory при сборке графа, а User framework module работает уже со своим API.
Если User Domain API постоянно нуждается в Auth, готовый `AuthSessionApi` передаётся User assembly при сборке runtime-графа. User framework binding работает уже со своим API.
## SSR, RSC и client boundary
Server prefetch и client hooks могут принадлежать разным modules Framework Group с совместимыми entry points. Они не разделяют API instance или mutable cache:
```text
server binding
→ server API instance
→ prefetch
→ hydration payload
client binding
→ client API instance
→ hydrate
→ rendering
```
Server Component не передаёт API object в Client Component. Client reference и Server Action reference объявляются checker-у отдельно от executable imports. Если Client Component участвует в SSR или prerender, его server render graph проверяется отдельно от browser hydration graph; browser-only capability используется только через объявленную framework-deferred boundary.
## Публичные API
```ts
import { AuthSessionProvider } from '@/domains/auth/react/session'
import { LoginForm } from '@/domains/auth/react/login-form'
import {
AuthSessionProvider,
} from '@/domains/auth/react/session'
import {
useAuthSessionQuery,
} from '@/domains/auth/react/queries'
import {
LoginForm,
} from '@/domains/auth/react/login-form'
```
Framework Group не реэкспортирует дочерние модули. Это сохраняет независимые ответственности и не превращает `react` в скрытый корневой модуль домена.

View File

@@ -7,46 +7,69 @@
- Level 1 является общей базой, а Level 2 применяется отдельно к выбранным предметным областям.
- Доменный модуль Level 1 и доменный пакет Level 2 могут постоянно сосуществовать в одном SLM root.
- Одна предметная область имеет только одну форму.
- Корень пакета содержит только metadata, модуль `business` и допустимые Groups и не имеет executable API.
- `business` может объявлять несколько независимо собираемых Domain API и одну фабрику для каждого API.
- Публичный API `business` имеет обязательные type-only `business` и runtime `business/factory`, а также необязательный deterministic `business/runtime`.
- Приложение получает предметную модель, transitions и результаты через Domain API; technical и framework cache могут хранить и проецировать эти значения.
- Ожидаемые ошибки adapters и других доменов не пересекают API текущего домена в исходной форме.
- Каждый доменный пакет содержит минимум одну assembly; универсальная изоморфная assembly не обязательна.
- Assembly может создавать именованный граф нескольких API и не добавляет к ним сценарии.
- При наличии технических зависимостей Group `adapters` обязательна, а каждая связная production implementation принадлежит adapter-модулю.
- Runtime cross-domain imports через пакетную границу разрешены только из `business/runtime`; type-only public contracts разрешены и входят в DAG.
- Framework Group называется по фреймворку и содержит самостоятельные SLM-модули.
- Cross-domain framework state, hooks, contexts и components не импортируются.
- Clock, timer, random, ID generator и environment являются явными dependencies business.
- Assembly без собственного lifecycle-ресурса не обязана возвращать пустой `dispose`.
- Корень package содержит только metadata, модуль `api` и допустимые Groups и не имеет executable API.
- Модуль `api` является единственным семантическим шлюзом данных и операций домена.
- Публичные фасеты разделяют consumer types, implementer ports, factories и optional deterministic runtime.
- Каждый Domain API имеет одну factory; production factories импортируют только assemblies своего домена.
- Dependency ports принадлежат `api`, а production adapters являются отдельными modules Group `adapters`.
- Provider errors проходят через closed port failures и преобразуются в stable domain errors.
- Каждый package содержит `assemblies/default` для одного baseline production context.
- Имя `default` не определяет environment или isomorphic compatibility.
- Дополнительная assembly появляется только для отличающегося graph, dependencies, trust, capabilities или lifecycle.
- Framework bindings владеют state, cache, reactivity и hydration и не обращаются к предметному external source в обход Domain API.
- Server и client используют разные API instances и caches; через RSC boundary проходят только serializable values.
- Realtime transport скрыт adapter, а messages и subscriptions доступны через Domain API.
- Realtime port объявляет correlation, ACK, ordering, duplicate, reconnect, resync, cancellation и cleanup semantics.
- Assembly rollback выполняет cleanup собственных resources и полученных adapter lifecycle handles; successful aggregate cleanup идемпотентен и прекращает callbacks.
- Cross-domain Domain API является отдельной runtime dependency, а не автоматически local port.
- Runtime assembly graph остаётся ацикличным.
## Владение состоянием
## Канал ошибок
Нужно подробнее определить создание initial state, применение transitions, persistence и внешний event input. Нормативным уже является то, что предметную модель и переходы определяет `business`, а concrete state manager реализует business-owned port либо framework projection.
Нужно выбрать project-wide recommendation между exceptions и discriminated `Result`, определить форму cancellation и unexpected failures, а также сериализацию domain errors через RPC и Server Actions.
Отдельно требуется проверить SSR snapshot, hydration, concurrent rendering, reset и поведение после завершения request scope.
Архитектурная цепочка provider failure → port failure → domain error от выбора канала не зависит.
## Передача ошибок
## Port semantics
Нужно выбрать общую рекомендацию exception или discriminated `Result`, определить cancellation и unexpected failures, а также сериализацию ошибок через RPC и server actions.
Нужно определить минимальный machine-readable способ объявлять behavioral guarantees ports: timeout, retry, cancellation, idempotency, ordering, concurrency и subscription cleanup.
Структура публичных фасетов от этого решения больше не зависит: type errors публикуются через `business`, а необходимые runtime codes и guards через опциональный `business/runtime`.
Не все ports требуют все поля, но существенная для корректности semantics не должна существовать только в комментарии adapter implementation.
## Технические порты
## Environment metadata
Нужно определить, является ли consumer-owned port обязательной формой каждой технической зависимости и какие behavioral guarantees он описывает: timeout, retry, cancellation, idempotency, ordering, concurrency и subscription cleanup.
Нужно выбрать формат для capability sets, resolver conditions, executable edges, framework reference edges, dynamic imports и API-safe package declarations.
Cross-domain API dependency уже зафиксирована как отдельный вид зависимости и не зависит от этого решения.
Особенно требуется проверить Next.js RSC, Server Actions, edge runtime, workers и conditional exports внешних packages.
## Lifecycle сборки
## Runtime dependency graph
Уже зафиксировано, что явная операция, запускающая ресурс, возвращает cleanup, а assembly с собственным ресурсом предоставляет cleanup handle результата. Ещё нужно определить async cleanup, rollback частичной сборки, repeated disposal, request abort и поведение API после завершения scope.
Нужно выбрать machine-readable формат assembly inputs и создаваемых API, чтобы автоматически обнаруживать runtime cycles, скрытые static structural ports и неверный cleanup order.
## Cache hydration
До появления формата runtime graph остаётся обязательной review boundary.
Нужно проверить единый способ разделять framework-neutral cache policy, client hooks, server prefetch и serialization boundary без утечки query-library types в Domain API.
## Lifecycle
## Автоматическая проверка
Гарантии rollback, reverse cleanup, idempotence и отсутствия callbacks после disposal зафиксированы. Ещё нужно определить aggregate cleanup errors, retry failed cleanup, request abort, deadline disposal и поведение API после завершения scope.
Нужно выбрать machine-readable формат для форм домена, metadata, модулей, Groups, entry points, environment labels и запрещённых транзитивных импортов.
## Hydration payload
Нужно выбрать рекомендации по versioning, schema validation, stale persisted cache, partial hydration и защите request-specific или sensitive values.
Hydration payload остаётся framework-owned и не может содержать API instance или mutable client.
## Multiple APIs и shared capabilities
Нужно проверить рекомендуемую форму для нескольких Domain API, которые используют один shared connection, transaction coordinator или framework-neutral operation context, не перенося предметную семантику в adapter или assembly.
Если independent factories не сохраняют atomicity, APIs должны объединяться; точный критерий требует дополнительных примеров.
## Framework-only SDK
Нужно проверить React/Vue SDK, которые предоставляют capability только через Provider, hook или component: payment elements, CAPTCHA, maps и identity widgets.
Зафиксировано, что binding может передать Domain API только opaque operation input и не выполняет предметную provider operation напрямую. Требуются проверочные примеры для `infra` + `ui` + composition.
## Масштаб production graph
Нужно проверить lazy и route-scoped сборку на SLM root с десятками Level 2 packages. Импорт assemblies остаётся side-effect-free, а graph owner создаёт только dependency-connected часть graph; конкретный registry или lazy-loading mechanism пока не нормирован.

View File

@@ -0,0 +1,217 @@
# Realtime messages и subscriptions
> Пояснение Domain API поверх WebSocket, SSE, GraphQL subscriptions и provider SDK.
## Связанные правила
- [`SLM-L2-API-R006`](../../rules/level-2.md#slm-l2-api-r006)
- [`SLM-L2-ERROR-R009`](../../rules/level-2.md#slm-l2-error-r009)
- [`SLM-L2-ERROR-R010`](../../rules/level-2.md#slm-l2-error-r010)
- [`SLM-L2-ADAPTER-R021`](../../rules/level-2.md#slm-l2-adapter-r021)
- [`SLM-L2-ASSEMBLY-R023`](../../rules/level-2.md#slm-l2-assembly-r023)
- [`SLM-L2-PORT-R027`](../../rules/level-2.md#slm-l2-port-r027)
- [`SLM-L2-STATE-R028`](../../rules/level-2.md#slm-l2-state-r028)
- [`SLM-L2-REALTIME-R029`](../../rules/level-2.md#slm-l2-realtime-r029)
## Граница транспорта
Realtime transport находится внутри adapter:
```text
WebSocket / SSE / GraphQL / SDK
→ adapter
→ realtime port
→ Domain API
→ domain event/outcome/error
→ framework projection
```
Domain API не экспортирует `WebSocket`, `MessageEvent`, raw frames, SDK subscription, provider error или transport close code. Port также не должен быть generic socket API с `send(frame)` и `onMessage(frame)`: он описывает capability, необходимую конкретному домену.
## Realtime-команда
Публичная команда может выглядеть как обычный Promise независимо от транспорта:
```ts
export type ChatApi = {
sendMessage: (
command: SendMessageCommand,
) => Promise<ChatMessage>
}
```
Port возвращает типизированный technical outcome:
```ts
export type SendMessagePortFailure =
| Readonly<{ type: 'FORBIDDEN' }>
| Readonly<{ type: 'RATE_LIMITED' }>
| Readonly<{ type: 'UNAVAILABLE' }>
| Readonly<{ type: 'OUTCOME_UNKNOWN' }>
export type ChatRealtimePort = {
sendMessage: (
command: SendMessagePortCommand,
) => Promise<PortResult<ChatMessageRecord, SendMessagePortFailure>>
}
```
Domain API преобразует port result в `ChatMessage` или собственную `ChatError`. Для прикладного consumer transport остаётся незаметным.
## Correlation
`socket.send()` подтверждает только локальную отправку frame. Чтобы завершить `sendMessage()` результатом server command, protocol должен сопоставить command и acknowledgement:
```text
Domain API command
→ adapter assigns operationId
→ transport frame
→ server ACK or ERROR with operationId
→ adapter settles pending port operation
→ Domain API maps outcome
```
Adapter владеет protocol registry pending operations и не бросает error из async `onmessage`, который невозможно поймать вокруг исходного `send`. Он завершает соответствующую Promise или другой объявленный operation channel.
Correlation contract фиксирует:
- источник и scope уникальности operation ID;
- момент, когда команда считается принятой или выполненной;
- поведение при duplicate и late acknowledgement;
- timeout и cancellation;
- очистку pending operation при disconnect;
- связь command outcome с последующими domain events.
Если provider не возвращает correlation metadata, API не обещает индивидуальный результат. Такая операция является fire-and-forget, а поздний отказ публикуется отдельным domain event либо доступна только общая ошибка transport scope.
## Outcome uncertainty и idempotency
Disconnect после отправки и до acknowledgement не доказывает, что command не выполнена:
```text
frame sent
→ connection lost
→ server may have committed command
→ acknowledgement unknown
```
Port возвращает `OUTCOME_UNKNOWN`, если это различие нужно Domain API. Автоматический retry безопасен только при provider guarantee или idempotency key. Domain API не преобразует неопределённый outcome в ложное `MESSAGE_NOT_SENT`.
## Subscription
Публичная subscription предоставляет проверенные events и явный cleanup:
```ts
export type ChatEvent =
| Readonly<{
type: 'MESSAGE_CREATED'
message: ChatMessage
revision: number
}>
| Readonly<{
type: 'MESSAGE_REMOVED'
messageId: string
revision: number
}>
export type ChatSubscription = Readonly<{
close: () => Promise<void>
}>
export type ChatObserver = Readonly<{
onEvent: (event: ChatEvent) => void
onError: (error: ChatRealtimeError) => void
onStatus: (status: ChatRealtimeStatus) => void
}>
export type ChatApi = {
subscribe: (
observer: ChatObserver,
) => Promise<ChatSubscription>
}
```
Callback, async iterable или другой project-wide channel допустимы. Обязательны типизированные domain events/errors, определённый lifecycle и cleanup.
## Stable errors и statuses
Начальная ошибка подключения может завершить `subscribe()` domain error. Ошибка после успешного запуска приходит через stream channel.
Не каждый transport failure становится domain error. Adapter может восстановить соединение и опубликовать только устойчивый status:
```ts
export type ChatRealtimeStatus =
| Readonly<{ type: 'CONNECTED' }>
| Readonly<{ type: 'RECONNECTING' }>
| Readonly<{ type: 'RESYNC_REQUIRED' }>
| Readonly<{ type: 'CLOSED' }>
```
Публичные errors описывают реакции приложения, например `CHAT_REALTIME_UNAVAILABLE`, `CHAT_FORBIDDEN` или `CHAT_SESSION_EXPIRED`. Close codes, provider messages и SDK classes остаются внутри adapter.
Caller-initiated close не является domain error.
## Ordering, duplicates и resync
Realtime port явно объявляет:
- гарантируется ли порядок событий;
- возможна ли at-least-once delivery;
- кто устраняет duplicates;
- содержит ли event revision или sequence;
- как обнаруживается gap после reconnect;
- откуда загружается authoritative snapshot.
Если adapter не может доказать непрерывность, Domain API публикует `RESYNC_REQUIRED`. Framework binding invalidates projection и получает snapshot через query Domain API.
Binding не применяет raw delta к публичной модели. Если безопасный merge содержит предметную семантику, его выполняет операция Domain API или pure-функция `api/runtime`.
## Shared connection
Один adapter может multiplex несколько ports и subscriptions через физическое соединение. Connection имеет явные owner, scope, multiplicity и cleanup:
```text
assembly-owned connection
├── chat messages port
├── presence port
└── notification port
```
Cleanup отдельной subscription снимает её lease. Cleanup assembly закрывает shared connection после завершения всех принадлежащих графу operations. После awaited cleanup новые callbacks запрещены.
Если создание connection завершилось успешно, а следующий шаг assembly упал, connection закрывается на rollback path до возврата ошибки.
## Framework materialization
Framework binding выбирает техническую реакцию на domain event:
```text
MESSAGE_CREATED
→ update query cache verified full model
RESYNC_REQUIRED
→ invalidate query
→ fetch snapshot through Domain API
```
Zustand, QueryClient, Pinia или другой store не импортирует socket adapter и не интерпретирует protocol frame. Он хранит только public values, events, statuses и errors Domain API.
## SSR, RSC и workers
Browser assembly может включать realtime adapter, а request/RSC assembly — только query API. Отсутствующий realtime API не заменяется throwing stub.
Server process или worker получает отдельную assembly и scope, если ему действительно нужна долгоживущая subscription. Server Component не открывает connection, которая переживает request, без отдельного owner вне request scope.
## Тестовые границы
API-тест с fake realtime port проверяет mapping records, failures, stable errors и public events. Adapter contract test проверяет protocol frames, correlation, timeout, disconnect, duplicate acknowledgement, reconnect, resync и cleanup. Framework test проверяет materialization и invalidation. Assembly test проверяет shared connection, rollback и отсутствие callbacks после disposal.
Контрольные случаи:
- acknowledgement приходит после timeout;
- duplicate acknowledgement приходит после reconnect;
- event приходит раньше command acknowledgement;
- disconnect происходит после send и до ACK;
- unsubscribe завершается во время pending callback;
- adapter получает malformed payload;
- следующий resource assembly падает после открытия connection.

View File

@@ -1,114 +1,172 @@
# Состояние и кэш
# Состояние, cache и hydration
> Пояснение границы между предметной властью business и техническими state/query runtimes.
> Пояснение границы между семантической властью Domain API и framework-owned materialization.
## Связанные правила
- [`SLM-L2-BUSINESS-R006`](../../rules/level-2.md#slm-l2-business-r006)
- [`SLM-L2-BUSINESS-A007`](../../rules/level-2.md#slm-l2-business-a007)
- [`SLM-L2-API-R006`](../../rules/level-2.md#slm-l2-api-r006)
- [`SLM-L2-API-A007`](../../rules/level-2.md#slm-l2-api-a007)
- [`SLM-L2-DEPENDENCY-A012`](../../rules/level-2.md#slm-l2-dependency-a012)
- [`SLM-L2-FRAMEWORK-R015`](../../rules/level-2.md#slm-l2-framework-r015)
- [`SLM-L2-BUSINESS-R018`](../../rules/level-2.md#slm-l2-business-r018)
- [`SLM-L2-API-R018`](../../rules/level-2.md#slm-l2-api-r018)
- [`SLM-L2-STATE-R028`](../../rules/level-2.md#slm-l2-state-r028)
## Библиотеки не запрещены
## Основная граница
Запрет state/query manager в import-графе `business` не запрещает TanStack Query, SWR, Apollo, RTK Query, Zustand, Redux, MobX или RxJS в доменном пакете. Он запрещает concrete runtime становиться предметным контрактом.
Модуль `api` определяет форму и семантику доменных значений, но не выбирает способ их хранения и реактивной доставки. TanStack Query, SWR, Apollo, RTK Query, Zustand, Redux, MobX, Pinia, Signals и RxJS остаются в framework bindings или compositions.
Такая библиотека может находиться:
- в adapter-модуле, если реализует техническую зависимость business-фабрики;
- в framework binding module, если доставляет готовый Domain API конкретному framework;
- в composition, если состояние принадлежит только её UI scope и не подменяет доменную модель.
## Три вида состояния
### Предметное состояние
Модель фактов и переходов домена. `business` определяет initial value, validation, допустимые команды, transitions и selectors. Concrete store может быть передан фабрике как business-owned port:
```ts
export type AuthStateDependency = {
create: (initial: AuthState) => {
get: () => AuthState
set: (state: AuthState) => void
subscribe: (listener: () => void) => () => void
}
}
```text
Domain API
→ public model/outcome/event
framework projection
→ rendering
```
Adapter поверх Zustand или другого manager реализует этот контракт, но не выбирает initial state и не добавляет переходы.
Concrete state/query runtime не импортируется модулем `api`, не является dependency port фабрики и не входит в публичный Domain API.
### Technical source cache
## Виды materialization
Кэш SDK, HTTP, GraphQL или storage-вызовов. Adapter может использовать QueryClient, Apollo cache или иной runtime для deduplication, transport retry и хранения внешних результатов. Business по-прежнему проверяет и преобразует результат до публикации предметной модели.
### Source cache
Query keys и библиотечные result types остаются внутри adapter. Domain API не экспортирует `UseQueryResult`, `ApolloError`, raw DTO или mutable QueryClient.
Технический cache внешнего provider внутри adapter. Он может отвечать за transport deduplication, connection state, provider retry и хранение port records.
Если technical cache создаёт timers, subscriptions или другой lifecycle-ресурс для всего графа, владеющая им assembly передаёт cleanup graph owner. Cache без такого ресурса не требует искусственного `dispose`.
Source cache не публикует raw DTO, query keys, mutable client или library result через Domain API. Если adapter создаёт timers, subscriptions или connection, он остаётся единственным владельцем и экспортирует lifecycle handle, который assembly только агрегирует. Если resource создаёт assembly, adapter использует его как borrowed capability и не закрывает самостоятельно.
### Framework projection cache
### Framework projection
Кэш, который framework binding строит поверх вызовов готового Domain API для rendering, Suspense, revalidation или UI coordination.
State или cache, который framework binding строит из готового Domain API:
```ts
const useProfile = () => {
const api = useUserApi()
export const useAuthSession = () => {
const api = useAuthApi()
return useQuery({
queryKey: ['user', 'profile'],
queryFn: api.getProfile,
queryKey: ['auth', 'session'],
queryFn: api.getSession,
})
}
```
Такой cache не является параллельным предметным источником, пока его значения происходят из Domain API и все предметные изменения выполняются через API.
Query key, stale time, pending/retry status, Suspense, rendering stale data и техническая invalidation принадлежат binding. `AuthSession` и `AuthError` принадлежат `api`.
### Composition state
Состояние конкретной страницы или multi-domain flow принадлежит composition: выбранная вкладка, открытый modal, draft формы, route transition и координация нескольких API.
Если draft приобретает самостоятельную доменную семантику, Domain API предоставляет validation или transition, но framework по-прежнему хранит возвращаемое readonly value.
## Domain API не является store
Публичный Domain API не экспортирует:
- mutable store;
- `getState` и `setState` framework runtime;
- QueryClient;
- Zustand `StoreApi`;
- framework hook;
- глобальный singleton данных;
- универсальный state port.
API methods возвращают значения и outcomes. Framework consumer решает, как долго их хранить и когда повторно запросить.
Это не означает, что framework определяет предметные transitions. Он материализует только то, что произвёл или проверил API.
## Invalidation и retry
Не каждая cache policy является бизнес-правилом.
| Политика | Обычный владелец |
|---|---|
| Query key, stale time, deduplication, background refetch | Adapter или framework binding |
| Rendering stale data, Suspense, polling UI | Framework binding или composition |
| Query key, stale time, deduplication, background refetch | Framework binding |
| Transport retry безопасного запроса | Adapter |
| Запрет повторной предметной команды | `business` |
| Cooldown, лимит попыток, допустимый transition | `business` |
| Freshness, влияющая на корректность сценария | `business` через явный контракт |
| Rendering stale data, Suspense, polling UI | Framework binding или composition |
| Запрет повторной предметной команды | Domain API |
| Cooldown, лимит попыток, допустимый transition | Domain API |
| Freshness, влияющая на корректность сценария | Domain API через operation contract |
Если после команды требуется invalidation, framework binding может связать успешный результат API с техническими keys. Если выбор invalidation выражает предметную семантику, business возвращает устойчивый outcome/event либо публикует чистое правило через `business/runtime`; binding только отображает его на библиотечные операции.
После успешной команды binding может технически invalidировать известные query keys. Если выбор invalidation выражает предметную семантику, Domain API возвращает устойчивый outcome/event, а binding только отображает его на framework cache.
## Optimistic updates
Framework binding не конструирует произвольную предметную модель из input формы или transport DTO. Optimistic update допустим, когда предполагаемое значение:
Framework binding не конструирует произвольную публичную модель из form input, raw DTO или текущего cache. Optimistic projection допустима, когда предполагаемое значение:
- возвращено командой Domain API как безопасная projection;
- создано отдельным pure-методом Domain API;
- создано или проверено публичной функцией `business/runtime`.
- возвращено командой Domain API;
- создано отдельной операцией Domain API;
- создано или проверено pure-функцией `api/runtime`.
```ts
const optimisticProfile = projectProfileUpdate(currentProfile, command)
import {
projectProfileUpdate,
} from '@/domains/user/api/runtime'
const optimisticProfile = projectProfileUpdate(
currentProfile,
command,
)
queryClient.setQueryData(profileKey, optimisticProfile)
```
Здесь `projectProfileUpdate` принадлежит `business/runtime`, а `setQueryData` остаётся технической операцией framework binding.
`projectProfileUpdate` владеет предметным transition, а `setQueryData` остаётся framework operation.
Запись raw form data или DTO напрямую в публичный product cache обходит предметного владельца и нарушает границу.
## Concurrent mutations и realtime
## Browser, SSR и RSC
При нескольких optimistic commands и realtime events binding не выбирает самостоятельно ordering, versioning, rollback или rebase. Domain API возвращает correlation/version metadata либо предоставляет deterministic reconciliation:
Client hook, server prefetch и hydrate/dehydrate могут принадлежать разным framework binding modules с совместимыми environment entry points. Общая framework-specific cache policy выносится в отдельный модуль той же Framework Group, если у неё несколько реальных потребителей.
```ts
const nextProjection = reconcileProfile({
current,
event,
pendingCommands,
})
```
`compositions` вызывает публичный server binding для prefetch и публичный client binding для rendering. Она не повторяет query keys и mapping доменных результатов.
Если API не объявляет безопасный merge, binding invalidates cache и получает authoritative snapshot через Domain API. Это предпочтительнее скрытого применения неполного delta.
## Persistence
Framework cache может технически сохраняться между reloads, но persisted value не становится источником предметной истины. После восстановления значение:
- используется как stale projection до revalidation;
- либо проверяется публичным validator `api/runtime`;
- либо отбрасывается и загружается через Domain API.
Если storage является самостоятельным предметным внешним источником, доступ к нему оформляется dependency port и adapter. Автоматический framework middleware не обходит API validation и transitions.
## SSR и hydration
Server и client имеют разные API instances и caches:
```text
server request
→ request assembly
→ server Domain API
→ server framework cache
→ serializable hydration payload
browser
→ client assembly
→ client Domain API
→ hydrated client cache
```
Hydration payload принадлежит framework binding и содержит только public domain values и framework metadata. API object, functions, ports, adapters, mutable clients и request secrets не сериализуются.
Server cache создаётся на каждый request и не хранится в module singleton. Client cache создаётся на согласованный application или route scope.
## RSC и Server Actions
Server Component вызывает server Domain API и передаёт Client Component только сериализуемые values или hydration payload. Client Component создаёт или получает отдельный client API instance; при SSR его render отдельно проверяется в server prerender graph до browser hydration.
Server Action создаёт request-scoped production graph на каждый вызов, выполняет Domain API command и гарантированно выполняет все cleanup obligations графа. Client invocation Server Action является framework reference edge, а не передачей server API в browser.
## Проверка на ревью
Для каждого state/query runtime определяется:
- является ли он adapter, framework projection или локальным UI state;
- откуда поступают значения;
- кто определяет transition и optimistic projection;
- является ли он source cache, framework projection или composition state;
- откуда поступают public domain values;
- кто определяет validation и transition;
- где находятся library-specific types и keys;
- как invalidation соотносится с результатами Domain API;
- соответствует ли cache lifecycle области жизни API и framework scope.
- как invalidation связана с Domain API outcomes;
- как обрабатываются optimistic concurrency и realtime events;
- что сериализуется при SSR/RSC;
- соответствует ли cache scope области жизни API graph.

View File

@@ -1,91 +1,189 @@
# Тестирование доменного пакета
> Проверка владельцев и публичных границ Level 2.
> Проверка Domain API, port contracts, production wiring и framework projections Level 2.
## Связанные правила
- [`SLM-L2-TEST-R016`](../../rules/level-2.md#slm-l2-test-r016)
- [`SLM-L2-BUSINESS-A019`](../../rules/level-2.md#slm-l2-business-a019)
- [`SLM-L2-API-A019`](../../rules/level-2.md#slm-l2-api-a019)
- [`SLM-L2-ASSEMBLY-A020`](../../rules/level-2.md#slm-l2-assembly-a020)
- [`SLM-L2-ADAPTER-R021`](../../rules/level-2.md#slm-l2-adapter-r021)
- [`SLM-L2-BUSINESS-A022`](../../rules/level-2.md#slm-l2-business-a022)
- [`SLM-L2-API-A022`](../../rules/level-2.md#slm-l2-api-a022)
- [`SLM-L2-ASSEMBLY-R023`](../../rules/level-2.md#slm-l2-assembly-r023)
- [`SLM-L2-BUSINESS-R024`](../../rules/level-2.md#slm-l2-business-r024)
- [`SLM-L2-API-R024`](../../rules/level-2.md#slm-l2-api-r024)
- [`SLM-L2-PORT-R027`](../../rules/level-2.md#slm-l2-port-r027)
- [`SLM-L2-REALTIME-R029`](../../rules/level-2.md#slm-l2-realtime-r029)
- [`SLM-L2-ASSEMBLY-R030`](../../rules/level-2.md#slm-l2-assembly-r030)
## Размещение
Тест находится рядом с модулем-владельцем проверяемой ответственности. У доменного пакета нет общей корневой папки `tests`.
Тест находится рядом с module-owner проверяемой ответственности. У доменного пакета нет общей корневой папки `tests`.
| Проверяемая граница | Владелец теста |
|---|---|
| Сценарии, Domain API, данные и ошибки | `business` |
| Публичная pure-функция или guard | `business/runtime` внутри тестов модуля business |
| Техническое преобразование | Adapter |
| Выбор API, dependencies и environment boundary | Assembly |
| Provider, hook, query integration, form или guard | Соответствующий framework binding module |
| Граф нескольких доменов | Модуль `composition`, точка входа `app` или другое место сборки |
| Domain API operations, models, outcomes и errors | `api` через factory |
| Deterministic runtime и guards | `api` |
| Реализация dependency port | Adapter |
| Default и специальный production graph | Assembly |
| Provider, hook, query/store integration и hydration | Framework binding |
| Cross-domain graph | Graph owner |
## Business через фабрику
## Domain API через фабрику
Каждый публичный сценарий проверяется через фабрику владеющего им API с управляемыми dependencies:
Каждый публичный сценарий проверяется через фабрику владеющего им Domain API с управляемыми fake ports:
```ts
import type { AuthSessionApi } from '@/domains/auth/business'
import { authSessionFactory } from '@/domains/auth/business/factory'
import type {
AuthSessionApi,
} from '@/domains/auth/api'
import type {
AuthIdentityPort,
} from '@/domains/auth/api/ports'
import {
AUTH_ERROR_CODES,
isAuthError,
} from '@/domains/auth/business/runtime'
createAuthSessionApi,
} from '@/domains/auth/api/factory'
const api: AuthSessionApi = authSessionFactory(createAuthSessionTestDeps({
clock: { now: () => 1_700_000_000_000 },
phone: { requestCode: async () => ({ ok: true }) },
}))
const identity: AuthIdentityPort = createIdentityPortFake({
signIn: {
ok: true,
value: {
expiresAt: 1_700_000_000_000,
subject: 'user-1',
},
},
})
await api.requestPhoneOtp('+79991112233')
const api: AuthSessionApi = createAuthSessionApi({
identity,
runtime: {
createId: () => 'id-1',
now: () => 1_700_000_000_000,
},
})
```
Набор проверяет успешные и ожидаемые ошибочные результаты, validation, преобразование внешних данных и публичные изменения состояния. Способ assertion для ошибки зависит от `throw` или `Result`, но наружу проверяется только собственный AuthErrorCode.
API suite проверяет:
Business-тест не использует React, реальный SDK, database, production assembly или системные clock/random. Локальная fake implementation допустима в тесте и не становится adapter-модулем, потому что не входит в production graph.
- public models и outcomes;
- validation commands и port records;
- mapping каждого expected port failure;
- отсутствие raw provider details в domain errors;
- cancellation и outcome uncertainty при наличии;
- pure transitions и reconciliation;
- каждый API отдельно при нескольких factories.
Если `business` объявляет несколько API, каждый тестируется через свою фабрику. Общая внутренняя pure-логика не требует повторять одинаковые cases на уровне всех API.
API-тест не использует React, production assembly, реальный SDK, backend, system clock или module singleton.
## Остальные модули
## Adapter contract test
Тест adapter-модуля проверяет технический вызов, аргументы, преобразование результата и передачу исходного сбоя business-слою. Он не повторяет mapping в доменные ошибки.
Adapter test доказывает, что concrete provider реализует port:
Тест обязательной assembly проверяет:
- правильно преобразует arguments;
- валидно читает provider record;
- возвращает port record, а не raw DTO;
- различает закрытые port failures;
- не создаёт public domain error;
- соблюдает cancellation, timeout и lifecycle contract;
- использует заявленный environment capability set.
- вызов только нужных business-фабрик;
- точный именованный состав возвращённого графа;
- выбор публичных adapter-модулей;
- отсутствие несовместимого environment-кода;
- передачу cross-domain API аргументом, а не импортом;
- cleanup handle, если assembly создаёт ресурс жизненного цикла.
Fake port в API-тесте не заменяет adapter contract test. Идеальный fake может соответствовать типу, пока реальный endpoint или SDK уже изменился.
Assembly без собственного lifecycle-ресурса не тестирует пустой `dispose`, потому что не обязана его предоставлять.
## Default assembly
Framework-тест импортирует только конкретный модуль, например `auth/react/session`, передаёт тестовый `AuthSessionApi` и проверяет Provider, hook или component. Query binding дополнительно проверяет keys, invalidation и отсутствие raw DTO в публичном результате, но не повторяет полный набор business-сценариев.
Тест `assemblies/default` проверяет:
- вызов только нужных API factories;
- выбор штатных adapter modules;
- точный именованный состав graph;
- объявленный baseline capability set;
- отсутствие module-import side effects;
- передачу cross-domain API аргументом;
- отсутствие factory/adapter leakage наружу;
- aggregate cleanup, если assembly создаёт owned resource или получает adapter lifecycle handle.
Каждая дополнительная assembly тестирует отличие своего production context, а не повторяет полный API suite.
## Partial construction и cleanup
Assembly test моделирует ошибку после регистрации каждого cleanup obligation, включая adapter-owned handle:
```text
resource A created
resource B creation failed
→ cleanup A awaited
→ original failure propagated
```
Проверяются reverse dependency order, idempotent repeated disposal, попытка очистить все resources и отсутствие callbacks после завершившегося cleanup.
Assembly без cleanup obligations не тестирует пустой `dispose`, потому что не обязана его предоставлять. Если adapter передал lifecycle handle, obligation существует независимо от resource ownership.
## Framework binding
Framework test получает fake готового Domain API и проверяет собственную responsibility:
- Provider и hook;
- query keys, stale policy и invalidation;
- store projection;
- optimistic update через API-owned function;
- hydration payload;
- public domain errors;
- subscription cleanup;
- отсутствие direct SDK/external source access.
Framework test не повторяет validation и failure mapping всех API operations.
## Realtime
API realtime test с fake port проверяет public events, stable errors, acknowledgement semantics и `OUTCOME_UNKNOWN` mapping.
Adapter realtime contract test проверяет:
- command correlation;
- duplicate и late acknowledgement;
- disconnect до ACK;
- ordering и sequence gaps;
- reconnect и resync;
- malformed frames;
- cancellation и unsubscribe;
- отсутствие callbacks после cleanup.
Assembly test отдельно проверяет shared connection, multiplexing, rollback и graph-level disposal. Framework test проверяет только materialization events и invalidation.
## SSR, RSC и Server Actions
Environment tests подтверждают:
- request-scoped API и cache не разделяются между users;
- API instance не входит в hydration payload;
- Client Component создаёт отдельный client graph;
- SSR-enabled Client Component проверяется в server prerender и browser hydration graphs;
- browser-only effect не выполняется во время server render;
- Server Action создаёт и очищает graph на каждый вызов;
- framework reference edge не превращается в executable client/server leak;
- `default` проверяется под всеми объявленными resolver conditions.
## Cross-domain graph
Graph owner test создаёт assemblies и construction points модулей Level 1 в dependency order и проверяет runtime inputs и callbacks. Отдельно проверяется невозможность mixed L1/L2 циклической сборки и reverse cleanup order.
Не достаточно проверить только статический import DAG: runtime dependencies, передаваемые arguments, должны быть представлены architecture mapping или review evidence.
## Автоматические структурные проверки
Проверка файлов, exports и import-графа подтверждает:
Import и export checks подтверждают:
- отсутствие root API доменного пакета и Framework Groups;
- обязательные `business` и `business/factory`, type-only exports в корневом barrel и допустимый опциональный `business/runtime`;
- соблюдение матрицы потребителей фасетов business;
- наличие непосредственно в корне пакета непустой Group `assemblies` с объявленными модульными границами;
- отсутствие запрещённых runtime cross-domain imports;
- отсутствие type-only импортов из чужих assemblies, adapters и framework-модулей;
- отсутствие cross-domain framework hooks, contexts и components;
- отсутствие server-only достижимости из client modules;
- отсутствие runtime- и type-only циклов.
- отсутствие root API пакета и Groups;
- обязательные `api`, `api/factory` и `assemblies/default`;
- `api/ports` только при наличии declared ports;
- допустимые exports каждого фасета;
- importer matrix factories, ports и concrete adapters;
- отсутствие deep imports;
- отсутствие SDK, framework и state/query runtime в graph `api`;
- отсутствие запрещённых cross-domain imports;
- environment compatibility под configured conditions;
- отсутствие статических cycles.
## Архитектурное ревью
На ревью проверяется, что `business/factory` экспортирует только объявленные фабрики, а `business/runtime` при наличии содержит только публичный deterministic runtime. Для каждой технической зависимости рассматриваются все production implementations: каждая связная implementation должна принадлежать одному модулю Group `adapters`, но один такой модуль может реализовать несколько тесно связанных capabilities одного provider.
Отдельно проверяются прямые обращения business к `Date.now`, `Math.random`, `crypto.randomUUID`, timers, env и другим скрытым runtime-capabilities.
Runtime-тест не заменяет автоматическую проверку или архитектурное ревью.
Runtime tests не заменяют import-graph checks и architecture review.