11 KiB
Модуль business
Пояснение предметного владельца, нескольких Domain API и публичного deterministic runtime.
Связанные правила
SLM-L2-BUSINESS-R005SLM-L2-BUSINESS-R006SLM-L2-BUSINESS-A007SLM-L2-FACTORY-R008SLM-L2-ERROR-R009SLM-L2-ERROR-R010SLM-L2-BUSINESS-R018SLM-L2-BUSINESS-A019SLM-L2-BUSINESS-A022SLM-L2-BUSINESS-R024SLM-L2-BUSINESS-R025
Роль
business является обязательным SLM-модулем доменного пакета. Он владеет:
- публичными предметными сценариями;
- одним или несколькими именованными Domain API;
- одной публичной фабрикой для каждого API;
- типами явных зависимостей фабрик;
- предметными типами и детерминированными правилами;
- контрактами ожидаемых доменных ошибок;
- публичным представлением доменных данных и состояния.
Несколько API остаются частью одного модуля, пока относятся к одной связной предметной области. Они нужны не для копирования use cases, а для независимой сборки разных наборов сценариев, зависимостей и сред.
Публичные фасеты
Один логический API модуля business имеет два обязательных и один необязательный entry point.
Type-only barrel
Корневой business/index.ts экспортирует только типы:
export type {
AuthAdministrationApi,
AuthAdministrationDeps,
AuthAdministrationFactory,
AuthError,
AuthErrorCode,
AuthSessionApi,
AuthSessionDeps,
AuthSessionFactory,
AuthState,
} from './types'
Потребитель использует этот путь только через import type:
import type {
AuthSessionApi,
AuthState,
} from '@/domains/auth/business'
Factory entry
business/factory.ts экспортирует только именованные runtime-фабрики:
export { authAdministrationFactory } from './factories/auth-administration.factory'
export { authSessionFactory } from './factories/auth-session.factory'
import {
authAdministrationFactory,
authSessionFactory,
} from '@/domains/auth/business/factory'
Каждому API соответствует одна фабрика. Фасет не экспортирует готовые instances, adapters или environment-specific assembly.
Runtime entry
Необязательный business/runtime.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
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:
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.
Подробная граница описана в Состоянии и кэше.
Потребители фасетов
| Потребитель | 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-форму:
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:
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 преобразует обрабатываемый технический сбой в собственный код:
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, после чего преобразовать ожидаемую ошибку в собственный контракт.
Ошибки программирования и нарушенные внутренние инварианты не обязаны маскироваться под ожидаемые доменные ошибки.