11 KiB
Модуль api и Domain API
Пояснение семантического шлюза домена, его публичных фасетов, моделей, операций и ошибок.
Связанные правила
SLM-L2-API-R005SLM-L2-API-R006SLM-L2-API-A007SLM-L2-FACTORY-R008SLM-L2-ERROR-R009SLM-L2-ERROR-R010SLM-L2-API-R018SLM-L2-API-A019SLM-L2-API-A022SLM-L2-API-R024SLM-L2-API-R025SLM-L2-PORT-R027SLM-L2-STATE-R028
Роль
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.