Files
slm-design/DRAFT/level-2/domains/factory-ports-adapters.md

236 lines
9.7 KiB
Markdown
Raw Normal View History

2026-08-02 22:53:05 +03:00
# Фабрики, ports и adapters
2026-08-02 22:53:05 +03:00
> Пояснение dependency inversion между Domain API и внешними runtime-возможностями.
## Связанные правила
2026-08-02 22:53:05 +03:00
- [`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)
2026-08-02 22:53:05 +03:00
- [`SLM-L2-API-R018`](../../rules/level-2.md#slm-l2-api-r018)
- [`SLM-L2-API-A019`](../../rules/level-2.md#slm-l2-api-a019)
2026-07-30 20:48:05 +03:00
- [`SLM-L2-ADAPTER-R021`](../../rules/level-2.md#slm-l2-adapter-r021)
2026-08-02 22:53:05 +03:00
- [`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)
2026-08-02 22:53:05 +03:00
## Одна фабрика на Domain API
```text
2026-08-02 22:53:05 +03:00
явные ports + cross-domain APIs + factory → один Domain API
```
2026-08-02 22:53:05 +03:00
Модуль `api` предоставляет одну именованную фабрику для каждого объявленного Domain API:
```ts
import type {
AuthSessionApi,
2026-08-02 22:53:05 +03:00
} from '@/domains/auth/api'
2026-08-02 22:53:05 +03:00
import type {
AuthIdentityPort,
AuthRuntimePort,
} from '@/domains/auth/api/ports'
export type AuthSessionApiDependencies = Readonly<{
identity: AuthIdentityPort
runtime: AuthRuntimePort
}>
2026-08-02 22:53:05 +03:00
export type AuthSessionApiFactory = (
dependencies: AuthSessionApiDependencies,
) => AuthSessionApi
```
2026-07-30 20:48:05 +03:00
```ts
import {
2026-08-02 22:53:05 +03:00
createAuthSessionApi,
} from '@/domains/auth/api/factory'
2026-07-30 20:48:05 +03:00
```
2026-08-02 22:53:05 +03:00
Фабрика не выбирает environment, provider, adapter или assembly. Она не открывает connection, не запускает subscription и не создаёт framework state. Разные Domain API могут иметь разные dependency sets и собираться независимо.
2026-08-02 22:53:05 +03:00
## Consumer-owned ports
2026-08-02 22:53:05 +03:00
Port описывает capability с позиции модуля `api`, а не повторяет конкретный provider:
```ts
2026-08-02 22:53:05 +03:00
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>
}
```
2026-08-02 22:53:05 +03:00
Port не экспортирует generated DTO, SDK error class, HTTP status или concrete client. `AuthIdentityRecord` не становится `AuthSession`: модуль `api` проверяет record и создаёт публичную модель.
2026-08-02 22:53:05 +03:00
Не каждый port обязан использовать `Result`. Exception, callback или async iterable допустимы при project policy, если expected failures, cancellation, outcome uncertainty и cleanup остаются типизированными и проверяемыми.
2026-08-02 22:53:05 +03:00
## Гранулярность ports
2026-08-02 22:53:05 +03:00
Port соответствует связной capability, а не каждому endpoint и не всему SDK:
```text
AuthIdentityPort
├── requestCode
├── verifyCode
└── revokeSession
```
2026-08-02 22:53:05 +03:00
Допустимо разделить capability, если операции имеют разные trust boundaries, lifecycle или providers. Запрещено создавать десятки pass-through ports только ради зеркала transport operations.
2026-08-02 22:53:05 +03:00
Clock, timer, random, ID generator и environment также являются ports, если влияют на результат Domain API. Materialized framework state и query cache ports не являются: они принадлежат framework binding.
2026-08-02 22:53:05 +03:00
## Failure algebra
2026-08-02 22:53:05 +03:00
Expected failure проходит две явные стадии:
2026-08-02 22:53:05 +03:00
```text
provider-specific failure
→ adapter mapping
→ closed port failure
→ api mapping
→ stable domain error or outcome
```
2026-08-02 22:53:05 +03:00
Port failure должен сохранять различия, которые нужны Domain API. Если adapter сводит `FORBIDDEN`, `CONFLICT` и `UNAVAILABLE` к `unknown`, API не может выбрать корректную публичную семантику. Если adapter передаёт HTTP status или SDK error, concrete provider протекает внутрь API.
2026-08-02 22:53:05 +03:00
Unexpected exception не обязана превращаться в expected failure. Cancellation объявляется отдельно от failure, если caller управляет ею. Disconnect или timeout после отправки неидемпотентной команды может означать `OUTCOME_UNKNOWN`, а не доказанный отказ.
2026-07-30 20:48:05 +03:00
## Adapter module
2026-08-02 22:53:05 +03:00
Adapter соединяет port с concrete provider:
```text
2026-08-02 22:53:05 +03:00
api-owned port ← adapter → SDK / REST / storage / platform / realtime
```
2026-08-02 22:53:05 +03:00
```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)
}
},
})
```
2026-08-02 22:53:05 +03:00
Adapter преобразует protocol arguments, records и expected failures, но не решает, какой `AuthError` получит приложение, не добавляет предметный fallback и не объявляет метод Domain API.
2026-07-30 20:48:05 +03:00
## Размещение adapters
2026-08-02 22:53:05 +03:00
Каждая связная production-реализация является отдельным SLM-модулем Group `adapters`:
```text
2026-07-30 20:48:05 +03:00
auth/adapters/
2026-08-02 22:53:05 +03:00
├── identity-rest/
2026-07-30 20:48:05 +03:00
│ └── index.ts
2026-08-02 22:53:05 +03:00
├── identity-realtime/
│ └── index.ts
2026-08-02 22:53:05 +03:00
└── session-cookie/
2026-07-30 20:48:05 +03:00
└── index.ts
```
2026-08-02 22:53:05 +03:00
Один adapter-модуль может реализовать несколько тесно связанных ports одного provider. Group `adapters` не имеет `index.ts` и не реэкспортирует дочерние модули.
2026-07-30 20:48:05 +03:00
Production adapter запрещено определять:
2026-08-02 22:53:05 +03:00
- внутри `api`;
- закрытым сегментом assembly;
2026-08-02 22:53:05 +03:00
- inline-функцией в `app` или composition;
- частью framework binding;
- mutable registry или service locator.
Concrete adapters в production импортируют только assemblies своего домена. Adapter tests импортируют соответствующий module напрямую.
2026-07-30 20:48:05 +03:00
2026-08-02 22:53:05 +03:00
## Универсальный 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:
2026-07-30 20:48:05 +03:00
```ts
2026-08-02 22:53:05 +03:00
import type {
AuthSessionApi,
} from '@/domains/auth/api'
2026-07-30 20:48:05 +03:00
2026-08-02 22:53:05 +03:00
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
```
2026-08-02 22:53:05 +03:00
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 события.
2026-07-30 20:48:05 +03:00
2026-08-02 22:53:05 +03:00
Adapter contract tests отдельно доказывают, что concrete provider действительно реализует port. API-тест с идеальным fake не заменяет эту проверку.