Files
slm-design/DRAFT/level-2/domains/business.md
2026-07-30 20:48:05 +03:00

8.4 KiB
Raw Blame History

Модуль business

Пояснение единственного runtime-источника доменных данных и результатов.

Связанные правила

Роль

business является обязательным SLM-модулем доменного пакета. Он владеет:

  • публичными предметными сценариями;
  • единым контрактом DomainApi;
  • одной публичной фабрикой;
  • типом явных зависимостей фабрики;
  • предметными типами и детерминированными правилами;
  • кодами, типом и runtime guard доменных ошибок;
  • публичным представлением доменных данных и состояния.

Приложение получает runtime-данные, состояние и результаты домена только через экземпляр DomainApi. Adapter, preset или framework binding module не открывает параллельный источник доменных данных.

Публичный API модуля

Один логический API business разделён на три фиксированных фасета.

Type-only barrel

Корневой business/index.ts экспортирует только типы:

export type {
  AuthApi,
  AuthDeps,
  AuthError,
  AuthErrorCode,
  AuthFactory,
  AuthState,
} from './types'

Потребитель использует этот путь только через import type:

import type {
  AuthApi,
  AuthError,
  AuthErrorCode,
} from '@/domains/auth/business'

Factory entry

business/factory.ts экспортирует только runtime-фабрику:

export { authFactory } from './auth.factory'
import { authFactory } from '@/domains/auth/business/factory'

Error entry

business/error.ts экспортирует только runtime-коды и guards:

export { AUTH_ERROR_CODES, isAuthError } from './errors/auth-error'
import {
  AUTH_ERROR_CODES,
  isAuthError,
} from '@/domains/auth/business/error'

AuthError и AuthErrorCode не реэкспортируются из business/error: все public types имеют один канонический путь через type-only barrel. Предметные validators, normalizers, constructors ошибок, source-error mappers, mutable store и технические DTO остаются закрытыми.

Другие внешние пути внутри business являются deep imports. Файлы factory.ts и error.ts являются фасетами одного SLM-модуля, а не сегментами или вложенными модулями.

Потребители фасетов

Потребитель 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

export type AuthApi = {
  getSnapshot: () => AuthState
  requestPhoneOtp: (phone: string) => Promise<void>
  verifyPhoneOtp: (code: string) => Promise<void>
}

export type AuthFactory = (deps: AuthDeps) => AuthApi

Все presets вызывают одну authFactory и создают AuthApi этого контракта. Preset может использовать другую техническую реализацию, но не добавляет метод и не меняет семантику сценария. Одноразовое место сборки в composition также может вызвать business/factory, используя публичные adapter-модули пакета, если фабрика имеет технические зависимости.

Точная модель хранения, initial state, подписки и SSR snapshot пока остаётся открытым вопросом. Нормативной уже является публичная граница: приложение наблюдает доменное состояние через DomainApi, а не напрямую через adapter или framework store.

Обязательный контракт ошибок

Каждый business объявляет устойчивые коды, безопасную readonly-форму и runtime guard. Типы публикуются через business, а runtime symbols через business/error:

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 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)
}

Способ передачи ошибки, exception или discriminated Result, пока не закреплён. В обоих вариантах публичный сценарий сообщает ожидаемый сбой только через собственный AuthError.

Изоляция технических ошибок

Ошибки SDK, HTTP, database, storage и adapters не пересекают DomainApi в форме, доступной приложению. business преобразует обрабатываемый технический сбой в собственный код:

SDK error
  → adapter failure
  → business mapping
  → AuthErrorCode
  → приложение

Публичная форма не включает исходные message, status, payload, cause, SDK class или сам объект ошибки. Диагностические данные могут сохраняться только во внутреннем механизме observability, контракт которого будет определён отдельно.

То же относится к cross-domain вызову. Если UserApi использует AuthApi, сбой, который становится результатом публичного сценария User, представлен собственным UserError, а не AuthError.

Ошибки программирования и нарушенные внутренние инварианты не обязаны маскироваться под ожидаемые доменные ошибки. Способ отличить их от ожидаемых cross-domain ошибок при exception-модели и их диагностическая политика остаются открытыми вопросами; исходная ошибка при этом не добавляется в публичную форму DomainError.