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

236 lines
9.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Фабрики, ports и adapters
> Пояснение dependency inversion между Domain API и внешними runtime-возможностями.
## Связанные правила
- [`SLM-L2-API-A007`](../../rules/level-2.md#slm-l2-api-a007)
- [`SLM-L2-FACTORY-R008`](../../rules/level-2.md#slm-l2-factory-r008)
- [`SLM-L2-ERROR-R010`](../../rules/level-2.md#slm-l2-error-r010)
- [`SLM-L2-DEPENDENCY-A012`](../../rules/level-2.md#slm-l2-dependency-a012)
- [`SLM-L2-API-R018`](../../rules/level-2.md#slm-l2-api-r018)
- [`SLM-L2-API-A019`](../../rules/level-2.md#slm-l2-api-a019)
- [`SLM-L2-ADAPTER-R021`](../../rules/level-2.md#slm-l2-adapter-r021)
- [`SLM-L2-API-A022`](../../rules/level-2.md#slm-l2-api-a022)
- [`SLM-L2-API-R024`](../../rules/level-2.md#slm-l2-api-r024)
- [`SLM-L2-PORT-R027`](../../rules/level-2.md#slm-l2-port-r027)
## Одна фабрика на Domain API
```text
явные ports + cross-domain APIs + factory → один Domain API
```
Модуль `api` предоставляет одну именованную фабрику для каждого объявленного Domain API:
```ts
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
```
```ts
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:
```ts
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:
```text
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 проходит две явные стадии:
```text
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:
```text
api-owned port ← adapter → SDK / REST / storage / platform / realtime
```
```ts
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`:
```text
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 является универсальным для приложения:
```text
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:
```ts
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:
```text
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 не заменяет эту проверку.