mirror of
https://github.com/gromlab-ru/slm-design.git
synced 2026-08-22 07:30:16 +03:00
feat: доменный API
This commit is contained in:
@@ -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) отделяют принятые решения от ещё не нормированных деталей.
|
||||
|
||||
|
||||
@@ -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 не считается его владельцем.
|
||||
|
||||
@@ -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 не является миграционным долгом.
|
||||
|
||||
@@ -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`, после чего преобразовать ожидаемую ошибку в собственный контракт.
|
||||
|
||||
Ошибки программирования и нарушенные внутренние инварианты не обязаны маскироваться под ожидаемые доменные ошибки.
|
||||
260
DRAFT/level-2/domains/domain-api.md
Normal file
260
DRAFT/level-2/domains/domain-api.md
Normal 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.
|
||||
@@ -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 и публичная граница.
|
||||
|
||||
@@ -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 не заменяет эту проверку.
|
||||
|
||||
@@ -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` в скрытый корневой модуль домена.
|
||||
|
||||
@@ -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 пока не нормирован.
|
||||
|
||||
217
DRAFT/level-2/domains/realtime.md
Normal file
217
DRAFT/level-2/domains/realtime.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user