Files
slm-design/docs/canons/business-factory.md

332 lines
22 KiB
Markdown
Raw Normal View History

2026-07-24 14:35:35 +03:00
---
title: Business-фабрика
description: Контракт 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 передаются явно;
Плохо:
```ts
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 и форму внешнего клиента.
Хорошо:
```ts
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.
Плохо:
```ts
export type AuthApi = {
AuthGuard: ComponentType<AuthGuardProps>
LoginButton: ComponentType<LoginButtonProps>
useAuth: ReturnType<typeof createAuthHook>
}
```
Проблема: factory output смешивает доменную логику с UI и начинает собирать React tree.
Хорошо:
```ts
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-модуля с переданными зависимостями.
```ts
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` или форму внешней ошибки.
```ts
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}`.
```text
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 к фреймворку.
```ts
import { createAuthBusiness } from '@/compositions/business/auth'
import { createUserBusiness } from '@/compositions/business/user'
const authApi = createAuthBusiness()
const userApi = createUserBusiness({ authApi })
```
Подробный пример см. в [Business composition](../examples/business-composition.md).
## Public API файла index.ts
Жёсткое правило без исключений: `business/{domain}/index.ts` экспортирует только фабрику и type-only экспорты.
```ts
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](../examples/business-testing.md).
## Чеклист
- Фабрика лежит в корне 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 созданы и выполняются.