mirror of
https://github.com/gromlab-ru/slm-design.git
synced 2026-08-22 07:30:16 +03:00
feat: уточнить архитектурные границы SLM
This commit is contained in:
@@ -1,26 +1,29 @@
|
||||
# Доменные пакеты Level 2
|
||||
|
||||
Доменный пакет заменяет корневой доменный модуль Level 1. Он объединяет SLM-модули одной предметной области, но сам не является модулем, Group или публичным API.
|
||||
Доменный пакет заменяет один выбранный доменный модуль Level 1. Он объединяет SLM-модули одной предметной области, но сам не является модулем, Group или публичным API.
|
||||
|
||||
```text
|
||||
domains/auth/
|
||||
├── business/
|
||||
├── presets/ # Обязательная Group
|
||||
├── assemblies/ # Обязательная Group
|
||||
├── adapters/ # При наличии technical dependencies
|
||||
└── react/
|
||||
├── session/
|
||||
└── login-form/
|
||||
```
|
||||
|
||||
Другие предметные области того же SLM root могут оставаться доменными модулями Level 1.
|
||||
|
||||
## Основные границы
|
||||
|
||||
- [Доменный пакет](./domain-package.md) определяет предметную и структурную границу.
|
||||
- [Business](./business.md) владеет `DomainApi` и разделяет public types, factory и error runtime по трём фасетам.
|
||||
- [Фабрика, зависимости и adapters](./factory-ports-adapters.md) требуют отдельный SLM-модуль для каждой production adapter implementation.
|
||||
- [Presets](./presets.md) обязательны и собирают один API для нужных окружений.
|
||||
- [Доменный пакет](./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 и технические проекции.
|
||||
- [Framework Groups](./framework-bindings.md) содержат domain-specific SLM-модули фреймворка.
|
||||
- [Тестирование](./testing.md) проверяет каждого владельца через его публичную границу.
|
||||
- [Миграция auth](./auth-example.md) показывает переход с Level 1.
|
||||
- [Переход auth](./auth-example.md) показывает локальный переход с Level 1 без big-bang всего root.
|
||||
- [Открытые вопросы](./open-questions.md) отделяют принятые решения от ещё не нормированных деталей.
|
||||
|
||||
Точные блокирующие требования объявлены только в [реестре правил Level 2](../../rules/level-2.md).
|
||||
|
||||
170
DRAFT/level-2/domains/assemblies.md
Normal file
170
DRAFT/level-2/domains/assemblies.md
Normal file
@@ -0,0 +1,170 @@
|
||||
# Assemblies и среды выполнения
|
||||
|
||||
> Пояснение повторяемой сборки именованного графа Domain API.
|
||||
|
||||
## Связанные правила
|
||||
|
||||
- [`SLM-L2-FACTORY-R008`](../../rules/level-2.md#slm-l2-factory-r008)
|
||||
- [`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-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-ASSEMBLY-R023`](../../rules/level-2.md#slm-l2-assembly-r023)
|
||||
|
||||
## Назначение
|
||||
|
||||
Assembly является SLM-модулем в Group `assemblies`. Она создаёт явный граф одного или нескольких Domain API пакета для конкретного повторяемого контекста выполнения. Каждый доменный пакет содержит минимум одну assembly.
|
||||
|
||||
```text
|
||||
business/factory
|
||||
├── assemblies/browser → { session: AuthSessionApi }
|
||||
├── assemblies/request → { session, administration }
|
||||
└── assemblies/server-action → { administration }
|
||||
```
|
||||
|
||||
Архитектура не требует `base` или изоморфную assembly и не ограничивает их максимальное количество. Обязательная assembly соответствует реальному поддерживаемому контексту, а не существует только для заполнения структуры.
|
||||
|
||||
Место сборки графа в `composition` может вызвать фабрики напрямую для одноразовой конфигурации. Такая сборка не отменяет обязательную assembly пакета и при наличии технических зависимостей использует публичные adapter-модули, а не inline implementations.
|
||||
|
||||
## Именованный граф API
|
||||
|
||||
Browser assembly импортирует только фабрики и adapters нужных ей 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'
|
||||
|
||||
export type AuthBrowserGraph = Readonly<{
|
||||
session: AuthSessionApi
|
||||
}>
|
||||
|
||||
export const createBrowserAuth = (): AuthBrowserGraph => {
|
||||
const session = authSessionFactory({
|
||||
phone: createPhoneHttpAdapter(),
|
||||
session: createBrowserSessionAdapter(),
|
||||
})
|
||||
|
||||
return { session }
|
||||
}
|
||||
```
|
||||
|
||||
Request assembly может собрать дополнительный API, которого нет в браузере:
|
||||
|
||||
```ts
|
||||
import type {
|
||||
AuthAdministrationApi,
|
||||
AuthSessionApi,
|
||||
} from '@/domains/auth/business'
|
||||
|
||||
import {
|
||||
authAdministrationFactory,
|
||||
authSessionFactory,
|
||||
} from '@/domains/auth/business/factory'
|
||||
|
||||
export type AuthRequestGraph = Readonly<{
|
||||
administration: AuthAdministrationApi
|
||||
session: AuthSessionApi
|
||||
}>
|
||||
```
|
||||
|
||||
Assembly не добавляет методы к этим контрактам и не создаёт общий `AuthApi`. Именованный объект только сообщает, какие независимые API доступны в контексте.
|
||||
|
||||
## Cross-domain input
|
||||
|
||||
Assembly зависимого домена принимает готовый API аргументом. Cross-domain API не является adapter и не размещается в Group `adapters`:
|
||||
|
||||
```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'
|
||||
|
||||
export type CreateUserForRequestInput = {
|
||||
auth: Pick<AuthSessionApi, 'getSnapshot'>
|
||||
request: UserRequestInput
|
||||
}
|
||||
|
||||
export type UserRequestGraph = Readonly<{
|
||||
profile: UserProfileApi
|
||||
}>
|
||||
|
||||
export const createUserForRequest = ({
|
||||
auth,
|
||||
request,
|
||||
}: CreateUserForRequestInput): UserRequestGraph => {
|
||||
const profile = userProfileFactory({
|
||||
auth,
|
||||
profile: createUserProfileAdapter(request),
|
||||
})
|
||||
|
||||
return { profile }
|
||||
}
|
||||
```
|
||||
|
||||
Assembly делает только type-only импорт `AuthSessionApi`. Runtime-фабрику, assembly или instance Auth она не импортирует.
|
||||
|
||||
Место сборки графа выполняет runtime-связь:
|
||||
|
||||
```ts
|
||||
const auth = createAuthForRequest(authInput)
|
||||
const user = createUserForRequest({
|
||||
auth: auth.session,
|
||||
request: userInput,
|
||||
})
|
||||
```
|
||||
|
||||
## Environment entry points
|
||||
|
||||
Server assembly имеет отдельный публичный entry point и marker выбранного framework или bundler:
|
||||
|
||||
```ts
|
||||
import 'server-only'
|
||||
|
||||
export { createAuthForRequest } from './create-auth-for-request'
|
||||
```
|
||||
|
||||
Server entry point не реэкспортируется через `business`, Framework Group, browser assembly или корень доменного пакета. Аналогично client-only код не достигается из server/shared entry point, если выбранная среда запрещает такую зависимость.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
Factory не запускает запрос, subscription, timer или другую скрытую долгоживущую работу во время создания API. Assembly может активировать технический ресурс только с явной передачей cleanup своему caller. Операция Domain API, которая запускает ресурс позже, сама возвращает cleanup:
|
||||
|
||||
```ts
|
||||
const stop = auth.session.startInvalidationTracking()
|
||||
|
||||
try {
|
||||
// Scope использует API.
|
||||
} finally {
|
||||
await stop()
|
||||
}
|
||||
```
|
||||
|
||||
Если assembly сама создаёт ресурс, принадлежащий всему возвращённому графу, результат дополнительно предоставляет cleanup handle:
|
||||
|
||||
```ts
|
||||
export type AuthRequestAssembly = Readonly<{
|
||||
apis: AuthRequestGraph
|
||||
dispose: () => Promise<void>
|
||||
}>
|
||||
```
|
||||
|
||||
```ts
|
||||
const auth = createAuthForRequest(input)
|
||||
|
||||
try {
|
||||
return await handleRequest(auth.apis)
|
||||
} finally {
|
||||
await auth.dispose()
|
||||
}
|
||||
```
|
||||
|
||||
Assembly без собственного ресурса не обязана возвращать пустой `dispose`. Graph owner вызывает каждый реально предоставленный cleanup не позже завершения application, route, request или test scope.
|
||||
|
||||
Одноразовое место сборки, которое вызывает фабрики напрямую, подчиняется той же границе: оно не запускает скрытый ресурс в constructor и сохраняет cleanup любого созданного adapter-ресурса до завершения своего scope.
|
||||
|
||||
Рекомендуется делать `dispose` идемпотентным, освобождать частично созданные ресурсы при ошибке assembly и закрывать зависимые ресурсы раньше их зависимостей. Детальная политика rollback и поведения API после cleanup остаётся открытым вопросом.
|
||||
@@ -1,73 +1,74 @@
|
||||
# Миграция домена auth с Level 1
|
||||
# Переход домена auth с Level 1
|
||||
|
||||
> Проверочный пример перехода от доменного модуля к доменному пакету.
|
||||
> Проверочный пример локального перехода от доменного модуля к доменному пакету.
|
||||
|
||||
## Связанное правило
|
||||
## Связанные правила
|
||||
|
||||
- [`SLM-L2-MIGRATION-A017`](../../rules/level-2.md#slm-l2-migration-a017)
|
||||
- [`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-PRESET-A020`](../../rules/level-2.md#slm-l2-preset-a020)
|
||||
- [`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)
|
||||
|
||||
## Исходная форма Level 1
|
||||
|
||||
```text
|
||||
domains/auth/ # Доменный модуль
|
||||
├── hooks/
|
||||
├── services/
|
||||
├── stores/
|
||||
├── ui/
|
||||
└── index.ts # Общий API модуля
|
||||
domains/
|
||||
├── auth/ # Доменный модуль
|
||||
│ ├── hooks/
|
||||
│ ├── services/
|
||||
│ ├── stores/
|
||||
│ ├── ui/
|
||||
│ └── index.ts # Общий API модуля
|
||||
└── catalog/ # Независимый доменный модуль
|
||||
└── index.ts
|
||||
```
|
||||
|
||||
Level 1 разрешает business-сценариям, framework hooks, state adapter и локальной сборке находиться внутри одного модуля.
|
||||
Level 1 разрешает business-сценариям, framework hooks, state adapter и локальной сборке Auth находиться внутри одного модуля.
|
||||
|
||||
## Целевая форма Level 2
|
||||
## Целевая форма Auth
|
||||
|
||||
```text
|
||||
domains/auth/ # Доменный пакет
|
||||
├── README.md
|
||||
├── business/ # SLM-модуль
|
||||
│ ├── errors/
|
||||
│ ├── lib/
|
||||
│ ├── services/
|
||||
│ ├── types/
|
||||
│ ├── index.ts # Только public types
|
||||
│ ├── factory.ts # Public factory entry
|
||||
│ └── error.ts # Public error runtime entry
|
||||
├── adapters/ # Group
|
||||
│ ├── phone-http/ # SLM-модуль
|
||||
│ │ └── index.ts
|
||||
│ ├── browser-session/ # SLM-модуль
|
||||
│ │ └── index.ts
|
||||
│ └── request-session/ # SLM-модуль
|
||||
│ └── index.ts
|
||||
├── presets/ # Обязательная Group
|
||||
│ ├── browser/ # SLM-модуль
|
||||
│ │ └── index.ts
|
||||
│ └── request/ # SLM-модуль
|
||||
│ └── index.ts
|
||||
└── react/ # Framework Group
|
||||
├── session/ # SLM-модуль
|
||||
│ └── index.ts
|
||||
└── login-form/ # SLM-модуль
|
||||
└── index.ts
|
||||
domains/
|
||||
├── auth/ # Доменный пакет Level 2
|
||||
│ ├── README.md
|
||||
│ ├── business/ # Один SLM-модуль
|
||||
│ │ ├── errors/
|
||||
│ │ ├── factories/
|
||||
│ │ ├── services/
|
||||
│ │ ├── types/
|
||||
│ │ ├── index.ts # Только public types нескольких API
|
||||
│ │ ├── factory.ts # Public factories entry
|
||||
│ │ └── runtime.ts # Error codes, guards, public pure runtime
|
||||
│ ├── adapters/ # Group
|
||||
│ │ ├── phone-http/ # SLM-модуль
|
||||
│ │ ├── browser-session/ # SLM-модуль
|
||||
│ │ └── request-session/ # SLM-модуль
|
||||
│ ├── assemblies/ # Обязательная Group
|
||||
│ │ ├── browser/ # Только AuthSessionApi
|
||||
│ │ └── request/ # Session + Administration API
|
||||
│ └── react/ # Framework Group
|
||||
│ ├── session/ # SLM-модуль
|
||||
│ └── login-form/ # SLM-модуль
|
||||
└── catalog/ # По-прежнему модуль Level 1
|
||||
└── index.ts
|
||||
```
|
||||
|
||||
Корневой `domains/auth/index.ts` удаляется. Потребители переходят на публичные API конкретных модулей.
|
||||
Корневой `domains/auth/index.ts` удаляется. Потребители переходят на публичные API конкретных модулей. `catalog` и остальные домены не меняют форму только из-за перехода Auth.
|
||||
|
||||
## Перенос ответственности
|
||||
|
||||
| Исходная часть | Владелец Level 2 | Публичный путь |
|
||||
|---|---|---|
|
||||
| Сценарии и public types | `auth/business` | `auth/business` |
|
||||
| Runtime-фабрика | `auth/business` | `auth/business/factory` |
|
||||
| Коды и guards ошибок | `auth/business` | `auth/business/error` |
|
||||
| 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 implementations | `auth/presets/browser` | `auth/presets/browser` |
|
||||
| Выбор request implementations | `auth/presets/request` | `auth/presets/request` |
|
||||
| Browser graph | `auth/assemblies/browser` | `auth/assemblies/browser` |
|
||||
| Request graph | `auth/assemblies/request` | `auth/assemblies/request` |
|
||||
| Provider и session hooks | `auth/react/session` | `auth/react/session` |
|
||||
| Переиспользуемая форма | `auth/react/login-form` | `auth/react/login-form` |
|
||||
| Страница, текст и redirect | `compositions` | API конкретной composition |
|
||||
@@ -76,54 +77,63 @@ domains/auth/ # Доменный пакет
|
||||
|
||||
```ts
|
||||
import type {
|
||||
AuthApi,
|
||||
AuthAdministrationApi,
|
||||
AuthError,
|
||||
AuthErrorCode,
|
||||
AuthSessionApi,
|
||||
} from '@/domains/auth/business'
|
||||
|
||||
import { authFactory } from '@/domains/auth/business/factory'
|
||||
import {
|
||||
authAdministrationFactory,
|
||||
authSessionFactory,
|
||||
} from '@/domains/auth/business/factory'
|
||||
|
||||
import {
|
||||
AUTH_ERROR_CODES,
|
||||
isAuthError,
|
||||
} from '@/domains/auth/business/error'
|
||||
} from '@/domains/auth/business/runtime'
|
||||
|
||||
import { createPhoneHttpAdapter } from '@/domains/auth/adapters/phone-http'
|
||||
import { createBrowserAuth } from '@/domains/auth/presets/browser'
|
||||
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 зависит от Auth, оба связанных домена сначала переводятся в пакеты. Затем User получает только type-only контракт:
|
||||
Если User package зависит от Auth, он получает только type-only API contract и при необходимости deterministic runtime:
|
||||
|
||||
```ts
|
||||
import type { AuthApi } from '@/domains/auth/business'
|
||||
import type { AuthSessionApi } from '@/domains/auth/business'
|
||||
import { isAuthError } from '@/domains/auth/business/runtime'
|
||||
|
||||
export type UserDeps = {
|
||||
auth: Pick<AuthApi, 'getSession'>
|
||||
auth: Pick<AuthSessionApi, 'getSnapshot'>
|
||||
}
|
||||
```
|
||||
|
||||
Место сборки графа создаёт экземпляры:
|
||||
Место сборки создаёт instances:
|
||||
|
||||
```ts
|
||||
const authApi = createBrowserAuth()
|
||||
const userApi = createBrowserUser({ authApi })
|
||||
const auth = createBrowserAuth()
|
||||
const user = createBrowserUser({ auth: auth.session })
|
||||
```
|
||||
|
||||
User не импортирует runtime-код Auth, а его React-модули не импортируют `useAuthSession` или Auth components.
|
||||
User не импортирует Auth factory или assembly, а его React-модули не импортируют `useAuthSession` или Auth components.
|
||||
|
||||
Если User остаётся модулем Level 1, runtime-связь также выполняется снаружи доменных границ. Для этого его собственный API должен иметь явную точку передачи нужного Auth behavior; иначе именно User требуется рефакторинг или переход, но несвязанные домены не затрагиваются.
|
||||
|
||||
## Порядок перехода
|
||||
|
||||
1. Определить dependency-connected набор доменов, который нужно мигрировать вместе.
|
||||
2. Выделить `business` и три публичных фасета: type-only barrel, `factory` и `error`.
|
||||
3. Зафиксировать `DomainApi`, error codes, error type и runtime guard.
|
||||
4. Оформить каждую production implementation отдельным модулем `adapters/*`.
|
||||
5. Создать минимум один preset и перенести туда повторяемый выбор adapter-модулей.
|
||||
6. Разделить React-ответственности на модули внутри Group `react`.
|
||||
7. Перенести страницы, redirects и multi-domain UI в `compositions`.
|
||||
8. Перевести внешние импорты на разрешённые public paths.
|
||||
9. Удалить старый root `index.ts` и проверить import-граф.
|
||||
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-у.
|
||||
|
||||
Простые доменные модули могут оставаться в SLM root только как временное миграционное состояние. Они не создают прямые runtime- или type-only зависимости с уже переведёнными пакетами.
|
||||
Завершённость перехода определяется только границей Auth. Наличие других доменных модулей Level 1 не является миграционным долгом.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Модуль business
|
||||
|
||||
> Пояснение единственного runtime-источника доменных данных и результатов.
|
||||
> Пояснение предметного владельца, нескольких Domain API и публичного deterministic runtime.
|
||||
|
||||
## Связанные правила
|
||||
|
||||
@@ -13,24 +13,26 @@
|
||||
- [`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-модулем доменного пакета. Он владеет:
|
||||
|
||||
- публичными предметными сценариями;
|
||||
- единым контрактом `DomainApi`;
|
||||
- одной публичной фабрикой;
|
||||
- типом явных зависимостей фабрики;
|
||||
- одним или несколькими именованными Domain API;
|
||||
- одной публичной фабрикой для каждого API;
|
||||
- типами явных зависимостей фабрик;
|
||||
- предметными типами и детерминированными правилами;
|
||||
- кодами, типом и runtime guard доменных ошибок;
|
||||
- контрактами ожидаемых доменных ошибок;
|
||||
- публичным представлением доменных данных и состояния.
|
||||
|
||||
Приложение получает runtime-данные, состояние и результаты домена только через экземпляр `DomainApi`. Adapter, preset или framework binding module не открывает параллельный источник доменных данных.
|
||||
Несколько API остаются частью одного модуля, пока относятся к одной связной предметной области. Они нужны не для копирования use cases, а для независимой сборки разных наборов сценариев, зависимостей и сред.
|
||||
|
||||
## Публичный API модуля
|
||||
## Публичные фасеты
|
||||
|
||||
Один логический API `business` разделён на три фиксированных фасета.
|
||||
Один логический API модуля `business` имеет два обязательных и один необязательный entry point.
|
||||
|
||||
### Type-only barrel
|
||||
|
||||
@@ -38,11 +40,14 @@
|
||||
|
||||
```ts
|
||||
export type {
|
||||
AuthApi,
|
||||
AuthDeps,
|
||||
AuthAdministrationApi,
|
||||
AuthAdministrationDeps,
|
||||
AuthAdministrationFactory,
|
||||
AuthError,
|
||||
AuthErrorCode,
|
||||
AuthFactory,
|
||||
AuthSessionApi,
|
||||
AuthSessionDeps,
|
||||
AuthSessionFactory,
|
||||
AuthState,
|
||||
} from './types'
|
||||
```
|
||||
@@ -51,73 +56,134 @@ export type {
|
||||
|
||||
```ts
|
||||
import type {
|
||||
AuthApi,
|
||||
AuthError,
|
||||
AuthErrorCode,
|
||||
AuthSessionApi,
|
||||
AuthState,
|
||||
} from '@/domains/auth/business'
|
||||
```
|
||||
|
||||
### Factory entry
|
||||
|
||||
`business/factory.ts` экспортирует только runtime-фабрику:
|
||||
`business/factory.ts` экспортирует только именованные runtime-фабрики:
|
||||
|
||||
```ts
|
||||
export { authFactory } from './auth.factory'
|
||||
```
|
||||
|
||||
```ts
|
||||
import { authFactory } from '@/domains/auth/business/factory'
|
||||
```
|
||||
|
||||
### Error entry
|
||||
|
||||
`business/error.ts` экспортирует только runtime-коды и guards:
|
||||
|
||||
```ts
|
||||
export { AUTH_ERROR_CODES, isAuthError } from './errors/auth-error'
|
||||
export { authAdministrationFactory } from './factories/auth-administration.factory'
|
||||
export { authSessionFactory } from './factories/auth-session.factory'
|
||||
```
|
||||
|
||||
```ts
|
||||
import {
|
||||
AUTH_ERROR_CODES,
|
||||
isAuthError,
|
||||
} from '@/domains/auth/business/error'
|
||||
authAdministrationFactory,
|
||||
authSessionFactory,
|
||||
} from '@/domains/auth/business/factory'
|
||||
```
|
||||
|
||||
`AuthError` и `AuthErrorCode` не реэкспортируются из `business/error`: все public types имеют один канонический путь через type-only barrel. Предметные validators, normalizers, constructors ошибок, source-error mappers, mutable store и технические DTO остаются закрытыми.
|
||||
Каждому API соответствует одна фабрика. Фасет не экспортирует готовые instances, adapters или environment-specific assembly.
|
||||
|
||||
Другие внешние пути внутри `business` являются deep imports. Файлы `factory.ts` и `error.ts` являются фасетами одного SLM-модуля, а не сегментами или вложенными модулями.
|
||||
### Runtime entry
|
||||
|
||||
## Потребители фасетов
|
||||
|
||||
| Потребитель | `business` | `business/factory` | `business/error` |
|
||||
|---|---|---|---|
|
||||
| Adapter module своего домена | Type-only | Нет | Нет |
|
||||
| Preset своего домена | Type-only | Да | Нет |
|
||||
| Framework binding module своего домена | Type-only | Нет | Да |
|
||||
| `composition` или `app` | Type-only | Да | Да |
|
||||
| Модуль другого доменного пакета | Type-only | Нет | Нет |
|
||||
| Тест | Type-only | По границе тестируемого владельца | По границе тестируемого владельца |
|
||||
|
||||
## Один DomainApi
|
||||
Необязательный `business/runtime.ts` экспортирует только публичные детерминированные значения и функции:
|
||||
|
||||
```ts
|
||||
export type AuthApi = {
|
||||
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 AuthFactory = (deps: AuthDeps) => AuthApi
|
||||
export type AuthAdministrationApi = {
|
||||
revokeUserSessions: (userId: string) => Promise<void>
|
||||
}
|
||||
```
|
||||
|
||||
Все presets вызывают одну `authFactory` и создают `AuthApi` этого контракта. Preset может использовать другую техническую реализацию, но не добавляет метод и не меняет семантику сценария. Одноразовое место сборки в `composition` также может вызвать `business/factory`, используя публичные adapter-модули пакета, если фабрика имеет технические зависимости.
|
||||
`AuthSessionApi` может собираться в browser и request contexts, а `AuthAdministrationApi` только в доверенной server assembly. Browser assembly не получает метод-заглушку и не импортирует adapters административного API.
|
||||
|
||||
Точная модель хранения, initial state, подписки и SSR snapshot пока остаётся открытым вопросом. Нормативной уже является публичная граница: приложение наблюдает доменное состояние через `DomainApi`, а не напрямую через adapter или framework store.
|
||||
Общий `business/factory` является публичным фасетом, а не гарантией отдельного business chunk для каждой фабрики. Разделение API устраняет обязательное создание лишних adapters и instances. Если самой business-логике нужны независимо поставляемые bundle boundaries, требуется отдельный build/package mechanism за пределами текущего Level 2, а не искусственное разделение предметной области.
|
||||
|
||||
## Обязательный контракт ошибок
|
||||
Один публичный сценарий принадлежит ровно одному API. Если два API постоянно требуют одинаковых методов, состояния и lifecycle, их граница пересматривается вместо дублирования.
|
||||
|
||||
Каждый `business` объявляет устойчивые коды, безопасную readonly-форму и runtime guard. Типы публикуются через `business`, а runtime symbols через `business/error`:
|
||||
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 = {
|
||||
@@ -126,41 +192,16 @@ export const AUTH_ERROR_CODES = {
|
||||
OTP_CODE_INVALID: 'AUTH_OTP_CODE_INVALID',
|
||||
} as const
|
||||
|
||||
export type AuthErrorCode =
|
||||
typeof AUTH_ERROR_CODES[keyof typeof AUTH_ERROR_CODES]
|
||||
|
||||
export type AuthError = Readonly<{
|
||||
code: AuthErrorCode
|
||||
}>
|
||||
|
||||
const authErrorCodes = new Set<string>(Object.values(AUTH_ERROR_CODES))
|
||||
|
||||
export const isAuthError = (value: unknown): value is AuthError => {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return false
|
||||
}
|
||||
|
||||
const prototype = Object.getPrototypeOf(value)
|
||||
const keys = Reflect.ownKeys(value)
|
||||
|
||||
if (
|
||||
(prototype !== Object.prototype && prototype !== null)
|
||||
|| keys.length !== 1
|
||||
|| keys[0] !== 'code'
|
||||
|| !('code' in value)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
return typeof value.code === 'string' && authErrorCodes.has(value.code)
|
||||
return isSafeCodedError(value, Object.values(AUTH_ERROR_CODES))
|
||||
}
|
||||
```
|
||||
|
||||
Способ передачи ошибки, exception или discriminated `Result`, пока не закреплён. В обоих вариантах публичный сценарий сообщает ожидаемый сбой только через собственный `AuthError`.
|
||||
Guard не является обязательной частью каждого домена. При discriminated `Result` типизированному потребителю может быть достаточно error union; при exception, RPC или неизвестной runtime-границе guard часто нужен. Выбор `throw` или `Result` не меняет набор обязательных фасетов.
|
||||
|
||||
## Изоляция технических ошибок
|
||||
## Изоляция технических и чужих ошибок
|
||||
|
||||
Ошибки SDK, HTTP, database, storage и adapters не пересекают `DomainApi` в форме, доступной приложению. `business` преобразует обрабатываемый технический сбой в собственный код:
|
||||
Ошибки SDK, HTTP, database, storage и adapters не пересекают Domain API в форме, доступной приложению. `business` преобразует обрабатываемый технический сбой в собственный код:
|
||||
|
||||
```text
|
||||
SDK error
|
||||
@@ -170,8 +211,8 @@ SDK error
|
||||
→ приложение
|
||||
```
|
||||
|
||||
Публичная форма не включает исходные `message`, `status`, `payload`, `cause`, SDK class или сам объект ошибки. Диагностические данные могут сохраняться только во внутреннем механизме observability, контракт которого будет определён отдельно.
|
||||
Публичная форма не включает исходные `message`, `status`, `payload`, `cause`, SDK class или сам объект ошибки. Диагностические данные остаются во внутреннем observability-механизме.
|
||||
|
||||
То же относится к cross-domain вызову. Если `UserApi` использует `AuthApi`, сбой, который становится результатом публичного сценария User, представлен собственным `UserError`, а не `AuthError`.
|
||||
То же относится к cross-domain вызову. Если User API использует Auth API, сбой, который становится результатом публичного сценария User, представлен собственным `UserError`. При exception-модели зависимый business может импортировать `isAuthError` и error codes из `auth/business/runtime`, после чего преобразовать ожидаемую ошибку в собственный контракт.
|
||||
|
||||
Ошибки программирования и нарушенные внутренние инварианты не обязаны маскироваться под ожидаемые доменные ошибки. Способ отличить их от ожидаемых cross-domain ошибок при exception-модели и их диагностическая политика остаются открытыми вопросами; исходная ошибка при этом не добавляется в публичную форму DomainError.
|
||||
Ошибки программирования и нарушенные внутренние инварианты не обязаны маскироваться под ожидаемые доменные ошибки.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Граница доменного пакета
|
||||
|
||||
> Пояснение новой контейнерной сущности Level 2.
|
||||
> Пояснение контейнерной сущности Level 2 и её совместного использования с доменными модулями Level 1.
|
||||
|
||||
## Связанные правила
|
||||
|
||||
@@ -8,24 +8,26 @@
|
||||
- [`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-DOMAIN-A026`](../../rules/level-2.md#slm-l2-domain-a026)
|
||||
- [`SLM-L2-BUSINESS-A019`](../../rules/level-2.md#slm-l2-business-a019)
|
||||
- [`SLM-L2-PRESET-A020`](../../rules/level-2.md#slm-l2-preset-a020)
|
||||
- [`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-модули. Пакет `auth` может содержать business-сценарии авторизации, её browser/server presets и React-модули, но не страницу профиля или общий database client.
|
||||
Доменный пакет представляет одну связную предметную область и объединяет только принадлежащие ей SLM-модули. Пакет `auth` может содержать business-сценарии авторизации, её browser/server assemblies и React-модули, но не страницу профиля или общий database client.
|
||||
|
||||
Пакет не владеет исполняемой ответственностью. Конкретными API, состоянием, зависимостями и lifecycle владеют модули внутри него.
|
||||
|
||||
Level 2 применяется к пакету, а не ко всему SLM root. Например, `auth` может быть пакетом Level 2, пока `catalog` и `news` остаются доменными модулями Level 1.
|
||||
|
||||
## Корень пакета
|
||||
|
||||
```text
|
||||
domains/auth/
|
||||
├── README.md
|
||||
├── business/
|
||||
├── presets/
|
||||
├── assemblies/
|
||||
├── adapters/
|
||||
└── react/
|
||||
```
|
||||
@@ -36,8 +38,8 @@ domains/auth/
|
||||
- ownership metadata;
|
||||
- декларативный manifest или декларативная конфигурация архитектурной проверки;
|
||||
- обязательный модуль `business`;
|
||||
- обязательная непустая Group `presets`;
|
||||
- непустая Group `adapters`, если фабрика имеет технические зависимости;
|
||||
- обязательная непустая Group `assemblies`;
|
||||
- непустая Group `adapters`, если хотя бы одна фабрика имеет технические зависимости;
|
||||
- Framework Groups при наличии соответствующих модулей.
|
||||
|
||||
В корне запрещены:
|
||||
@@ -48,48 +50,62 @@ domains/auth/
|
||||
- реэкспорт API внутренних модулей;
|
||||
- page-specific компоненты или сборка нескольких доменов.
|
||||
|
||||
Metadata содержит только статические данные, не исполняется приложением, build tooling или проверяющим инструментом и не становится скрытым API пакета.
|
||||
Metadata содержит только статические данные. Проверяющий инструмент может читать её декларативно, но она не исполняется и не становится скрытым API пакета.
|
||||
|
||||
## Policy boundary
|
||||
|
||||
Доменный пакет не является узлом import-графа. Его техническая граница состоит из объявленного проверке набора дочерних модулей и правил, применяемых ко всем связям через эту границу.
|
||||
|
||||
Отсутствие root barrel намеренно:
|
||||
|
||||
- client- и server-entry points не агрегируются в один импорт;
|
||||
- каждый модуль сохраняет отдельную ответственность и environment boundary;
|
||||
- переименование публичного модуля является изменением его собственного контракта, а не скрывается пакетом;
|
||||
- versioning целого publishable package остаётся за пределами Level 2.
|
||||
|
||||
## Модули и Groups
|
||||
|
||||
`business` размещается непосредственно в пакете и предоставляет три публичных фасета: type-only barrel, `factory` и `error`. Presets размещаются в обязательной Group `presets`. Все production adapters являются самостоятельными модулями Group `adapters` и не определяются в других частях production-графа. Framework Group называется по фреймворку: `react`, `vue` и аналогично.
|
||||
`business` размещается непосредственно в пакете. Assemblies находятся в обязательной Group `assemblies`. Production adapters являются самостоятельными модулями Group `adapters`. Framework Group называется по фреймворку: `react`, `vue` и аналогично.
|
||||
|
||||
```text
|
||||
auth/
|
||||
├── business/ # SLM-модуль
|
||||
│ ├── index.ts # Только public types
|
||||
│ ├── factory.ts # Public factory entry
|
||||
│ └── error.ts # Public error runtime entry
|
||||
│ ├── factory.ts # Public factories entry
|
||||
│ └── runtime.ts # Необязательный deterministic runtime
|
||||
├── adapters/ # Group при наличии technical dependencies
|
||||
│ └── phone-http/ # SLM-модуль
|
||||
├── presets/ # Обязательная Group
|
||||
├── assemblies/ # Обязательная Group
|
||||
│ └── browser/ # SLM-модуль
|
||||
└── react/ # Framework Group
|
||||
├── session/ # SLM-модуль
|
||||
└── login-form/ # SLM-модуль
|
||||
```
|
||||
|
||||
Groups не имеют `index.ts`. Поэтому публичными путями являются `auth/business`, `auth/business/factory`, `auth/business/error`, `auth/adapters/phone-http`, `auth/presets/browser` и `auth/react/session`, но не `auth`, `auth/adapters`, `auth/presets` или `auth/react`.
|
||||
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
|
||||
|
||||
Слой `domains` может содержать навигационные Groups с пакетами:
|
||||
Слой `domains` может содержать Groups с обеими формами домена:
|
||||
|
||||
```text
|
||||
domains/
|
||||
└── commerce/ # Навигационная Group
|
||||
├── catalog/ # Доменный пакет
|
||||
└── orders/ # Доменный пакет
|
||||
├── catalog/ # Доменный модуль Level 1
|
||||
└── orders/ # Доменный пакет Level 2
|
||||
```
|
||||
|
||||
Такая Group отличается от Group внутри пакета только допустимым составом: она содержит доменные пакеты и другие navigation Groups, а не модули.
|
||||
Такая Group отличается от Group внутри пакета допустимым составом: она содержит доменные модули, доменные пакеты и другие navigation Groups, а не внутренние модули пакета.
|
||||
|
||||
Одна предметная область не представляется одновременно модулем и пакетом. Переход завершается удалением старой границы именно этого домена, а не переводом всех соседних областей.
|
||||
|
||||
## Границы соседних слоёв
|
||||
|
||||
| Ответственность | Владелец |
|
||||
|---|---|
|
||||
| Предметные сценарии, `DomainApi`, доменные ошибки | `business` |
|
||||
| Предметные сценарии, Domain API, доменные ошибки | `business` |
|
||||
| Техническая реализация зависимости одного домена | Adapter внутри пакета |
|
||||
| Сборка API для именованного контекста | Assembly внутри пакета |
|
||||
| Универсальный технический сервис | `infra` |
|
||||
| Domain-specific framework API | Модуль внутри `react`, `vue` и аналогичной Group |
|
||||
| Страница, маршрут, redirect, продуктовый текст | `compositions` |
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Фабрика, зависимости и adapters
|
||||
# Фабрики, зависимости и adapters
|
||||
|
||||
> Пояснение границы между `business` и технической средой.
|
||||
|
||||
@@ -8,31 +8,45 @@
|
||||
- [`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-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)
|
||||
|
||||
## Одна фабрика
|
||||
## Одна фабрика на API
|
||||
|
||||
```text
|
||||
явные зависимости + business factory → DomainApi
|
||||
явные зависимости + business factory → один Domain API
|
||||
```
|
||||
|
||||
Модуль `business` предоставляет одну публичную фабрику. Она получает все runtime-возможности явными аргументами и создаёт API одного контракта независимо от выбранного preset.
|
||||
Модуль `business` предоставляет одну именованную фабрику для каждого объявленного API. Фабрики экспортируются через общий runtime-фасет `business/factory`:
|
||||
|
||||
```ts
|
||||
import type { AuthApi, AuthDeps } from '@/domains/auth/business'
|
||||
import type {
|
||||
AuthAdministrationApi,
|
||||
AuthAdministrationDeps,
|
||||
AuthSessionApi,
|
||||
AuthSessionDeps,
|
||||
} from '@/domains/auth/business'
|
||||
|
||||
export type AuthFactory = (deps: AuthDeps) => AuthApi
|
||||
export type AuthSessionFactory = (
|
||||
deps: AuthSessionDeps,
|
||||
) => AuthSessionApi
|
||||
|
||||
export type AuthAdministrationFactory = (
|
||||
deps: AuthAdministrationDeps,
|
||||
) => AuthAdministrationApi
|
||||
```
|
||||
|
||||
Runtime-фабрика импортируется только через отдельный entry point:
|
||||
|
||||
```ts
|
||||
import { authFactory } from '@/domains/auth/business/factory'
|
||||
import {
|
||||
authAdministrationFactory,
|
||||
authSessionFactory,
|
||||
} from '@/domains/auth/business/factory'
|
||||
```
|
||||
|
||||
Рекомендуется сохранять сам вызов фабрики чистым: он создаёт объекты и closures, но не читает скрытое окружение и не выбирает конкретную техническую реализацию. Детальный lifecycle ресурсов будет нормирован позже.
|
||||
Фабрика не выбирает environment, assembly или конкретную production-реализацию зависимости. Разные API могут иметь разные наборы deps и собираться независимо.
|
||||
|
||||
## Технические зависимости
|
||||
|
||||
@@ -47,6 +61,28 @@ export type AuthPhoneDependency = {
|
||||
|
||||
`unknown` допустим на границе непроверенного внешнего результата. `business` проверяет его до превращения в предметные данные или состояние.
|
||||
|
||||
Техническими зависимостями также являются:
|
||||
|
||||
- concrete state/query runtime;
|
||||
- subscription и event source;
|
||||
- browser, Node.js и framework capabilities;
|
||||
- request data и abort signal;
|
||||
- текущее время и timer;
|
||||
- random и ID generator;
|
||||
- environment и runtime configuration provider.
|
||||
|
||||
```ts
|
||||
export type VerificationDeps = {
|
||||
clock: { now: () => number }
|
||||
ids: { create: () => string }
|
||||
timer: { delay: (ms: number) => Promise<void> }
|
||||
}
|
||||
```
|
||||
|
||||
`Date.now()`, `new Date()` без аргумента, `Math.random()`, `crypto.randomUUID()`, скрытый env и глобальный timer не читаются напрямую business-кодом, если влияют на поведение сценария. Детерминированная арифметика над переданным timestamp остаётся business-safe.
|
||||
|
||||
Cancellation также описывается business-owned контрактом. Concrete `AbortSignal` или другой platform type остаётся внутри adapter, пока не принят отдельный общий публичный контракт.
|
||||
|
||||
Термин и обязательная поведенческая форма технического порта пока не закреплены. В частности, позднее нужно определить cancellation, timeout, retry, concurrency и subscription semantics.
|
||||
|
||||
## Cross-domain API dependency
|
||||
@@ -54,61 +90,69 @@ export type AuthPhoneDependency = {
|
||||
Готовый API другого домена не считается техническим adapter-портом. Это отдельная cross-domain API dependency, выраженная type-only контрактом:
|
||||
|
||||
```ts
|
||||
import type { AuthApi } from '@/domains/auth/business'
|
||||
import type { AuthSessionApi } from '@/domains/auth/business'
|
||||
|
||||
export type UserDeps = {
|
||||
auth: Pick<AuthApi, 'getSession'>
|
||||
auth: Pick<AuthSessionApi, 'getSnapshot'>
|
||||
}
|
||||
```
|
||||
|
||||
Runtime-значение место сборки графа передаёт через preset либо напрямую зависимой business-фабрике. `user/business` не импортирует executable API, factory или preset Auth.
|
||||
Runtime-значение место сборки графа передаёт через assembly либо напрямую зависимой business-фабрике. `user/business` не импортирует executable factory, assembly или instance Auth.
|
||||
|
||||
Если exception-модели нужен runtime guard чужой ожидаемой ошибки, зависимый business может импортировать его из детерминированного `auth/business/runtime`. Этот импорт остаётся обычным ребром DAG.
|
||||
|
||||
## Adapter module
|
||||
|
||||
Adapter module соединяет явную техническую зависимость фабрики с конкретной системой:
|
||||
|
||||
```text
|
||||
business dependency ← adapter → SDK / storage / platform / request data
|
||||
business dependency ← adapter → SDK / query runtime / platform / request data
|
||||
```
|
||||
|
||||
Adapter преобразует аргументы и технический результат к контракту зависимости. Он не определяет публичный метод `DomainApi`, предметный fallback или код доменной ошибки.
|
||||
Adapter преобразует аргументы и технический результат к контракту зависимости. Он не определяет публичный метод Domain API, предметный fallback или код доменной ошибки.
|
||||
|
||||
Ожидаемый исходный сбой возвращается `business`, который выбирает собственный error code. Поэтому приложение никогда не строит поведение по HTTP status, SDK error class или storage exception.
|
||||
Ожидаемый исходный сбой возвращается `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`, даже если пока используется одним preset:
|
||||
Каждая связная production-реализация является отдельным SLM-модулем в Group `adapters`:
|
||||
|
||||
```text
|
||||
auth/adapters/
|
||||
├── phone-http/
|
||||
│ └── index.ts
|
||||
└── browser-session/
|
||||
├── 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 запрещено определять:
|
||||
|
||||
- закрытым сегментом preset;
|
||||
- закрытым сегментом assembly;
|
||||
- inline-функцией в `composition` или `app`;
|
||||
- частью framework binding module;
|
||||
- скрытой реализацией внутри `business`.
|
||||
|
||||
Preset и одноразовое место сборки импортируют конкретные adapter-модули через их публичные API:
|
||||
Assembly и одноразовое место сборки импортируют конкретные adapter-модули через их публичные API:
|
||||
|
||||
```ts
|
||||
import { authFactory } from '@/domains/auth/business/factory'
|
||||
import { authSessionFactory } from '@/domains/auth/business/factory'
|
||||
import { createPhoneHttpAdapter } from '@/domains/auth/adapters/phone-http'
|
||||
import { createBrowserSessionAdapter } from '@/domains/auth/adapters/browser-session'
|
||||
import { createBrowserRuntimeAdapter } from '@/domains/auth/adapters/browser-runtime'
|
||||
|
||||
const authApi = authFactory({
|
||||
const session = authSessionFactory({
|
||||
phone: createPhoneHttpAdapter(),
|
||||
session: createBrowserSessionAdapter(),
|
||||
runtime: createBrowserRuntimeAdapter(),
|
||||
})
|
||||
```
|
||||
|
||||
Если фабрика не имеет технических зависимостей, Group `adapters` не обязательна. Cross-domain API dependency не считается adapter и передаётся отдельно.
|
||||
Если ни одна фабрика не имеет технических зависимостей, Group `adapters` не обязательна. Cross-domain API dependency не считается adapter и передаётся отдельно.
|
||||
|
||||
Локальные fake implementations в business-тестах не являются production adapters и не требуют SLM-модулей. Они существуют только внутри тестовой границы и не экспортируются в рабочий код.
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
## Связанные правила
|
||||
|
||||
- [`SLM-L2-BUSINESS-R006`](../../rules/level-2.md#slm-l2-business-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)
|
||||
@@ -20,6 +21,8 @@ domains/auth/react/ # Framework Group
|
||||
│ ├── hooks/
|
||||
│ ├── providers/
|
||||
│ └── index.ts
|
||||
├── queries/ # SLM-модуль
|
||||
│ └── index.ts
|
||||
└── login-form/ # SLM-модуль
|
||||
├── components/
|
||||
└── index.ts
|
||||
@@ -31,43 +34,44 @@ domains/auth/react/ # Framework Group
|
||||
|
||||
## Framework binding module
|
||||
|
||||
Модуль принадлежит Framework Group, если его самостоятельная ответственность состоит в связывании готового `DomainApi` одного домена с конкретным framework. Сам факт зависимости от framework недостаточен: framework-specific preset остаётся в `presets`, а page-specific модуль остаётся в `compositions`.
|
||||
Модуль принадлежит Framework Group, если его самостоятельная ответственность состоит в связывании одного или нескольких готовых Domain API своего домена с конкретным framework. Сам факт зависимости от framework недостаточен: framework-specific assembly остаётся в `assemblies`, а page-specific модуль остаётся в `compositions`.
|
||||
|
||||
Framework binding module может:
|
||||
|
||||
- передавать готовый `DomainApi` через Provider и context;
|
||||
- передавать готовые API через Provider и context;
|
||||
- предоставлять domain-specific hooks;
|
||||
- отображать состояние и безопасные ошибки домена;
|
||||
- использовать framework-compatible state/query runtime;
|
||||
- реализовывать переиспользуемую domain-specific форму или guard;
|
||||
- связывать framework lifecycle с публичным API домена.
|
||||
- связывать framework lifecycle с явными операциями Domain API.
|
||||
|
||||
Он не вызывает business-фабрику или preset, не выбирает adapters и не создаёт новые предметные сценарии.
|
||||
Он не вызывает business-фабрику или assembly, не выбирает adapters и не создаёт новые предметные сценарии.
|
||||
|
||||
Framework binding module импортирует типы и runtime error contract через разные фасеты:
|
||||
Framework binding импортирует типы и deterministic runtime через разные фасеты:
|
||||
|
||||
```ts
|
||||
import type {
|
||||
AuthApi,
|
||||
AuthError,
|
||||
AuthSessionApi,
|
||||
} from '@/domains/auth/business'
|
||||
|
||||
import {
|
||||
AUTH_ERROR_CODES,
|
||||
isAuthError,
|
||||
} from '@/domains/auth/business/error'
|
||||
} from '@/domains/auth/business/runtime'
|
||||
```
|
||||
|
||||
Импорт `business/factory` из Framework Group запрещён: готовый `DomainApi` передаётся модулю извне.
|
||||
Импорт `business/factory` запрещён: готовые API передаются модулю извне.
|
||||
|
||||
## Модуль session
|
||||
|
||||
`auth/react/session` может владеть Provider и hooks доступа к уже созданному `AuthApi`:
|
||||
`auth/react/session` может владеть Provider и hooks доступа к уже созданному `AuthSessionApi`:
|
||||
|
||||
```tsx
|
||||
'use client'
|
||||
|
||||
type AuthSessionProviderProps = PropsWithChildren<{
|
||||
api: AuthApi
|
||||
api: AuthSessionApi
|
||||
}>
|
||||
|
||||
export const AuthSessionProvider = ({
|
||||
@@ -93,15 +97,34 @@ import {
|
||||
|
||||
Импорт `@/domains/auth/react` запрещён, потому что Group не имеет API.
|
||||
|
||||
## State/query runtime
|
||||
|
||||
`auth/react/queries` может использовать TanStack Query, SWR или другой React runtime поверх готового `AuthSessionApi`. Query cache остаётся framework projection, пока данные и transitions проходят через API, а optimistic values создаются или проверяются business-владельцем.
|
||||
|
||||
```ts
|
||||
export const useAuthSessionQuery = () => {
|
||||
const api = useAuthSession()
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['auth', 'session'],
|
||||
queryFn: api.getCurrentSession,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
Server prefetch и client hook могут быть разными модулями Framework Group с совместимыми environment markers. Общая query policy выносится в отдельный framework-модуль при наличии нескольких потребителей, а не дублируется в compositions.
|
||||
|
||||
Подробности описаны в [Состоянии и кэше](./state-cache.md).
|
||||
|
||||
## Модуль login-form
|
||||
|
||||
`auth/react/login-form` может владеть переиспользуемой формой, если она работает только с `AuthApi`, AuthState и AuthError. Она может использовать публичный API соседнего `auth/react/session`, если зависимость остаётся ацикличной.
|
||||
`auth/react/login-form` может владеть переиспользуемой формой, если она работает только с Auth API, состоянием и ошибками своего домена. Она может использовать публичный API соседнего `auth/react/session`, если зависимость остаётся ацикличной.
|
||||
|
||||
Конкретная страница, продуктовый текст, layout, redirect и выбор маршрута принадлежат `compositions`. Поэтому domain-owned `AuthGuard` может решить, разрешён ли доступ, но политика перехода на `/login` остаётся у route composition.
|
||||
Конкретная страница, продуктовый текст, layout, redirect и выбор маршрута принадлежат `compositions`. Domain-owned guard может решить, разрешено ли действие, но политика перехода на `/login` остаётся у route composition.
|
||||
|
||||
## Запрет cross-domain framework imports
|
||||
|
||||
Framework binding module не импортирует hooks, contexts, Providers, components или framework state другого доменного пакета:
|
||||
Framework binding module не импортирует hooks, contexts, Providers, components или framework state другого домена:
|
||||
|
||||
```ts
|
||||
// Недопустимо: domains/user/react/profile
|
||||
@@ -121,7 +144,7 @@ return (
|
||||
)
|
||||
```
|
||||
|
||||
Передача через props является границей композиции, но не требует prop drilling внутри домена: каждый пакет может использовать собственный Provider и context. Если User business постоянно нуждается в Auth, готовый `AuthApi` передаётся User factory при сборке графа, а User framework module работает уже со своим `UserApi`.
|
||||
Передача через props является границей композиции, но не требует prop drilling внутри домена: каждый пакет может использовать собственный Provider и context. Если User business постоянно нуждается в Auth, готовый `AuthSessionApi` передаётся User factory при сборке графа, а User framework module работает уже со своим API.
|
||||
|
||||
## Публичные API
|
||||
|
||||
|
||||
@@ -4,44 +4,49 @@
|
||||
|
||||
## Зафиксированные решения
|
||||
|
||||
- Level 1 включает слой `domains` и простые доменные модули.
|
||||
- Level 2 заменяет доменный модуль доменным пакетом.
|
||||
- Корень пакета содержит только metadata, модули и Groups и не имеет executable API.
|
||||
- `business` предоставляет одну фабрику и один `DomainApi`.
|
||||
- Публичный API `business` разделён на type-only barrel, `business/factory` и `business/error`; другие пути запрещены.
|
||||
- Приложение получает доменные данные, состояние и результаты только через `DomainApi`.
|
||||
- Каждый `business` экспортирует коды, тип и runtime guard доменных ошибок.
|
||||
- Ожидаемые ошибки adapters и других доменов не пересекают API текущего домена.
|
||||
- Каждый доменный пакет содержит минимум один preset; универсальный изоморфный preset не обязателен.
|
||||
- При наличии технических зависимостей Group `adapters` обязательна, а каждая production implementation является отдельным SLM-модулем.
|
||||
- Все production consumers используют публичные adapter-модули; inline adapter implementations вне Group `adapters` запрещены.
|
||||
- Одноразовая composition может вызвать `business/factory` напрямую; это не отменяет обязательный preset пакета.
|
||||
- Runtime cross-domain imports запрещены; type-only business contracts разрешены и входят в DAG.
|
||||
- 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`.
|
||||
|
||||
## Владение состоянием
|
||||
|
||||
Нужно определить создание initial state, применение transitions, persistence и внешний event input. Нормативным уже является только то, что приложение читает состояние через `DomainApi`, но точная граница между business state machine и storage adapter пока не выбрана.
|
||||
Нужно подробнее определить создание initial state, применение transitions, persistence и внешний event input. Нормативным уже является то, что предметную модель и переходы определяет `business`, а concrete state manager реализует business-owned port либо framework projection.
|
||||
|
||||
Отдельно требуется проверить SSR snapshot, hydration, concurrent rendering, reset и поведение после завершения request scope.
|
||||
|
||||
## Передача ошибок
|
||||
|
||||
Нужно выбрать общую политику exception или discriminated `Result`, определить cancellation и unexpected failures, а также сериализацию ошибок через RPC и server actions.
|
||||
Нужно выбрать общую рекомендацию exception или discriminated `Result`, определить cancellation и unexpected failures, а также сериализацию ошибок через RPC и server actions.
|
||||
|
||||
Для exception-модели отдельно нужно определить, как зависимый business отличает ожидаемую ошибку другого домена без runtime-импорта его guard: через переданный discriminator, wrapper места сборки или другой явный контракт.
|
||||
Структура публичных фасетов от этого решения больше не зависит: type errors публикуются через `business`, а необходимые runtime codes и guards через опциональный `business/runtime`.
|
||||
|
||||
## Технические порты
|
||||
|
||||
Нужно определить, является ли consumer-owned port обязательной формой каждой технической зависимости и какие behavioral guarantees он обязан описывать: timeout, retry, cancellation, idempotency, ordering, concurrency и subscription cleanup.
|
||||
Нужно определить, является ли consumer-owned port обязательной формой каждой технической зависимости и какие behavioral guarantees он описывает: timeout, retry, cancellation, idempotency, ordering, concurrency и subscription cleanup.
|
||||
|
||||
Cross-domain `Pick<OtherDomainApi>` уже зафиксирован как отдельный вид API dependency и не зависит от этого решения.
|
||||
Cross-domain API dependency уже зафиксирована как отдельный вид зависимости и не зависит от этого решения.
|
||||
|
||||
## Lifecycle сборки
|
||||
|
||||
Нужно определить контракты `start` и `dispose`, async cleanup, rollback частичного старта, repeated disposal, request abort и поведение API после завершения scope. До решения применяется только общее владение lifecycle Level 1.
|
||||
Уже зафиксировано, что явная операция, запускающая ресурс, возвращает cleanup, а assembly с собственным ресурсом предоставляет cleanup handle результата. Ещё нужно определить async cleanup, rollback частичной сборки, repeated disposal, request abort и поведение API после завершения scope.
|
||||
|
||||
## Cache hydration
|
||||
|
||||
Нужно проверить единый способ разделять framework-neutral cache policy, client hooks, server prefetch и serialization boundary без утечки query-library types в Domain API.
|
||||
|
||||
## Автоматическая проверка
|
||||
|
||||
Нужно выбрать machine-readable формат для domain packages, metadata, модулей, Groups, entry points, environment labels и запрещённых транзитивных импортов.
|
||||
Нужно выбрать machine-readable формат для форм домена, metadata, модулей, Groups, entry points, environment labels и запрещённых транзитивных импортов.
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
# Presets и среды выполнения
|
||||
|
||||
> Пояснение повторяемых сборок одного `DomainApi`.
|
||||
|
||||
## Связанные правила
|
||||
|
||||
- [`SLM-L2-FACTORY-R008`](../../rules/level-2.md#slm-l2-factory-r008)
|
||||
- [`SLM-L2-PRESET-R011`](../../rules/level-2.md#slm-l2-preset-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-PRESET-A020`](../../rules/level-2.md#slm-l2-preset-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)
|
||||
|
||||
## Назначение
|
||||
|
||||
Preset является SLM-модулем в Group `presets`. Он создаёт API одной business-фабрики для конкретного повторяемого контекста выполнения. Каждый доменный пакет содержит минимум один preset-модуль.
|
||||
|
||||
```text
|
||||
authFactory
|
||||
├── presets/browser → AuthApi в браузере
|
||||
├── presets/request → AuthApi одного server request
|
||||
└── presets/server-action → AuthApi server action
|
||||
```
|
||||
|
||||
Архитектура не требует `base` или изоморфный preset и не ограничивает максимальное количество presets. Обязательный preset должен соответствовать реальному поддерживаемому контексту, а не существовать только для заполнения структуры.
|
||||
|
||||
Место сборки графа в `composition` может вызвать фабрику напрямую для одноразовой конфигурации. Такая сборка не отменяет обязательный preset пакета и при наличии технических зависимостей использует публичные adapter-модули, а не inline implementations.
|
||||
|
||||
## Один контракт API
|
||||
|
||||
Каждый preset вызывает одну и ту же фабрику и возвращает один контракт `DomainApi`. При наличии технических зависимостей preset выбирает их публичные adapter-модули:
|
||||
|
||||
```ts
|
||||
import type { AuthApi } from '@/domains/auth/business'
|
||||
import { authFactory } from '@/domains/auth/business/factory'
|
||||
import { createPhoneHttpAdapter } from '@/domains/auth/adapters/phone-http'
|
||||
import { createBrowserSessionAdapter } from '@/domains/auth/adapters/browser-session'
|
||||
|
||||
export const createBrowserAuth = (): AuthApi => {
|
||||
return authFactory({
|
||||
phone: createPhoneHttpAdapter(),
|
||||
session: createBrowserSessionAdapter(),
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
import type { AuthApi } from '@/domains/auth/business'
|
||||
import { authFactory } from '@/domains/auth/business/factory'
|
||||
import { createRequestPhoneAdapter } from '@/domains/auth/adapters/request-phone'
|
||||
import { createRequestSessionAdapter } from '@/domains/auth/adapters/request-session'
|
||||
|
||||
export const createAuthForRequest = (
|
||||
input: AuthRequestInput,
|
||||
): AuthApi => {
|
||||
return authFactory({
|
||||
phone: createRequestPhoneAdapter(input),
|
||||
session: createRequestSessionAdapter(input),
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
Server preset может обращаться к database напрямую через adapter, а browser preset реализует тот же сценарий через HTTP или RPC. Preset не добавляет server-only метод к `AuthApi` и не меняет доменные ошибки.
|
||||
|
||||
Если полный `DomainApi` невозможно корректно создать в некоторой среде, пакет просто не предоставляет preset для этой среды. Метод, намеренно падающий только потому, что среда не поддерживается, не считается реализацией контракта.
|
||||
|
||||
## Cross-domain input
|
||||
|
||||
Preset зависимого домена принимает готовый API аргументом. Cross-domain API не является adapter и не размещается в Group `adapters`:
|
||||
|
||||
```ts
|
||||
import type { AuthApi } from '@/domains/auth/business'
|
||||
import type { UserApi } from '@/domains/user/business'
|
||||
import { userFactory } from '@/domains/user/business/factory'
|
||||
import { createUserProfileAdapter } from '@/domains/user/adapters/profile'
|
||||
|
||||
export type CreateUserForRequestInput = {
|
||||
authApi: Pick<AuthApi, 'getSession'>
|
||||
request: UserRequestInput
|
||||
}
|
||||
|
||||
export const createUserForRequest = ({
|
||||
authApi,
|
||||
request,
|
||||
}: CreateUserForRequestInput): UserApi => {
|
||||
return userFactory({
|
||||
auth: authApi,
|
||||
profile: createUserProfileAdapter(request),
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
Preset делает только type-only импорт `AuthApi`. Runtime-фабрику, preset или instance Auth он не импортирует.
|
||||
|
||||
Место сборки графа выполняет сборку:
|
||||
|
||||
```ts
|
||||
const authApi = createAuthForRequest(authInput)
|
||||
const userApi = createUserForRequest({ authApi, request: userInput })
|
||||
```
|
||||
|
||||
## Environment entry points
|
||||
|
||||
Server preset имеет отдельный публичный entry point и marker выбранного framework или bundler:
|
||||
|
||||
```ts
|
||||
import 'server-only'
|
||||
|
||||
export { createAuthForRequest } from './create-auth-for-request'
|
||||
```
|
||||
|
||||
Server entry point не реэкспортируется через `business`, Framework Group, browser preset или корень доменного пакета. Аналогично client-only код не достигается из server/shared entry point, если выбранная среда запрещает такую зависимость.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
Preset может создавать ресурсы, которым потребуется запуск или cleanup, но точная форма `start`, `dispose`, rollback и request abort пока не нормирована. До принятия решения действует общее правило владения lifecycle Level 1.
|
||||
114
DRAFT/level-2/domains/state-cache.md
Normal file
114
DRAFT/level-2/domains/state-cache.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# Состояние и кэш
|
||||
|
||||
> Пояснение границы между предметной властью business и техническими state/query runtimes.
|
||||
|
||||
## Связанные правила
|
||||
|
||||
- [`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-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)
|
||||
|
||||
## Библиотеки не запрещены
|
||||
|
||||
Запрет state/query manager в import-графе `business` не запрещает TanStack Query, SWR, Apollo, RTK Query, Zustand, Redux, MobX или RxJS в доменном пакете. Он запрещает concrete runtime становиться предметным контрактом.
|
||||
|
||||
Такая библиотека может находиться:
|
||||
|
||||
- в 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
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Adapter поверх Zustand или другого manager реализует этот контракт, но не выбирает initial state и не добавляет переходы.
|
||||
|
||||
### Technical source cache
|
||||
|
||||
Кэш SDK, HTTP, GraphQL или storage-вызовов. Adapter может использовать QueryClient, Apollo cache или иной runtime для deduplication, transport retry и хранения внешних результатов. Business по-прежнему проверяет и преобразует результат до публикации предметной модели.
|
||||
|
||||
Query keys и библиотечные result types остаются внутри adapter. Domain API не экспортирует `UseQueryResult`, `ApolloError`, raw DTO или mutable QueryClient.
|
||||
|
||||
Если technical cache создаёт timers, subscriptions или другой lifecycle-ресурс для всего графа, владеющая им assembly передаёт cleanup graph owner. Cache без такого ресурса не требует искусственного `dispose`.
|
||||
|
||||
### Framework projection cache
|
||||
|
||||
Кэш, который framework binding строит поверх вызовов готового Domain API для rendering, Suspense, revalidation или UI coordination.
|
||||
|
||||
```ts
|
||||
const useProfile = () => {
|
||||
const api = useUserApi()
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['user', 'profile'],
|
||||
queryFn: api.getProfile,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
Такой cache не является параллельным предметным источником, пока его значения происходят из Domain API и все предметные изменения выполняются через API.
|
||||
|
||||
## Invalidation и retry
|
||||
|
||||
Не каждая cache policy является бизнес-правилом.
|
||||
|
||||
| Политика | Обычный владелец |
|
||||
|---|---|
|
||||
| Query key, stale time, deduplication, background refetch | Adapter или framework binding |
|
||||
| Rendering stale data, Suspense, polling UI | Framework binding или composition |
|
||||
| Transport retry безопасного запроса | Adapter |
|
||||
| Запрет повторной предметной команды | `business` |
|
||||
| Cooldown, лимит попыток, допустимый transition | `business` |
|
||||
| Freshness, влияющая на корректность сценария | `business` через явный контракт |
|
||||
|
||||
Если после команды требуется invalidation, framework binding может связать успешный результат API с техническими keys. Если выбор invalidation выражает предметную семантику, business возвращает устойчивый outcome/event либо публикует чистое правило через `business/runtime`; binding только отображает его на библиотечные операции.
|
||||
|
||||
## Optimistic updates
|
||||
|
||||
Framework binding не конструирует произвольную предметную модель из input формы или transport DTO. Optimistic update допустим, когда предполагаемое значение:
|
||||
|
||||
- возвращено командой Domain API как безопасная projection;
|
||||
- создано отдельным pure-методом Domain API;
|
||||
- создано или проверено публичной функцией `business/runtime`.
|
||||
|
||||
```ts
|
||||
const optimisticProfile = projectProfileUpdate(currentProfile, command)
|
||||
|
||||
queryClient.setQueryData(profileKey, optimisticProfile)
|
||||
```
|
||||
|
||||
Здесь `projectProfileUpdate` принадлежит `business/runtime`, а `setQueryData` остаётся технической операцией framework binding.
|
||||
|
||||
Запись raw form data или DTO напрямую в публичный product cache обходит предметного владельца и нарушает границу.
|
||||
|
||||
## Browser, SSR и RSC
|
||||
|
||||
Client hook, server prefetch и hydrate/dehydrate могут принадлежать разным framework binding modules с совместимыми environment entry points. Общая framework-specific cache policy выносится в отдельный модуль той же Framework Group, если у неё несколько реальных потребителей.
|
||||
|
||||
`compositions` вызывает публичный server binding для prefetch и публичный client binding для rendering. Она не повторяет query keys и mapping доменных результатов.
|
||||
|
||||
## Проверка на ревью
|
||||
|
||||
Для каждого state/query runtime определяется:
|
||||
|
||||
- является ли он adapter, framework projection или локальным UI state;
|
||||
- откуда поступают значения;
|
||||
- кто определяет transition и optimistic projection;
|
||||
- где находятся library-specific types и keys;
|
||||
- как invalidation соотносится с результатами Domain API;
|
||||
- соответствует ли cache lifecycle области жизни API и framework scope.
|
||||
@@ -6,9 +6,11 @@
|
||||
|
||||
- [`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-PRESET-A020`](../../rules/level-2.md#slm-l2-preset-a020)
|
||||
- [`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-ASSEMBLY-R023`](../../rules/level-2.md#slm-l2-assembly-r023)
|
||||
- [`SLM-L2-BUSINESS-R024`](../../rules/level-2.md#slm-l2-business-r024)
|
||||
|
||||
## Размещение
|
||||
|
||||
@@ -16,59 +18,74 @@
|
||||
|
||||
| Проверяемая граница | Владелец теста |
|
||||
|---|---|
|
||||
| Предметные сценарии, `DomainApi`, данные и ошибки | `business` |
|
||||
| Сценарии, Domain API, данные и ошибки | `business` |
|
||||
| Публичная pure-функция или guard | `business/runtime` внутри тестов модуля business |
|
||||
| Техническое преобразование | Adapter |
|
||||
| Выбор зависимостей и environment boundary | Preset |
|
||||
| Provider, hook, form или guard | Соответствующий framework binding module |
|
||||
| Выбор API, dependencies и environment boundary | Assembly |
|
||||
| Provider, hook, query integration, form или guard | Соответствующий framework binding module |
|
||||
| Граф нескольких доменов | Модуль `composition`, точка входа `app` или другое место сборки |
|
||||
|
||||
## Business через фабрику
|
||||
|
||||
Каждый публичный предметный сценарий проверяется через единственную фабрику с управляемыми зависимостями:
|
||||
Каждый публичный сценарий проверяется через фабрику владеющего им API с управляемыми dependencies:
|
||||
|
||||
```ts
|
||||
import type { AuthApi } from '@/domains/auth/business'
|
||||
import { authFactory } from '@/domains/auth/business/factory'
|
||||
import type { AuthSessionApi } from '@/domains/auth/business'
|
||||
import { authSessionFactory } from '@/domains/auth/business/factory'
|
||||
import {
|
||||
AUTH_ERROR_CODES,
|
||||
isAuthError,
|
||||
} from '@/domains/auth/business/error'
|
||||
} from '@/domains/auth/business/runtime'
|
||||
|
||||
const api: AuthApi = authFactory(createAuthTestDeps({
|
||||
requestCode: async () => ({ ok: true }),
|
||||
const api: AuthSessionApi = authSessionFactory(createAuthSessionTestDeps({
|
||||
clock: { now: () => 1_700_000_000_000 },
|
||||
phone: { requestCode: async () => ({ ok: true }) },
|
||||
}))
|
||||
|
||||
await api.requestPhoneOtp('+79991112233')
|
||||
```
|
||||
|
||||
Набор проверяет успешные и ожидаемые ошибочные результаты, validation, преобразование внешних данных и публичные изменения состояния. Способ assertion для ошибки зависит от будущего решения `throw` или `Result`, но наружу всегда проверяется только AuthErrorCode.
|
||||
Набор проверяет успешные и ожидаемые ошибочные результаты, validation, преобразование внешних данных и публичные изменения состояния. Способ assertion для ошибки зависит от `throw` или `Result`, но наружу проверяется только собственный AuthErrorCode.
|
||||
|
||||
Business-тест не использует React, реальный SDK, database или production preset. Локальная fake implementation допустима в тесте и не становится adapter-модулем, потому что не входит в production graph.
|
||||
Business-тест не использует React, реальный SDK, database, production assembly или системные clock/random. Локальная fake implementation допустима в тесте и не становится adapter-модулем, потому что не входит в production graph.
|
||||
|
||||
Если `business` объявляет несколько API, каждый тестируется через свою фабрику. Общая внутренняя pure-логика не требует повторять одинаковые cases на уровне всех API.
|
||||
|
||||
## Остальные модули
|
||||
|
||||
Тест каждого adapter-модуля проверяет технический вызов, аргументы, преобразование результата и передачу исходного сбоя business-слою. Он не повторяет mapping в доменные ошибки.
|
||||
Тест adapter-модуля проверяет технический вызов, аргументы, преобразование результата и передачу исходного сбоя business-слою. Он не повторяет mapping в доменные ошибки.
|
||||
|
||||
Тест обязательного preset проверяет вызов `business/factory`, контракт возвращённого `DomainApi` и отсутствие несовместимого environment-кода. Если фабрика имеет технические зависимости, тест также проверяет выбранные публичные adapter-модули; adapterless preset проверяет корректную сборку без Group `adapters`.
|
||||
Тест обязательной assembly проверяет:
|
||||
|
||||
Framework-тест импортирует только конкретный модуль, например `auth/react/session`, передаёт тестовый `AuthApi` и проверяет Provider, hook или component. `login-form` не повторяет полный набор business-сценариев.
|
||||
- вызов только нужных business-фабрик;
|
||||
- точный именованный состав возвращённого графа;
|
||||
- выбор публичных adapter-модулей;
|
||||
- отсутствие несовместимого environment-кода;
|
||||
- передачу cross-domain API аргументом, а не импортом;
|
||||
- cleanup handle, если assembly создаёт ресурс жизненного цикла.
|
||||
|
||||
Assembly без собственного lifecycle-ресурса не тестирует пустой `dispose`, потому что не обязана его предоставлять.
|
||||
|
||||
Framework-тест импортирует только конкретный модуль, например `auth/react/session`, передаёт тестовый `AuthSessionApi` и проверяет Provider, hook или component. Query binding дополнительно проверяет keys, invalidation и отсутствие raw DTO в публичном результате, но не повторяет полный набор business-сценариев.
|
||||
|
||||
## Автоматические структурные проверки
|
||||
|
||||
Проверка файлов, exports и import-графа подтверждает:
|
||||
|
||||
- отсутствие root API доменного пакета и Framework Groups;
|
||||
- наличие ровно трёх фасетов `business`, type-only exports в корневом barrel и отсутствие type exports в runtime-фасетах;
|
||||
- соблюдение матрицы потребителей `business`, `business/factory` и `business/error`;
|
||||
- наличие непосредственно в корне пакета непустой Group `presets` с объявленными модульными границами;
|
||||
- отсутствие runtime cross-domain imports;
|
||||
- отсутствие type-only импортов из чужих presets, adapters и framework-модулей;
|
||||
- обязательные `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 циклов.
|
||||
|
||||
## Архитектурное ревью
|
||||
|
||||
На ревью проверяется, что `business/factory` экспортирует только фабрику, а `business/error` только error codes и guards. Для каждой технической зависимости рассматриваются все production implementations: каждая должна принадлежать отдельному модулю Group `adapters`, даже если используется один раз. Inline implementations во всём production-графе запрещены, а test-only fakes из этой проверки исключены.
|
||||
На ревью проверяется, что `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-тест не заменяет автоматическую проверку или архитектурное ревью.
|
||||
|
||||
Reference in New Issue
Block a user