Files
slm-design/docs/canons/business-factory.md
2026-07-24 14:35:35 +03:00

22 KiB
Raw Blame History

title, description
title description
Business-фабрика Контракт business-модуля, runtime-зависимости, logic API, доменные ошибки и место сборки фабрик

Business-фабрика

Раздел фиксирует архитектурный контракт business-модуля: фабрика описывает доменный logic API приложения и не знает о конкретном backend, SDK, storage, state/query runtime, browser API или React tree.

Главный принцип

Business-модуль описывает домен приложения, а не форму backend API, SDK, storage, external hook, state manager или внешнего сервиса.

Фабрика должна отвечать на вопрос: какой API нужен приложению для работы с доменом. Она не должна отвечать на вопрос: какие методы есть у конкретного REST-клиента, SDK или browser API.

Когда создавать business-модуль

Полный контракт business-модуля — фабрика, deps, доменные типы, доменные ошибки, adapters, сборка в compositions/business/{domain}, обязательные factory/assembly tests и colocated tests для внутренней runtime-safe логики. Он применяется к любому модулю, созданному в business.

Создавай business-модуль, когда выполняется хотя бы одно условие:

  • сценарии нужны нескольким страницам, composition modules или другим доменам;
  • у логики есть собственная доменная модель и доменные ошибки, которые нельзя выразить типами внешнего API;
  • доменный сценарий зависит от внешних runtime-capabilities (backend, SDK, product storage, source hook, domain store, event, browser API), которые нужно изолировать за deps;
  • домен нужно тестировать независимо от UI и конкретного backend.

Не создавай business-модуль заранее, если логика нужна одной странице, не имеет доменной модели, product I/O и domain state. Presentation-only store или browser interaction, принадлежащие UI scope, сами по себе не создают business-домен. Такая page-local orchestration живёт в соответствующем composition module.

Наличие product I/O отменяет page-local исключение. Даже если источник нужен одной странице, consumer composition не обращается к нему напрямую: объяви business-контракт, dependency adapter и domain errors.

«Облегчённого» business-модуля не существует: если модуль создан в business/, контракт применяется целиком. Подъём вызревшей логики из composition module в business-модуль — обычный рефакторинг: объяви доменные типы и deps, перенеси сценарии в services и hooks, собери фабрику и покрой её factory-level тестами.

Обязательные правила

  • Фабрика лежит в корне business-модуля: business/{domain}/{domain}.factory.ts.
  • Фабрика принимает runtime-зависимости только через deps.
  • Фабрика возвращает только logic API домена: hooks, selectors, command/query methods, scenario services.
  • Фабрика не возвращает React-компоненты, layouts, guards, boundaries, providers или page-level wrappers.
  • Business-модуль не содержит React-компоненты. UI-решения домена размещаются в compositions; полностью универсальные UI-контролы размещаются в ui.
  • Business-модуль не импортирует реальные SDK, generated operations, HTTP-клиенты, storage, env, browser API или composition-сборку.
  • Business-модуль не импортирует React state/effect runtime, SWR/query runtime, Zustand/Redux/MobX store runtime или event bus implementation.
  • Source/query hooks, domain state stores, subscriptions, clock, random и technical services передаются через business-owned deps.
  • Public contract business-модуля использует собственные доменные типы, а не DTO внешнего API.
  • index.ts business-модуля экспортирует только фабрику и type-only экспорты. Исключений нет.
  • Runtime-сборка business-фабрики выполняется вне business, обычно в compositions/business/{domain}.
  • Для каждой runtime-capability создаётся явный dependency adapter. Adapter не пишется inline внутри builder.
  • Из public API business выходят только собственные domain errors со стабильным code.
  • Factory-level и assembly tests обязательны и входят в критерий завершения модуля.

Проектирование фабрики

Проектирование начинается со сценариев домена, а не с API-клиента.

Порядок работы:

  1. Опиши публичные сценарии, которые нужны приложению.
  2. Опиши доменные типы, которыми должен оперировать UI и другие business-модули.
  3. Проведи inventory всех runtime-capabilities: data sources, hooks, stores, events, browser APIs, env, clock и cross-domain APIs.
  4. Опиши минимальные внешние возможности в {Domain}Deps.
  5. Назови внешние возможности бизнес-языком, а не языком backend endpoint'ов и библиотек.
  6. Опиши domain error codes для каждого публичного сценария.
  7. Собери фабрику из внутренних services, hook wrappers, mappers и helpers поверх переданных deps.
  8. Верни наружу только {Domain}Api.
  9. Реализуй каждую dependency отдельным adapter в compositions/business/{domain}.

Правильные вопросы:

  • Не какой endpoint дернуть?, а какой бизнес-сценарий выполняем?.
  • Не какой DTO пришёл?, а какую доменную модель отдаём наружу?.
  • Не как называется метод SDK?, а какая внешняя возможность нужна домену?.
  • Не как устроен backend сейчас?, а какой стабильный контракт нужен приложению?.

Контракт зависимостей

{Domain}Deps описывает не технические инструменты, а возможности, которые нужны домену.

Правила:

  • имя зависимости отражает бизнес-возможность: phoneAuth, session, profile, agreements, notifications;
  • методы внутри зависимости называют сценарное действие без повторения endpoint names: phoneAuth.requestCode, phoneAuth.verifyCode, profile.getCurrentUser, agreements.saveUserAgreements;
  • параметры используют доменные типы business-модуля;
  • результат внешней границы принимается как unknown, если данные требуют runtime-нормализации;
  • пустой успешный ответ описывается как Promise<void>, если body не нужен;
  • dependency не должна возвращать generated DTO как доменный тип;
  • mapper, normalizer или domain error не передаётся через deps, если это часть доменной логики.
  • source/query hook описывается business-owned result type, без SWRConfiguration, UseQueryResult и других library types;
  • domain state описывается business-owned port, без StoreApi и concrete state manager types;
  • subscription возвращает cleanup function;
  • недетерминированные clock/random/id capabilities передаются явно;

Плохо:

import type { ApiRequestClient, BoundApi } from '@vendor/api-sdk'
import type { v1PatientProfileList } from '@vendor/api-sdk/operations'

type UserApiTree = {
  patient: {
    profile: typeof v1PatientProfileList
  }
}

export type UserDeps = {
  api: BoundApi<UserApiTree, ApiRequestClient>
}

Проблема: business-модуль знает про SDK, generated operation и форму внешнего клиента.

Хорошо:

import type { AuthState } from './auth-state.type'
import type { VerifyPhoneCodeData } from './verify-phone-code-data.type'

export type AuthDeps = {
  phoneAuth: {
    requestCode: (phone: string) => Promise<unknown>
    resendCode: (challengeId: string) => Promise<unknown>
    verifyCode: (data: VerifyPhoneCodeData) => Promise<unknown>
  }
  session: {
    setToken: (token?: string | null) => void
    useToken: () => string | null | undefined
  }
  sessionEvents: {
    onInvalidated: (listener: () => void) => () => void
  }
  state: {
    create: (initialState: AuthState) => {
      get: () => AuthState
      set: (state: AuthState) => void
      useState: () => AuthState
    }
  }
}

Конкретный SDK, storage, source hook, store или mock подключается в compositions/business/auth через адаптер. Business-модуль знает только о своём AuthDeps.

Публичный API фабрики

{Domain}Api описывает logic API домена, который фабрика возвращает наружу в runtime.

Правила:

  • API говорит на языке домена;
  • API не повторяет endpoint names;
  • API не отдаёт DTO внешнего сервиса;
  • API не раскрывает внутренние create* services;
  • API остаётся стабильным при замене backend, SDK или storage;
  • API может возвращать hooks и selectors, созданные поверх переданных dependency hooks/state ports; business не импортирует concrete hook/store runtime;
  • API не возвращает React-компоненты, UI-компоненты, layouts, guards, boundaries или page-level wrappers.

Плохо:

export type AuthApi = {
  AuthGuard: ComponentType<AuthGuardProps>
  LoginButton: ComponentType<LoginButtonProps>
  useAuth: ReturnType<typeof createAuthHook>
}

Проблема: factory output смешивает доменную логику с UI и начинает собирать React tree.

Хорошо:

export type AuthApi = {
  requestPhoneCode: ReturnType<typeof createRequestPhoneCode>
  resendPhoneCode: ReturnType<typeof createResendPhoneCode>
  verifyPhoneCode: ReturnType<typeof createVerifyPhoneCode>
  useAuth: ReturnType<typeof createAuthHook>
  useIsAuthenticated: ReturnType<typeof createIsAuthenticatedHook>
  startSessionInvalidationTracking: () => () => void
}

Такой API сообщает composition-слою доменное состояние и действия, но не навязывает UI-решение.

Реализация фабрики

Фабрика связывает внутренние части business-модуля с переданными зависимостями.

import { createCurrentUserHook } from './hooks/use-current-user.hook'
import { createStoredUserAgreementsGetter } from './services/get-stored-user-agreements.service'
import { createUpdateCurrentUserProfile } from './services/update-current-user-profile.service'
import type { UserFactory } from './types/user-factory.type'

export const userFactory: UserFactory = (deps) => {
  const { authApi, profile, storage } = deps

  return {
    getStoredUserAgreements: createStoredUserAgreementsGetter(storage),
    updateCurrentUserProfile: createUpdateCurrentUserProfile(profile),
    useCurrentUser: createCurrentUserHook({ authApi, profile }),
  }
}

Фабрика не должна:

  • создавать API-клиент;
  • выбирать backend endpoint;
  • читать env;
  • обращаться к browser storage напрямую;
  • делать запросы при создании API;
  • импортировать composition-сборку;
  • подстраивать свой API под конкретный внешний сервис;
  • создавать или возвращать React-компоненты;
  • импортировать React state/effect APIs;
  • импортировать SWR, query library или state manager runtime;
  • создавать concrete store, query client или event bus;
  • подписываться на external event без dependency contract и явного cleanup.

Runtime-границы и ошибки

Любая dependency фабрики считается ненадёжной runtime-границей.

Business-модуль защищает публичный контракт от таких случаев:

  • dependency вернула null, undefined, пустой body или объект неправильной формы;
  • dependency вернула rejected promise;
  • dependency синхронно выбросила исключение;
  • storage содержит устаревшие или битые данные;
  • внешний сервис поменял форму ответа без изменения TypeScript-типов.

Защита выполняется внутри business-модуля через mappers, normalizers, type guards и domain errors. Fallback допустим только для валидного доменного исхода, явно представленного dependency contract, а не для technical failure или malformed response.

Наружу не должны протекать ошибки SDK, HTTP-клиента, storage, query hook, store, generated API или browser API. Потребитель business API всегда получает только собственную domain error со стабильным code, а не message, status, response, stack или форму внешней ошибки.

if (isDomainError<AuthErrorCode>(error) && error.code === 'AUTH_PHONE_CODE_VERIFY_FAILED') {
  showInvalidCodeMessage()
}

Business всегда преобразует rejected promise, synchronous throw, source hook error и невалидную успешную структуру в собственную domain error.

Сборка business-фабрики

Реальные runtime-зависимости подключаются в composition module compositions/business/{domain}.

src/
├── business/
│   └── auth/
│       ├── auth.factory.ts
│       ├── types/
│       └── index.ts
└── compositions/
    └── business/
        └── auth/
            ├── create-auth-business.ts
            ├── adapters/
            ├── types/
            └── index.ts

Если business-домены сгруппированы, сборка повторяет тот же относительный путь: business/app/auth соответствует compositions/business/app/auth, business/cms/content соответствует compositions/business/cms/content. Группировка не является default-структурой.

Правила:

  • business/{domain} объявляет фабрику и контракт deps.
  • compositions/business/{domain} отдельными adapters адаптирует SDK, storage, infra-клиенты, source hooks, stores, events и другие runtime-capabilities к этому контракту.
  • Builder явно создаёт или получает scoped runtime instances без I/O, создаёт adapters поверх них, передаёт adapters фабрике и возвращает API; integration logic не пишется inline.
  • create{Domain}Business() возвращает готовый {Domain}Api.
  • Browser/application create{Domain}Business() принимает аргументы только для API других уже собранных business-фабрик.
  • SDK, storage, env, HTTP-клиенты и browser API не передаются в create{Domain}Business() как deps.
  • Если домен не зависит от других business API, create{Domain}Business() вызывается без аргументов.
  • Request-scoped builder отделяет cross-domain API от requestScopeInput. В input находятся только framework/request data; concrete client factory импортируется внутри integration module. Request input используется только для создания adapters и не передаётся factory как raw dependency.
  • Конечный граф API собирается в месте, которое владеет lifecycle: page composition, route composition, provider, request scope или test setup.
  • infra не собирает business-фабрики, потому что не должен импортировать business.
  • app не реализует business-сборку, а только подключает готовые composition modules к фреймворку.
import { createAuthBusiness } from '@/compositions/business/auth'
import { createUserBusiness } from '@/compositions/business/user'

const authApi = createAuthBusiness()
const userApi = createUserBusiness({ authApi })

Подробный пример см. в Business composition.

Public API файла index.ts

Жёсткое правило без исключений: business/{domain}/index.ts экспортирует только фабрику и type-only экспорты.

export { userFactory } from './user.factory'

export type { User } from './types/user.type'
export type { UserApi } from './types/user-api.type'
export type { UserDeps } from './types/user-deps.type'
export type { UserFactory } from './types/user-factory.type'
export type { UserErrorCode } from './types/user-error-code.type'

Тестирование

Business-модуль тестируется через public API фабрики. Factory-level тесты обязательны, импортируют модуль только через business/{domain} и не используют deep imports во внутренние services, hooks, mappers или lib.

Сборка в compositions/business/{domain} обязательно тестируется отдельно: эти тесты проверяют adapters, корректность передачи deps, отсутствие I/O при создании API и lifecycle cleanup, но не заменяют сценарные тесты business-модуля.

Colocated unit tests обязательны там, где есть mappers, normalizers, type guards, domain errors или другая внутренняя runtime-safe логика. Они являются дополнительным уровнем, а не заменой factory-level и assembly tests.

Подробный пример см. в Business testing.

Чеклист

  • Фабрика лежит в корне business-модуля.
  • Фабрика принимает все runtime-зависимости через deps.
  • В deps нет SDK, generated operations, HTTP-клиентов, backend DTO, StoreApi и query-library types.
  • Контракты зависимостей названы бизнес-языком.
  • Source/query hooks, stores, events и platform APIs переданы через deps.
  • Business не импортирует React/SWR/query/store runtime.
  • Все public types принадлежат business-модулю.
  • Внешние ответы нормализуются перед попаданием в public API.
  • Все source errors без исключений заменяются собственными domain errors.
  • Public API фабрики не повторяет внешний API.
  • Public API фабрики не возвращает React-компоненты.
  • Business-модуль не содержит React-компоненты.
  • index.ts экспортирует только фабрику и type-only экспорты, без исключений.
  • Runtime-сборка живёт в compositions/business/{domain}.
  • Для каждой runtime-capability существует private adapter.
  • Builder не содержит inline integration logic.
  • Business-модуль можно подключить к другому backend через адаптер без изменения фабрики.
  • Factory-level и assembly tests созданы и выполняются.