9.7 KiB
Фабрики, ports и adapters
Пояснение dependency inversion между Domain API и внешними runtime-возможностями.
Связанные правила
SLM-L2-API-A007SLM-L2-FACTORY-R008SLM-L2-ERROR-R010SLM-L2-DEPENDENCY-A012SLM-L2-API-R018SLM-L2-API-A019SLM-L2-ADAPTER-R021SLM-L2-API-A022SLM-L2-API-R024SLM-L2-PORT-R027
Одна фабрика на Domain API
явные ports + cross-domain APIs + factory → один Domain API
Модуль api предоставляет одну именованную фабрику для каждого объявленного Domain API:
import type {
AuthSessionApi,
} from '@/domains/auth/api'
import type {
AuthIdentityPort,
AuthRuntimePort,
} from '@/domains/auth/api/ports'
export type AuthSessionApiDependencies = Readonly<{
identity: AuthIdentityPort
runtime: AuthRuntimePort
}>
export type AuthSessionApiFactory = (
dependencies: AuthSessionApiDependencies,
) => AuthSessionApi
import {
createAuthSessionApi,
} from '@/domains/auth/api/factory'
Фабрика не выбирает environment, provider, adapter или assembly. Она не открывает connection, не запускает subscription и не создаёт framework state. Разные Domain API могут иметь разные dependency sets и собираться независимо.
Consumer-owned ports
Port описывает capability с позиции модуля api, а не повторяет конкретный provider:
export type AuthIdentityRecord = Readonly<{
expiresAt: number
subject: string
}>
export type AuthIdentityPortFailure =
| Readonly<{ type: 'FORBIDDEN' }>
| Readonly<{ type: 'RATE_LIMITED' }>
| Readonly<{ type: 'UNAVAILABLE' }>
export type AuthIdentityPortResult =
| Readonly<{
ok: true
value: AuthIdentityRecord
}>
| Readonly<{
ok: false
failure: AuthIdentityPortFailure
}>
export type AuthIdentityPort = {
signIn: (
command: AuthIdentityPortCommand,
) => Promise<AuthIdentityPortResult>
}
Port не экспортирует generated DTO, SDK error class, HTTP status или concrete client. AuthIdentityRecord не становится AuthSession: модуль api проверяет record и создаёт публичную модель.
Не каждый port обязан использовать Result. Exception, callback или async iterable допустимы при project policy, если expected failures, cancellation, outcome uncertainty и cleanup остаются типизированными и проверяемыми.
Гранулярность ports
Port соответствует связной capability, а не каждому endpoint и не всему SDK:
AuthIdentityPort
├── requestCode
├── verifyCode
└── revokeSession
Допустимо разделить capability, если операции имеют разные trust boundaries, lifecycle или providers. Запрещено создавать десятки pass-through ports только ради зеркала transport operations.
Clock, timer, random, ID generator и environment также являются ports, если влияют на результат Domain API. Materialized framework state и query cache ports не являются: они принадлежат framework binding.
Failure algebra
Expected failure проходит две явные стадии:
provider-specific failure
→ adapter mapping
→ closed port failure
→ api mapping
→ stable domain error or outcome
Port failure должен сохранять различия, которые нужны Domain API. Если adapter сводит FORBIDDEN, CONFLICT и UNAVAILABLE к unknown, API не может выбрать корректную публичную семантику. Если adapter передаёт HTTP status или SDK error, concrete provider протекает внутрь API.
Unexpected exception не обязана превращаться в expected failure. Cancellation объявляется отдельно от failure, если caller управляет ею. Disconnect или timeout после отправки неидемпотентной команды может означать OUTCOME_UNKNOWN, а не доказанный отказ.
Adapter module
Adapter соединяет port с concrete provider:
api-owned port ← adapter → SDK / REST / storage / platform / realtime
import type {
AuthIdentityPort,
} from '@/domains/auth/api/ports'
export const createAuthRestAdapter = (
client: IdentityClient,
): AuthIdentityPort => ({
async signIn(command) {
try {
const response = await client.signIn({
login: command.identifier,
password: command.secret,
})
return {
ok: true,
value: {
expiresAt: response.expires_at,
subject: response.user_id,
},
}
} catch (error) {
return mapIdentityProviderFailure(error)
}
},
})
Adapter преобразует protocol arguments, records и expected failures, но не решает, какой AuthError получит приложение, не добавляет предметный fallback и не объявляет метод Domain API.
Размещение adapters
Каждая связная production-реализация является отдельным SLM-модулем Group adapters:
auth/adapters/
├── identity-rest/
│ └── index.ts
├── identity-realtime/
│ └── index.ts
└── session-cookie/
└── index.ts
Один adapter-модуль может реализовать несколько тесно связанных ports одного provider. Group adapters не имеет index.ts и не реэкспортирует дочерние модули.
Production adapter запрещено определять:
- внутри
api; - закрытым сегментом assembly;
- inline-функцией в
appили composition; - частью framework binding;
- mutable registry или service locator.
Concrete adapters в production импортируют только assemblies своего домена. Adapter tests импортируют соответствующий module напрямую.
Универсальный infra service
Adapter может использовать публичный API infra, если concrete technical service является универсальным для приложения:
auth adapter
→ infra/http-client
→ external identity provider
Совпадение сигнатур infra API и port не переносит ownership port в infra. Adapter остаётся явной границей provider mapping, failures и environment. Он может быть тонким, но не добавляет фиктивные преобразования ради объёма кода.
Cross-domain API dependency
Готовый API другого домена не является technical port:
import type {
AuthSessionApi,
} from '@/domains/auth/api'
export type UserProfileApiDependencies = Readonly<{
auth: Pick<AuthSessionApi, 'getSession'>
profile: UserProfilePort
}>
Graph owner создаёт Auth раньше User и передаёт auth.session в User assembly. User не объявляет structural copy чужого API и не создаёт bridge adapter без реального преобразования контракта.
Если expected Auth failure становится публичным outcome User, User API преобразует его в собственную UserError. При exception-модели он может использовать публичный guard из auth/api/runtime.
Framework-only SDK
Некоторые SDK доступны только как framework Provider, hook или component, например CAPTCHA или payment element. Framework binding может получить opaque token или operation input через такой SDK и передать его команде Domain API:
framework SDK
→ opaque token
→ Domain API command
→ port
→ provider adapter
Binding не вызывает предметную provider operation напрямую, SDK type не входит в public Domain API, а generic technical UI при необходимости разделяется между infra, ui и composition.
Tests и fake ports
Локальные fake implementations в API-тестах не являются production adapters и не требуют SLM-модулей. Они существуют только внутри test boundary и позволяют детерминированно задавать records, failures, cancellation и realtime события.
Adapter contract tests отдельно доказывают, что concrete provider действительно реализует port. API-тест с идеальным fake не заменяет эту проверку.