mirror of
https://github.com/gromlab-ru/slm-design.git
synced 2026-08-22 07:30:16 +03:00
219 lines
11 KiB
Markdown
219 lines
11 KiB
Markdown
# Модуль 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`, после чего преобразовать ожидаемую ошибку в собственный контракт.
|
||
|
||
Ошибки программирования и нарушенные внутренние инварианты не обязаны маскироваться под ожидаемые доменные ошибки.
|