Files
slm-design/DRAFT/level-2/domains/domain-api.md
2026-08-02 22:53:05 +03:00

11 KiB
Raw Blame History

Модуль api и Domain API

Пояснение семантического шлюза домена, его публичных фасетов, моделей, операций и ошибок.

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

Роль

api является обязательным SLM-модулем доменного пакета. Для прикладного consumer предметная область доступна только через объявленные им Domain API, public models, outcomes и errors.

Модуль api владеет:

  • именованными Domain API;
  • публичными командами, запросами и подписками;
  • public domain models;
  • validation внешних и port values;
  • семантикой outcomes и expected errors;
  • dependency ports и port failures;
  • одной фабрикой для каждого Domain API;
  • необходимыми consumers deterministic guards и pure-функциями.

Модуль не владеет framework store, query cache, hydration runtime, SDK, transport client или production adapter. Он может координировать одну операцию и замыкать переданные ports, но не хранит скрытую mutable projection данных приложения между вызовами.

Domain API как шлюз

consumer command
  → Domain API
  → dependency port
  → adapter
  → provider

provider record/failure
  → adapter mapping
  → port record/failure
  → Domain API validation and semantics
  → public model/outcome/error
  → consumer

Framework hook, store или composition не импортирует concrete SDK и не читает предметный внешний источник напрямую. Это позволяет менять endpoint, provider и transport, сохраняя публичный контракт, пока не изменилась продуктовая семантика.

Domain API не обязан скрывать реальное предметное изменение. Если backend изменил правило, которое влияет на публичный outcome приложения, контракт домена пересматривается явно.

Публичные фасеты

Один логический публичный API модуля api разделён по аудиториям.

Consumer types

Корневой api/index.ts экспортирует только типы, необходимые прикладным consumers:

export type {
  AuthError,
  AuthErrorCode,
  AuthSession,
  AuthSessionApi,
  RequestPhoneOtpCommand,
  VerifyPhoneOtpCommand,
} from './types'
import type {
  AuthSession,
  AuthSessionApi,
} from '@/domains/auth/api'

Port contracts, factory dependencies, provider records и technical failures не входят в consumer-facing barrel.

Implementer types

api/ports.ts существует только при наличии dependency ports и экспортирует implementer-facing contracts:

export type {
  AuthIdentityPort,
  AuthIdentityPortFailure,
  AuthIdentityRecord,
  AuthSessionApiDependencies,
} from './ports'
import type {
  AuthIdentityPort,
} from '@/domains/auth/api/ports'

Этим фасетом пользуются adapters своего домена, assemblies и tests. Прикладной consumer не строит поведение по port records или failures.

Factory entry

api/factory.ts экспортирует только именованные runtime-фабрики:

export {
  createAuthAdministrationApi,
  createAuthSessionApi,
} from './factories'
import {
  createAuthSessionApi,
} from '@/domains/auth/api/factory'

В production этот фасет импортируют только assemblies текущего домена. API-тесты используют его с fake ports.

Runtime entry

Необязательный api/runtime.ts экспортирует только публичный детерминированный runtime:

export {
  AUTH_ERROR_CODES,
  isAuthError,
  projectSessionEvent,
} from './runtime'

Здесь допустимы error codes и guards, validators, value constructors, pure transitions, reconciliation functions и immutable-константы. Фасет не содержит фабрики, API instances, ports, I/O, subscriptions, mutable state или environment-specific код.

Если runtime-потребителей нет, файл не создаётся. Другие внешние пути внутри api являются deep imports.

Stateless runtime boundary

Domain API управляет смыслом данных, а не способом их materialization. Query и command возвращают public values или outcomes, которые framework binding может сохранить в TanStack Query, Zustand, Pinia или другом runtime:

export type AuthSessionApi = {
  getSession: () => Promise<AuthSession>
  requestPhoneOtp: (
    command: RequestPhoneOtpCommand,
  ) => Promise<RequestPhoneOtpOutcome>
  verifyPhoneOtp: (
    command: VerifyPhoneOtpCommand,
  ) => Promise<AuthSession>
  signOut: () => Promise<void>
}

API не экспортирует getState, mutable store, QueryClient или framework subscription. Operation-local correlation, cancellation и validation допустимы; canonical cache приложения остаётся у framework consumer.

Если клиентский workflow имеет предметное состояние, framework хранит readonly value, а API определяет переход:

const nextCheckout = checkoutApi.applyCommand(
  currentCheckout,
  command,
)

Или consumer использует pure-функцию api/runtime. Framework не применяет предметный merge самостоятельно.

Несколько Domain API

export type AuthSessionApi = {
  getSession: () => Promise<AuthSession>
  signIn: (command: SignInCommand) => Promise<AuthSession>
  signOut: () => Promise<void>
}

export type AuthAdministrationApi = {
  revokeUserSessions: (
    command: RevokeUserSessionsCommand,
  ) => Promise<void>
}

AuthSessionApi и AuthAdministrationApi могут иметь разные ports, trust boundaries и assemblies. Один публичный сценарий принадлежит ровно одному API.

Разделение не используется только ради файловой декомпозиции. Если APIs не могут быть созданы независимо из-за общей atomicity, состояния или lifecycle, они объединяются либо получают один явно созданный shared capability через assembly.

Assembly возвращает именованный граф готовых контрактов:

export type AuthGraph = Readonly<{
  session: AuthSessionApi
}>

Такой граф сообщает доступный набор API, но не является новым предметным API.

Errors и failure algebra

Ожидаемая публичная ошибка имеет устойчивую readonly сериализуемую форму:

export type AuthErrorCode =
  | 'AUTH_IDENTITY_INVALID'
  | 'AUTH_RATE_LIMITED'
  | 'AUTH_SERVICE_UNAVAILABLE'

export type AuthError = Readonly<{
  code: AuthErrorCode
}>

Внешний failure проходит две границы:

provider error
  → adapter
  → closed port failure
  → api
  → stable domain error

Например, adapter переводит HTTP 429, SDK class или socket error frame в AuthIdentityPortFailure с типом RATE_LIMITED. Domain API решает, что публичная операция завершается AUTH_RATE_LIMITED.

Port failure не содержит raw provider object в публично доступной форме. Domain error не включает status, SDK class, source message, payload или cause. Диагностические данные остаются в observability-механизме adapter или infra.

Cancellation и OUTCOME_UNKNOWN не объединяются с обычным failure, если приложение должно различать их. Ошибка программирования и нарушенный внутренний инвариант не маскируются под expected domain error.

Выбор exception или discriminated Result остаётся policy проекта. Архитектурная цепочка provider failure → port failure → domain error не зависит от канала передачи.

Недетерминизм

Clock, timer, random, ID generator и environment передаются как dependency ports:

export type AuthRuntimePort = {
  now: () => number
  createId: () => string
}

Модуль api не читает Date.now, Math.random, env или platform globals напрямую, если они влияют на результат операции. Это сохраняет детерминированность API-тестов и явную environment boundary.

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

Потребитель api api/ports api/factory api/runtime
Adapter своего домена Нет Type-only Нет Нет
Assembly своего домена Type-only Type-only Да При необходимости
Framework binding своего домена Type-only Нет Нет При необходимости
composition или app Type-only Нет Нет При необходимости
Код другого домена Type-only Нет Нет При необходимости
API-тест Type-only Type-only Да По тестируемой границе

Прикладной production graph создаётся assemblies. app, compositions и framework bindings не импортируют factory или concrete adapters.