Files
slm-design/docs/architecture/domains.md

221 lines
20 KiB
Markdown
Raw Permalink Normal View History

2026-08-10 14:42:29 +03:00
# Домены
Домен является специализированным SLM-модулем слоя `domains`. Он владеет одной связной предметной ответственностью, её сценариями, публичным контрактом, ошибками, состоянием, доменным UI и интеграцией с источниками данных.
Домен не создаёт новый структурный уровень над модулями. Он остаётся модулем-владельцем, отдельным узлом графа зависимостей и подчиняется всем общим [правилам модулей](./modules.md). Дополнительные правила домена защищают независимость предметной модели от внешних сервисов и технических контрактов.
## Место в структурной модели
```text
SLM root
└── domains # Слой
└── orders # Домен, специализированный модуль
├── index.ts # Публичный контракт
├── client.ts # Доменный UI при необходимости
├── ... # Внутренняя реализация
└── modules/ # Вложенные модули при необходимости
```
Как обычный модуль, домен:
- имеет одну физическую модульную границу;
- предоставляет единый логический публичный API через фасеты;
- владеет состоянием и жизненным циклом своей ответственности;
- использует другие модули только через их публичные API;
- может содержать сегменты и вложенные модули;
- участвует в общем ацикличном модульном графе.
Корень домена не является package-контейнером или группой модулей. Сегменты, функции преобразования, компоненты и source-specific код остаются внутренней реализацией ближайшего доменного модуля и не получают самостоятельного API только из-за технической роли.
## Что принадлежит домену
Домен полностью определяет предметный смысл ответственности:
- принимаемые команды, параметры и значения;
- возвращаемые модели и результаты;
- бизнес-правила, переходы и допустимые состояния;
- ожидаемые неуспешные исходы сценариев;
- смысл операций с продуктовыми данными;
- адаптацию внешних данных к доменному контракту;
- интерпретацию ошибок источника;
- состояние, доменный UI и framework-механизмы сценариев.
Техническая возможность сохраняет собственного владельца. Например, `infra` может владеть HTTP-транспортом, SDK runtime или storage, но домен определяет, зачем выполняется операция, какие данные она принимает и возвращает и какой предметный результат получает потребитель.
## Доменный контракт
Доменный контракт описывает ответственность в терминах продукта, а не источника данных. Он включает публичные входы, модели, результаты, события, доступные потребителям формы состояния и ожидаемые неуспешные исходы.
Контракт объявляется самим доменом. Даже если форма внешнего DTO временно совпадает с нужной моделью, домен создаёт собственную форму. Совпадение полей не передаёт источнику владение предметным контрактом.
```ts
// Один из возможных способов объявить доменный контракт.
export type Order = Readonly<{
id: OrderId
state: OrderState
total: Money
}>
export type GetOrderInput = Readonly<{
orderId: OrderId
}>
```
Недопустимо строить публичный контракт из типов источника:
```ts
// DTO источника стал доменной моделью.
export type Order = OrdersApiDto
// Внешний вызов стал публичным сценарием домена.
export const getOrder = ordersSdk.getOrder
// Форма результата выводится из SDK.
export type GetOrderResult = Awaited<ReturnType<typeof ordersSdk.getOrder>>
```
Источник может измениться, не меняя доменный контракт. Если новая форма источника не позволяет выполнить уже объявленный сценарий, меняется интеграция или принимается отдельное продуктовое решение, но контракт не подгоняется автоматически под DTO.
## Граница внешних данных
DTO, request types, response types и ошибки источника допускаются только во внутреннем интеграционном коде домена. До использования в правилах, состоянии, доменном UI или публичном результате внешнее значение адаптируется к доменному контракту.
Адаптация принадлежит домену, потому что только он определяет целевой предметный смысл. Она может быть реализована mapper-функцией, adapter-объектом, parser-ом или другим внутренним механизмом. SLM ограничивает результат пересечения границы, а не имя файла, функции или выбранный паттерн. Общий технический клиент при этом может принадлежать `infra`.
```ts
// Mapper является одним из возможных механизмов адаптации.
type OrderDto = Awaited<ReturnType<typeof ordersSdk.getOrder>>
const mapOrderDto = (dto: OrderDto): Order => ({
id: dto.order_id,
state: mapOrderState(dto.status),
total: mapMoney(dto.total),
})
```
Входящие и исходящие направления симметричны:
- response источника адаптируется к доменной модели;
- доменная команда адаптируется к контракту запроса источника;
- source-specific enum, nullable semantics и служебные поля не становятся частью доменной модели автоматически;
- невалидный ответ источника получает смысл, определённый доменом;
- DTO не сохраняется как продуктовое состояние и не передаётся доменному UI.
Внутренний механизм может быть близок к identity-преобразованию, но публичная граница остаётся независимой. Запрещены прямой реэкспорт, type alias, `Pick`, `Omit`, `ReturnType` или другое выведение публичной доменной модели из source type.
## Доменные ошибки
Домен самостоятельно определяет, какие неуспешные исходы его сценариев являются ожидаемыми и какой публичный контракт получают потребители. Ошибка источника не становится доменной ошибкой только потому, что была получена во время выполнения сценария.
SLM не устанавливает способ представления или передачи доменных ошибок. Проект может использовать exception, `Result`, discriminated union, отдельные типы сценариев или другую форму. Архитектурным инвариантом остаётся владелец смысла: потребитель зависит только от контракта текущего домена.
### Декларация и реализация
Доменная декларация определяет допустимые неуспешные исходы до реализации сценария и подключения источника. Реализация домена конструирует и возвращает только объявленные исходы, а интеграционный код преобразует ошибки источника в уже существующий доменный контракт.
Новый ожидаемый исход сначала добавляется в декларацию домена и только затем используется реализацией. Реализация, mapper, adapter или framework-механизм не объявляют собственные ошибки параллельно доменному контракту.
Декларация и реализация являются ролями внутри одного доменного модуля, а не новыми структурными сущностями, обязательными сегментами или именами файлов.
Публичный контракт ожидаемой ошибки не включает чужую ошибку в исходной форме:
- тип или экземпляр ошибки SDK;
- код и message внешнего сервиса;
- HTTP status или другой транспортный status источника;
- raw response payload;
- `cause`, stack trace или другие source-specific диагностические данные.
Домен может объявить собственные идентификаторы, данные для обработки и представления ошибки. Их форма, общий каталог, casing, имена полей и группировка по сценариям являются проектной policy, а не правилами SLM. Если проект выбирает машинные коды или единый union, соответствующие соглашения закрепляются в style guide и могут проверяться отдельным lint-правилом.
В примерах этой документации доменные коды ошибок записываются в `SCREAMING_SNAKE_CASE` по соглашению команды. Это соглашение определяет оформление примеров, но не является архитектурным требованием SLM.
```ts
// Публичная декларация домена Orders.
// Форма является project policy, а не обязательной формой SLM.
export type OrdersError =
| Readonly<{
code: 'ORDER_NOT_FOUND'
}>
| Readonly<{
code: 'ORDER_CANNOT_BE_CANCELLED'
payload: Readonly<{
currentState: OrderState
}>
}>
```
```ts
// Внутренняя реализация использует декларацию домена.
const createOrderNotFoundError = (): OrdersError => ({
code: 'ORDER_NOT_FOUND',
})
```
### Runtime-идентификация
SLM не требует универсального constructor, base class, marker, guard или parser для доменных ошибок. Домен предоставляет runtime-механизм идентификации только тогда, когда он необходим реальному потребителю и совместим с его средой выполнения.
| Условия использования | Возможный механизм |
|---|---|
| Типизированный результат внутри одного TypeScript-графа | Discriminated result без дополнительного guard |
| Ошибка поступает как `unknown` через `catch` | Domain guard или class с `instanceof` |
| Ошибка пересекает JSON, SSR, RSC, worker или другую serialization boundary | Сериализуемый discriminant и runtime parser |
| Значение приходит из недоверенной среды | Schema validation |
| Ошибка не покидает доменную реализацию | Публичный runtime-механизм не нужен |
Constructor или factory обычно остаётся внутренней частью реализации: внешние потребители распознают и обрабатывают доменные ошибки, но не создают их. Если потребителю действительно требуется runtime-идентификация, домен может открыть минимальную capability, например `isOrdersError` или `parseOrdersError`, через подходящий публичный фасет.
Механизм идентификации не переносит владение ошибкой. Общий product-agnostic marker или guard может принадлежать `shared`, но перечень ожидаемых исходов и domain-specific проверка остаются контрактом соответствующего домена.
## Преобразование ошибок источника
Домен интерпретирует ошибку источника в контексте текущего сценария. Одинаковый HTTP status может означать отсутствие предметного объекта, конфликт состояния, ошибку доступа или технический сбой, поэтому транспортный признак не передаётся потребителю как готовый доменный исход.
Ошибка источника, влияющая на публичный результат, преобразуется в собственный ожидаемый исход домена либо в неожиданный дефект согласно общей политике приложения. Raw ошибка может использоваться для внутренней диагностики и телеметрии, но не становится публичным контрактом домена.
Если домен использует API другого домена, он также не возвращает чужой error contract от своего имени. Неуспешный исход зависимого домена интерпретируется в терминах текущего сценария.
## Порядок создания домена
Интеграция с источником начинается только после определения предметной границы:
1. Сформулировать ответственность и сценарии домена.
2. Объявить доменный контракт входов, моделей и результатов.
3. Определить ожидаемые неуспешные исходы и их публичный контракт.
4. Определить границу между ожидаемым исходом и programming defect.
5. Только после этого определить внешние источники и технические зависимости.
6. Реализовать адаптацию запросов, ответов и ошибок выбранным внутренним механизмом.
7. Проверить сценарии и адаптацию источников независимо друг от друга.
8. Убедиться, что публичный API транзитивно не содержит типов источника.
Если контракт или семантика ошибок ещё не определены, запрос к реальному источнику не считается допустимым временным началом домена. Сначала создаётся предметная граница, затем к ней адаптируется источник.
## Проверка границы
При ревью домена проверяется:
- можно ли описать публичный контракт без упоминания API, endpoint, SDK или DTO;
- объявлены ли модели, результаты и ожидаемые неуспешные исходы самим доменом;
- использует ли реализация только исходы, объявленные доменной декларацией;
- не навязывает ли контракт источника форму доменной модели;
- адаптируются ли внешние значения до использования в правилах, состоянии и доменном UI;
- отсутствуют ли source types в публичных фасетах, состоянии и доменном UI;
- интерпретируются ли ошибки источника и зависимых доменов в терминах текущего сценария;
- не протекают ли наружу чужие error types, codes, messages, transport statuses, raw payload или cause;
- не маскируется ли programming defect под ожидаемый доменный исход;
- нужен ли реальным потребителям runtime-механизм идентификации и совместим ли он с их средой;
- не экспортирует ли домен constructor, guard, parser или schema без реального потребителя;
- не объявлена ли выбранная форма error contract универсальным требованием SLM.
## Связанные правила
- [`SLM-DOMAIN-R022`](../rules/registry.md#slm-domain-r022)
- [`SLM-DOMAIN-R023`](../rules/registry.md#slm-domain-r023)
- [`SLM-DOMAIN-R024`](../rules/registry.md#slm-domain-r024)
- [`SLM-DOMAIN-R025`](../rules/registry.md#slm-domain-r025)
- [`SLM-DOMAIN-R026`](../rules/registry.md#slm-domain-r026)
- [`SLM-MODULE-A004`](../rules/registry.md#slm-module-a004)
- [`SLM-MODULE-R006`](../rules/registry.md#slm-module-r006)
- [`SLM-MODULE-R011`](../rules/registry.md#slm-module-r011)
- [`SLM-MODULE-R012`](../rules/registry.md#slm-module-r012)