Files
slm-design/old-docs/canons/business-runtime-boundary.md

15 KiB
Raw Blame History

title, description
title description
Runtime-граница business Строгая изоляция business-фабрики от источников данных, state/query runtime, infra, browser API и внешних ошибок

Runtime-граница business

Главный инвариант

Business-модуль выполняет доменную композицию только над:

  • собственными типами и детерминированной логикой;
  • capabilities, переданными фабрике через {Domain}Deps.

Business не вызывает runtime-возможность, если она не была передана фабрике. Это относится не только к данным и infra, но и к hooks, stores, subscriptions, browser API и другим concrete runtime-механизмам.

Фабрика отвечает на вопрос «какой стабильный доменный API нужен приложению», а не «какими библиотеками и источниками он реализован».

Что разрешено внутри business

Business может напрямую использовать:

  • собственные domain types;
  • собственные services, mappers, normalizers, validators и type guards;
  • собственные domain errors;
  • детерминированные вычисления без I/O, runtime-state и скрытого окружения;
  • чистые библиотеки вроде schema validators, decimal/date utilities, если результат определяется только явными аргументами и типы библиотеки не становятся public contract;
  • type-only контракты других business API для cross-domain dependencies.

Что передаётся через deps

Через {Domain}Deps передавай любую runtime-capability:

Capability Примеры concrete implementation
Product source REST SDK, GraphQL client, CMS, storage
Source/query hook SWR, TanStack Query, Apollo hook
Domain state runtime Zustand, Redux, MobX, RxJS store
Technical service logger, telemetry, notifications, i18n engine
Platform API window, navigation, clipboard, geolocation
Lifecycle event unauthorized event, socket event, subscription
Environment env/config provider, feature runtime configuration
Nondeterminism clock, timer, random, ID generator
Cross-domain behavior ограниченный API другой business-фабрики

Не импортируй concrete implementation в business даже в том случае, если библиотека используется только в одном внутреннем файле.

Запрещённые imports

Production-код business/** не импортирует напрямую ни runtime values, ни types из concrete runtimes:

  • infra, compositions, app;
  • SDK, generated operations и HTTP clients;
  • storage implementation;
  • React state/effect APIs;
  • SWR, TanStack Query, Apollo и другие query runtimes;
  • Zustand, Redux, MobX, RxJS stores;
  • browser и framework runtime APIs;
  • event bus implementation;
  • env и process-specific configuration.

Запрет нельзя обойти через import type, alias, barrel, type cast или helper в shared. Type-only разрешены собственные contracts, детерминированный shared, чистые libraries и суженные public API других business-доменов.

Доменный шлюз данных

Business API является единственной продуктовой границей для потребительского кода.

page / layout / screen / widget
  → {Domain}Api
  → business scenario
  → {Domain}Deps
  → private dependency adapter
  → infra client / SDK / storage / external source

Внешний сервис остаётся физическим источником данных. Business является единственным источником доменной истины: он определяет модель, сценарий, нормализацию, fallback и ошибки.

Обычный consumer composition не создаёт параллельный продуктовый контракт поверх DTO или client. Единственная зона concrete product integration внутри compositionscompositions/business/{domain}.

Business-owned deps

{Domain}Deps принадлежит business-модулю и описывает необходимые возможности доменным языком.

Требования:

  • группируй методы по capability, а не по имени SDK/client;
  • принимай доменные аргументы;
  • принимай внешние результаты как unknown, если нужна runtime-проверка;
  • описывай собственную минимальную форму dependency hook/result;
  • описывай собственный state port, а не StoreApi конкретной библиотеки;
  • возвращай cleanup из subscription capability;
  • не передавай mapper, normalizer или domain error через deps;
  • не используй generated DTO как доменную модель;
  • не передавай целый client, если домену нужны два конкретных действия.

Плохо:

import type { StoreApi } from 'zustand'
import type { AdminApiClient } from '@vendor/admin-sdk'

export type AuthDeps = {
  api: AdminApiClient
  store: StoreApi<AuthState>
}

Хорошо:

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

export type SourceHookResult = {
  data: unknown
  error: unknown
  isLoading: boolean
  refresh: () => Promise<void>
}

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
    }
  }
}

Dependency adapters

Adapter находится снаружи business и реализует конкретную часть {Domain}Deps.

compositions/business/auth/
├── create-auth-business.ts
├── adapters/
│   ├── admin-auth-session.adapter.ts
│   ├── browser-auth-navigation.adapter.ts
│   ├── zustand-auth-state.adapter.ts
│   └── admin-auth-session-events.adapter.ts
└── index.ts

Правила adapter:

  • импортирует type-only business contract;
  • импортирует concrete infra/runtime implementation;
  • преобразует доменные аргументы в transport arguments;
  • возвращает raw/unknown runtime result, если business должен его проверить;
  • пробрасывает source error без создания domain error;
  • не реализует бизнес-правило;
  • не выбирает доменный fallback;
  • не экспортируется через public API integration module;
  • тестируется на wiring, payload и соответствие dependency contract.

Каждая runtime-capability получает явный adapter. Не скрывай несколько разных integrations как inline-функции внутри builder.

Dependency hooks

Если business должен предоставить hook, concrete hook передаётся через deps.

SWR/TanStack hook
  → dependency adapter
  → business-owned source hook contract
  → business wrapper hook
  → domain result / domain error

Business wrapper может:

  • вызвать переданный dependency hook;
  • нормализовать data в доменную модель;
  • заменить source error доменной ошибкой;
  • вычислить domain state и selectors;
  • вернуть собственный стабильный result type.

Dependency hook обязан быть non-throwing и non-Suspense: technical failure возвращается в error: unknown, а не выбрасывается во время вызова hook. Business wrapper обязан заменить этот error собственной domain error. Публичные callbacks dependency result, например refresh, также оборачиваются business и не могут выдать source error наружу.

Business wrapper не импортирует query library. В public contract не протекают SWRConfiguration, UseQueryResult, query keys, cache implementation или library-specific mutate API без отдельного domain contract.

Domain state

Business владеет:

  • моделью доменного state;
  • допустимыми переходами;
  • commands и selectors;
  • реакцией на dependency results и events.

Business не владеет concrete state manager. Business выбирает initial domain state и передаёт его в deps.state.create(initialState); adapter только создаёт concrete store с переданным значением.

Zustand/Redux/MobX adapter реализует business-owned state adapter factory и создаётся в compositions/business/{domain}. Business-фабрика получает adapter factory через deps, передаёт initial domain state и получает concrete port.

Локальный UI-state является другим случаем. Состояние раскрытия sidebar, выбранной вкладки или шага локального UI-flow может использовать concrete state manager непосредственно внутри владеющего composition module, если оно не подменяет доменное состояние и product data boundary.

Доменные ошибки

Из любого публичного метода, command, query или hook business-модуля выходят только собственные доменные ошибки.

Business обязан преобразовать:

  • rejected promise dependency;
  • synchronous throw dependency;
  • source hook error;
  • ошибку store/storage/browser API;
  • невалидный успешный ответ;
  • неизвестную runtime-ошибку.

Domain error содержит стабильный code. Исходная ошибка может сохраняться только в cause для диагностики.

Потребитель не ориентируется на:

  • message внешней ошибки;
  • HTTP status;
  • response;
  • SDK error class;
  • stack или transport code.

Adapter не импортирует и не создаёт domain error. Error mapping всегда выполняет business.

Rejected promise, synchronous throw, source error и malformed response всегда превращаются в domain error. Fallback допустим только для валидного доменного исхода, явно представленного dependency contract, например корректного отсутствия данных. Fallback не используется для поглощения technical failure или contract drift.

Чистый builder

compositions/business/{domain}/create-{domain}-business.ts только:

  1. явно создаёт или получает runtime instances нужного lifecycle без I/O;
  2. создаёт adapters поверх этих instances;
  3. передаёт adapters и API других доменов в factory;
  4. возвращает готовый {Domain}Api.

Builder не содержит:

  • inline SDK calls;
  • window/storage operations;
  • domain mapping;
  • domain errors;
  • Zustand/SWR setup вперемешку с другими dependencies;
  • product request при создании API;
  • скрытую subscription без cleanup contract.

Если capability имеет lifecycle, business API предоставляет domain-level start/subscribe operation, которая использует dependency и возвращает cleanup wrapper. Business преобразует ошибки регистрации, callback и cleanup в собственные domain errors. Builder остаётся без side effects, а владелец scope запускает operation после mount/commit и вызывает cleanup при завершении lifecycle.

Browser/application builder без cross-domain dependencies вызывается без аргументов. Для request scope integration module определяет отдельный requestScopeInput только с framework/request data, например headers, cookies, request ID и abort signal. Concrete client factory импортируется и вызывается внутри integration module. Request input используется только для создания adapters, не передаётся в business как raw client и не экспортируется consumer compositions.

Graph и lifecycle

Per-domain builder не является владельцем полного graph.

Владелец graph:

  • выбирает application-lifetime composition/route/page/request/test scope;
  • собирает домены в ацикличном порядке;
  • создаёт ровно необходимый набор API;
  • использует точный graph type;
  • управляет cleanup subscriptions/resources;
  • не повторяет adapter wiring;
  • не импортирует raw infra для «досборки» домена.

Не используй generic Partial<Business> с последующим as Business. Если scope содержит только Auth, его contract должен обещать только Auth.

Cross-domain dependencies

Один business-домен не импортирует runtime другого домена. Он объявляет нужную capability в своих Deps через type-only API другого домена, по возможности суженный Pick.

Готовый API передаётся builder-у при сборке graph:

createAuthBusiness()
  → createUserBusiness({ authApi })
  → createOrdersBusiness({ userApi })

Runtime-цикл означает ошибочную границу доменов. Не скрывай цикл service locator, lazy import или глобальным event bus.

Подробный контракт фабрики находится в Business-фабрике. Практическая сборка показана в Business composition.