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

9.7 KiB
Raw Blame History

Фабрики, ports и adapters

Пояснение dependency inversion между Domain API и внешними runtime-возможностями.

Связанные правила

Одна фабрика на 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 не заменяет эту проверку.