mirror of
https://github.com/gromlab-ru/slm-design.git
synced 2026-08-22 07:30:16 +03:00
236 lines
9.7 KiB
Markdown
236 lines
9.7 KiB
Markdown
# Фабрики, 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 не заменяет эту проверку.
|