Aggregate / Aggregate Root

Сначала — базовое понятие. Aggregate (агрегат) — это кластер связанных Entity и Value Object, которые меняются как единое целое и имеют общую границу консистентности. Снаружи агрегат виден как один объект.

Aggregate Root (корень агрегата) — одна сущность внутри агрегата, единственная точка входа: весь внешний код работает только с корнем, а тот гарантирует инварианты всей группы.

export class TravellerGroup {            // ← Aggregate Root
  private constructor(private readonly travellers: Traveller[]) {}

  static create(travellers: Traveller[]): TravellerGroup {
    const holders = travellers.filter(t => t.relationCode === 'OWNER');
    if (holders.length !== 1) throw new DomainError('TRAVELLERS', 'Ровно один holder обязателен');
    return new TravellerGroup(travellers);
  }
}

То же без класса — брендированный тип + фабрика как единственная точка сборки:

type TravellerGroup = { readonly travellers: readonly Traveller[] } & { readonly __brand: 'TravellerGroup' };

export const travellerGroup = (travellers: Traveller[]): TravellerGroup => {
  const holders = travellers.filter(t => t.relationCode === 'OWNER');
  if (holders.length !== 1) throw new DomainError('TRAVELLERS', 'Ровно один holder обязателен');
  return { travellers } as TravellerGroup;                 // собран ⇒ инвариант держится
};

Та же брендированная техника, что у Value Object, даёт «единственную точку входа» и неизменность границы без класса. Операции над агрегатом — функции, возвращающие новую версию (addTraveller(group, t): TravellerGroup).

Зачем нужен

Без агрегата инвариант «размазывается»: проверка «ровно один holder» оказывается и в UI, и в use case, и в репозитории — и легко разъезжается. Агрегат собирает правило в одном месте и не даёт собрать невалидное состояние.

Правила

  • внешний код обращается только к корню, не к внутренним сущностям напрямую;
  • инвариант проверяется в домене (в конструкторе/фабрике корня), а не в UI;
  • агрегат — это граница транзакции: его грузят и сохраняют целиком (через Repository).

Когда вводить

Точечно — там, где есть группа объектов с общим инвариантом, который иначе не удержать. Не превращайте весь граф сущностей в агрегаты: лишние границы усложняют модель. В PDA агрегаты — часть full DDD (профиль Large); на Medium — выборочно.

Где в PDA

Слой domain (подслой внутри domains/<context>/). См. Effective Aggregate Design.

Anti-Corruption Layer (ACL)

ACL защищает домен от чужих контрактов: между внешним API (или другим контекстом) и доменом стоит прослойка из собственных DTO и мапперов, которая переводит чужую модель в твою.

Зачем

Backend и внешние сервисы навязывают свою форму данных — person_id, relationship_code, странные форматы, флаги вместо enum’ов. Если пустить эти DTO прямо в домен, чужая модель «протекает» внутрь: домен начинает говорить на языке чужого API, а смена контракта на той стороне ломает бизнес-логику. ACL не даёт коррупции пройти границу — наружу из ACL выходят только доменные модели.

Как работает

// Backend DTO → Domain model: всё «странное» остаётся снаружи
export const mapBackendTravellerDtoToDomain = (dto: BackendTravellerDto): Traveller => ({
  id: dto.person_id,
  relationCode: dto.relationship_code,
  contactsSame: dto.contacts_same ?? false,
});

Когда backend переименует поле или сменит формат — правка локализуется в одном мапере ACL, а домен и use cases не трогаются.

Где в PDA

Зона integrations/api/*/{dto,mappers}. Главное правило: backend-DTO не протекают в domain/application — туда попадают только доменные модели.

Когда применять

Всегда на границе с внешним API, чья модель не совпадает с доменной (а она почти всегда не совпадает). В PDA ACL обязателен уже на Medium. Если же контракт тривиально совпадает с доменом и стабилен — отдельный маппер может быть избыточен, но это редкий случай. См. Anti-Corruption Layer (Microsoft).

Architecture as code (declared, не implied)

В PDA архитектура объявляется, а не подразумевается.

  • Профиль и слои зафиксированы в architecture.manifest.json.
  • Изменения идут через ADR + MR с обоснованием.
  • Линтер автоматически следит за соблюдением границ в IDE и CI.

Что это убирает

«Архитектурный дрейф» — ситуацию, когда любая часть структуры может незаметно измениться: появляются слои-синонимы (views vs pages), бизнес-правила переезжают в Pinia, границы размываются код-ревью за код-ревью.

Когда архитектура — это код (манифест + правила линтера), она:

  • видима в diff;
  • проверяема автоматически;
  • переносима между проектами (один общий пакет).

Bounded Context

Bounded Context — независимый контекст со своей моделью, языком и правилами. Один и тот же термин в разных контекстах может значить разное: «Policy» при покупке и «Policy» при скачивании документа — это две разные модели.

Зачем

Попытка сделать одну «универсальную» модель на всё приложение приводит к объекту-монстру: Policy обрастает полями из покупки, скачивания, тарификации, и любое изменение в одной фиче рискует сломать другую. Bounded Context разрезает систему по швам разного смысла: внутри границы язык однозначен и модель заточена под одну задачу. Контексты эволюционируют независимо — это главный инструмент управления сложностью в большой системе.

Как работает

Внутри контекста — свои domain/application; наружу контекст торчит публичным API. Связь между контекстами явная и идёт через внешние слои (адаптеры, ACL), а не через прямой доступ к чужому домену.

Примеры: Purchase, PolicyDownload, FeatureToggle.

Когда и сколько

Выделяйте 2–5 контекстов там, где у модели разное значение терминов и разные инварианты. Не дробите по техническим признакам и не плодите контексты «на вырост» — пустая граница только мешает.

Где в PDA

  • Medium — контексты как папки domains/<context>/ со своими domain/application, без отдельных npm-пакетов и event-driven интеграции, пока нет нужды.
  • Large — контексты с собственным публичным API и версионированием; ядро каждого изолировано правилом bounded-context-isolation: domain/application одного контекста не могут импортировать другой даже через его публичный API.

Слои Clean Architecture

Четыре концептуальных слоя (снаружи внутрь):

  1. Infrastructure — HTTP, SDK, storage, фреймворк. Реализации портов.
  2. Interface Adapters — мосты между миром и приложением: presenters, stores, контроллеры, мапперы.
  3. Application — прикладные сценарии (use cases) и порты (интерфейсы).
  4. Domain — сущности, value objects, доменные сервисы. Самый стабильный слой; не знает о внешнем мире.

Маппинг на слои PDA (Medium/Large)

В PDA те же слои названы иначе — это литеральные имена в манифесте (allowedLayers):

Clean ArchitectureСлой в PDA
Infrastructureintegrations
Interface Adaptersadapters
Applicationapplication (подслой внутри domains/<context>/)
Domaindomain (подслой внутри domains/<context>/)

Слои живут на двух уровнях вложенности.

1. Уровень папок (их по именам знает манифест/линтер, поле allowedLayers) — это top-level каталоги под src/:

app → integrations → adapters → domains → platform

Здесь domains — один слой: папка-контейнер, внутри которой лежат все bounded-контексты.

2. Уровень внутри контекста. Внутри каждого domains/<context>/ есть Clean-подслои:

application → domain

Они не top-level папки — они спрятаны внутри контекста.

Если «развернуть» контейнер domains в его подслои, два уровня склеиваются в одну сквозную цепочку зависимостей:

app → integrations → adapters → application → domain

Каждая стрелка читается как «может зависеть от» — направление внутрь, по Dependency Rule: снаружи app, в самой глубине — стабильный domain. Слой platform в эту линейную цепочку не входит намеренно: это не шаг в потоке, а общий sink снизу (от него зависят все, он — ни от кого).

app — composition root (внешний Main-компонент Clean Architecture): entrypoint (main.ts), роутер, глобальная конфигурация, регистрация DI/плагинов и связывание конкретных адаптеров с внутренними слоями. Зависит внутрь от всего, от него — ничто (поэтому он наверху). Это не подслой application: bootstrap/wiring живут в app, use cases — в application внутри контекста.

platform — технический общий sink (framework-agnostic утилиты, базовые типы, generic-порты); сидит в самом низу — от него зависят все, он — ни от кого. Это не DDD Shared Kernel: общий кусок доменной модели, когда он реально нужен, — это опциональный bounded context domains/shared-kernel (см. Shared Kernel), а не top-level слой.

Clean Architecture

Clean Architecture (автор Роберт Мартин) — это способ так разложить код по слоям, чтобы бизнес-логика не зависела от фреймворка, БД и UI, а наоборот. В PDA — базовая схема для профилей Medium и Large.

Какую проблему решает

В типичном фронтенде бизнес-правило («полис нельзя купить без holder’а») оказывается размазано по Vue-компоненту, Pinia-стору и http-клиенту. Поменяли библиотеку запросов или стейт-менеджер — переписываем заодно и бизнес-логику; покрыть правило тестом нельзя, не подняв полпроекта. Clean Architecture отделяет что система делает (домен, сценарии) от как она это технически делает (HTTP, UI, SDK).

Как работает

Система делится на слои, а зависимости направлены строго внутрь — к домену:

Infrastructure → Interface Adapters → Application → Domain

В PDA те же слои названы иначе: Infrastructure → integrations, Interface Adapters → adapters (полный маппинг — в карточке Слои).

Внешние слои знают о внутренних, но не наоборот (Dependency Rule). Чтобы внутренний слой мог обратиться наружу (например, use case — к данным), он объявляет интерфейс-порт, а внешний слой его реализует (Ports & Adapters).

Это даёт:

  • тестируемость домена и сценариев без UI/HTTP (подменяем порты заглушками);
  • устойчивость к смене фреймворка и внешних API — меняется только внешний слой;
  • понятное разделение ответственности для разных видов кода — нет вопроса «куда это положить».

Когда применять

  • Medium/Large — да: несколько фич, нетривиальные правила, много интеграций.
  • Small — нет: на этой высоте слои и порты — лишняя церемония, достаточно MVVM (см. профили). Архитектура должна быть пропорциональна сложности — вводить порты ради CRUD-экрана вредно.

Строительные блоки

Разобраны в дочерних нодах: Слои, Dependency Rule, Ports & Adapters, Use Case, Repository, Gateway, Presenter/VM.

См. также родственные модели: Onion Architecture, Hexagonal.

Архитектура = управление сложностью

Главный принцип PDA (ADR-6, §1.1): архитектура существует, чтобы управлять сложностью, и стоит ровно столько, сколько за неё платит команда. Значимость решения измеряется стоимостью его изменения: заранее продумываем то, что потом дорого переделать — выбор технологий, границы и декомпозицию, — а остальное оставляем эволюции.

Две крайности, которых избегаем

  • Нет границ. Бизнес-логика, HTTP, состояние и разметка смешаны в компонентах и Pinia. Любое изменение рискованно, юнит-тесты невозможны без поднятия всего приложения.
  • Слишком много границ. Полный DDD (агрегаты, value objects, контекстные карты) на простом CRUD. Команда тратит время на «ярлыки» вместо домена.

Та же мера и для проектирования наперёд: расписать всю архитектуру до первой строчки кода так же вредно, как не проектировать вовсе. Стартовый дизайн задаёт направление, а не финальный идеал — за часы-дни понять, что строим и взлетит ли это, а дальше развивать по мере появления знаний.

Практический вывод

Выбираем минимально достаточный набор слоёв и правил. Усложняем структуру только когда появляется реальная боль (несколько подсистем, нестабильный API, много инвариантов), и фиксируем это решение в ADR.

Contract Test

Contract Test проверяет, что инфраструктурная реализация действительно соблюдает контракт порта.

describe('ApiQuoteRepository contract', () => {
  it('returns QuoteResultDto shape required by use case', async () => {
    const repo: QuoteRepository = new ApiQuoteRepository(httpClient);
    const result = await repo.getQuote({ visitDate: '2026-05-01', travellers: [] });
    expect(result).toHaveProperty('quoteId');
    expect(Array.isArray(result.schemes)).toBe(true);
  });
});

Где в PDA

Тестовый слой между application (порт) и integrations (адаптер). Покрывают критичные порты (оплата, поиск полиса) и ACL-мапперы, а не каждый метод.

Цель — поймать рассинхрон, когда адаптер «дрейфует» от того, что ожидает use case. Для межсистемных контрактов (фронтенд ↔ backend) применяют consumer-driven contract testing.

DDD (Domain-Driven Design)

Domain-Driven Design — подход, где предметная область и её язык становятся центром проектирования кода. Цель — чтобы структура кода повторяла структуру бизнеса, а не технических деталей.

Зачем

Когда правил домена много и они меняются, «анемичная» модель (данные в DTO + логика, размазанная по сервисам) расплывается: одно правило живёт в трёх местах и со временем разъезжается. DDD собирает правило туда, где ему место — в доменные объекты с понятным языком, — и тем удерживает сложность.

Как подключается в PDA

Дозированно, по профилю:

  • Medium — light DDD: use cases, порты, ACL обязательны; агрегаты/VO — выборочно.
  • Large — full DDD: агрегаты, доменные сервисы, value objects, contract-тесты, изоляция контекстов.

Строительные блоки

Начинайте со стратегии — как минимум с единого языка, а дальше границы контекстов и тактику вводите по ситуации. Не разворачивайте весь арсенал сразу.

Стратегические (организуют границы между доменами): Ubiquitous Language, Bounded Context, Shared Kernel, Anti-Corruption Layer.

Тактические (моделируют сам домен): Entity, Value Object, Aggregate, Domain Service, Domain Policy.

Добавляйте блок, когда его стоимость окупается инвариантами и языком домена.

Dependency Rule (правило зависимостей)

Главное правило Clean Architecture: исходный код зависимостей всегда указывает внутрь. Внешние слои могут зависеть от внутренних; внутренние ничего не знают о внешних. В частности, domain не импортирует Vue, Pinia, Axios или внешние SDK.

Зачем

Это правило — то, что вообще делает архитектуру «чистой». Если домен импортирует Axios, то домен нельзя протестировать без сети, нельзя переиспользовать вне этого фреймворка и нельзя поменять транспорт, не задев бизнес-логику. Стрелка зависимости — это направление, в котором распространяются изменения: правило держит её так, чтобы нестабильные детали (UI, API, SDK) зависели от стабильного домена, а не наоборот.

Как пересекать границу «внутрь → наружу»

Сценарию из application часто нужно достать данные — то есть обратиться наружу. Делается это через инверсию зависимостей (DIP): внутренний слой объявляет интерфейс (порт), внешний — реализует его (Ports & Adapters).

// ❌ нарушение — application тянет конкретный HTTP-клиент
import { axios } from 'axios';

// ✅ application зависит от своего порта, реализация — снаружи
export interface QuoteRepository {
  getQuote(cmd: GetQuoteCommand): Promise<QuoteResultDto>;
}

Так use case вызывает «репозиторий», не зная, что под ним HTTP: стрелка от integrations к application идёт внутрь, к интерфейсу.

Как проверяется в PDA

Правило автоматизировано линтером layer-boundaries: ребро domain → external:* запрещено, обратные рёбра между слоями — тоже. Нарушение — ошибка сборки, а не вопрос code review. Это превращает архитектурный принцип в fitness function.

Частые ошибки

  • импорт типа из integrations в domain «просто чтобы переиспользовать» — это уже обратная стрелка;
  • доменная модель, повторяющая форму backend-DTO, — признак протёкшей наружу зависимости (лечится ACL).

A3. Business Rules Inventory

Инвентарь бизнес-правил проекта и их размещения: где правило живёт сейчас и где должно жить по целевому профилю.

Что внутри

Таблица правил:

ПолеОписание
IDидентификатор правила
Описаниечто за правило (на языке бизнеса)
Текущее местогде живёт сейчас (часто Pinia/composable)
Целевой слойdomain / application / integrations / adapters

Зачем

Документ про бизнес-уровень правил, а не их текущее расположение в коде. У каждого правила определён целевой слой; правила без него — открытый архитектурный вопрос. Это вход для миграции (Strangler Fig).

A2. Dependency Map

Объективная карта текущих зависимостей и пересечений слоёв в проекте, включая циклы.

Что внутри

  • кто импортирует ключевые слои/модули;
  • найденные циклы;
  • проблемные crossing-layer рёбра, требующие обсуждения;
  • инструмент анализа графа зависимостей, которым построена карта.

Зачем

Дополняет автоматический сигнал линтера человекочитаемой картой. Проблемные зависимости явно перечислены и связаны с задачами на исправление. Критерий готовности: выделены проблемные crossing-layer зависимости.

A1. Domain Glossary

Единый словарь доменных терминов — источник истины для имён сущностей, ролей и концепций, встречающихся в коде, документации и обсуждениях.

Что внутри

  • ключевые термины с однозначными определениями;
  • связи между терминами (обычно фрагмент ER);
  • правило приоритета: при противоречиях между кодом, доками и задачами — приоритет у глоссария.

Зачем

Это материализация Ubiquitous Language. Один источник, на который ссылаются ревью, код и follow-up ADR. Критерий готовности: один источник истины для терминов.

Domain Policy (бизнес-политика)

Domain Policy выделяет отдельное бизнес-правило выбора или допуска в самостоятельный, явно названный объект: «какую схему выбрать по умолчанию», «допустим ли этот addon».

Зачем

Правила выбора/допуска любят жить в виде if-ветвлений внутри сценария или компонента. Пока ветвление одно — терпимо; но такие правила меняются часто и независимо от остального сценария, а «зашитые» в if они не тестируются отдельно и дублируются. Вынесение политики в отдельный класс делает правило именованным (его видно в коде на языке домена), тестируемым в изоляции и переиспользуемым.

Как работает

export class PricingSelectionPolicy {
  pickDefaultScheme(schemes: Array<{ id: string; price: number }>) {
    if (!schemes.length) throw new Error('Нет доступных схем');
    return [...schemes].sort((a, b) => a.price - b.price)[0];
  }
}

То же без класса — политика это функция-предикат или функция-выбор:

export const pickDefaultScheme = (schemes: Array<{ id: string; price: number }>) => {
  if (!schemes.length) throw new Error('Нет доступных схем');
  return [...schemes].sort((a, b) => a.price - b.price)[0];
};

// eligibility-правило — предикат: (context) => boolean
export const isAddonEligible = (addon: Addon, ctx: PolicyContext): boolean =>
  addon.regions.includes(ctx.destination) && ctx.durationDays <= addon.maxDays;

Specification в FP-стиле — это комбинаторы над предикатами: and/or становятся обычными функциями высшего порядка ((a, b) => (x) => a(x) && b(x)), без иерархии классов.

Примеры: PricingSelectionPolicy, eligibility-правила для addon. Часто реализуется в духе Specification (Fowler/Evans) — правило как объект, который можно комбинировать (and/or).

Domain Policy vs Domain Service

Граница тонкая: и то, и другое — доменная логика в слое domain. Policy акцентирует решение/критерий («выбрать», «допустить ли»), Domain Service — вычисление/преобразование. На практике политику можно считать частным видом сервиса.

Где в PDA

Слой domain; вызывается из application (use case). Часть full DDD (Large); на Medium — для правил, которые реально меняются и заслуживают имени.

Domain Service

Domain Service — доменная логика, которая не принадлежит ни одной сущности или агрегату, но всё ещё является частью домена. Это правило без явного владельца, которому тесно внутри одного объекта.

Зачем

Не всякое правило укладывается в Entity или Aggregate. Если расчёт оперирует несколькими сущностями сразу или это чистое вычисление без естественного владельца, его пытаются впихнуть в случайную сущность («пусть будет в Policy») — и та раздувается чужой логикой. Domain Service даёт такому правилу явное место: отдельная единица с доменным именем, которая остаётся частью домена и не знает о внешнем мире.

Как работает

export class PolicyDurationResolver {
  resolve(rawDays: number): number {
    if (rawDays > 330) return 365;
    return Math.ceil(rawDays / 30) * 30;
  }
}

То же без класса — чистое доменное правило это просто функция:

export const resolvePolicyDuration = (rawDays: number): number =>
  rawDays > 330 ? 365 : Math.ceil(rawDays / 30) * 30;

У domain service нет состояния и идентичности, поэтому в TypeScript он естественно превращается в функцию (или модуль из нескольких функций): классу здесь нечего хранить, он лишь добавляет пустую обёртку. Имя остаётся доменным: resolvePolicyDuration, normalizeTravellers.

Примеры: TravellerNormalizationService, PolicyDurationResolver, InboundAddonEligibilityService.

Признак, что нужен именно domain service

Правило — вычислительное/смысловое, оперирует несколькими сущностями и не имеет естественного места ни в одной из них. Если же правило про одну сущность — ему место в ней, а не в сервисе (иначе вернётесь к анемичной модели).

Domain Service vs Use Case

Их легко спутать — оба «сервисы».

  • Domain Service (domain) — правило домена, не знает про порты, HTTP, сценарий.
  • Use Case (application) — оркестрация шагов сценария, знает про порты и вызывает доменные правила.

Где в PDA

Слой domain. Часть full DDD (Large); на Medium — по необходимости.

Entity (сущность)

Entity — доменный объект с идентичностью (id), которая важнее значений атрибутов. Сущность живёт во времени и меняет состояние, оставаясь «той же самой».

Зачем

Главный вопрос при моделировании объекта: «что делает его собой?». Если это конкретность, которую мы отслеживаем во времени (этот полис, этот traveller) — у неё должна быть идентичность, и сравнивать её надо по id, а не по полям. Спутать это с Value Object — частая и дорогая ошибка: если сравнивать сущность по значению, два traveller’а с одинаковым именем «схлопнутся» в одного, а изменение поля будет выглядеть как «другой объект».

// Два пользователя с одинаковым именем — РАЗНЫЕ сущности (разный id)
class Traveller {
  constructor(readonly id: string, public relationCode: string) {}
  sameAs(other: Traveller) { return this.id === other.id; }   // сравнение по identity
}

То же без класса — type для данных и отдельная функция равенства по id:

type Traveller = { readonly id: string; relationCode: string };

const sameTraveller = (a: Traveller, b: Traveller) => a.id === b.id;   // сравнение по identity

Суть сущности не в том, класс это или объект, а в правиле равенства: сравниваем по id, а не по полям. В FP-стиле идентичность держит функция sameTraveller, а изменение состояния — это новая версия объекта ({ ...t, relationCode }) с тем же id.

Entity или Value Object?

Спросите: важна ли здесь идентичность или только значение?

  • «Тот самый traveller, даже если поменял фамилию» → Entity (сравнение по id).
  • «Просто сумма денег / номер паспорта» → Value Object (сравнение по значению, неизменяемость).

Это базовое различение DDD — Evans Classification.

Где в PDA

Слой domain (подслой внутри domains/<context>/). Часто сущность живёт внутри агрегата под управлением его корня (Aggregate) — тогда снаружи к ней обращаются только через корень, а не напрямую.

ESLint → Oxlint

Сейчас линтер реализован на ESLint; стратегическая цель — переход на Oxlint (написан на Rust → скорость + единый toolchain).

Что переносим

В PDA пять архитектурных правил: четыре инварианта (работают во всех профилях) — barrel-only-index, public-api-only, layer-boundaries, no-imaginary-layers — плюс bounded-context-isolation, который включается только в профиле Large (full DDD). Переносить нужно все пять.

Условия миграции

  1. Oxlint поддерживает кастомные правила (или эквивалент) — без этого не переехать (см. ниже).
  2. Паритет экосистемы: аналоги import/no-internal-modules, no-restricted-paths для правил, которые сводятся к проверке путей импорта.
  3. Готовый плагин для Vue/SFC (не блокирует, если правила работают на уровне импортов в .ts).

Насколько это реально

Замысел — описать правила декларативно (whitelists + JSON-манифест, без императивного JS), тогда миграция свелась бы к замене рантайма. Но не все правила декларативно выражаются:

  • public-api-only — кастомное правило с автофиксом (обрезает путь до публичного входа: @/x/y/lib/z → @/x/y); в ADR-6 помечено как риск R1 и требует unit-тестов. Это императивная логика, а не конфиг.
  • barrel-only-index — ограничивает набор допустимых AST-узлов в index.ts (только re-export’ы, без Function/Class/Variable-деклараций). Тоже кастомное AST-правило, а не whitelist путей.

Поэтому миграция — не просто смена линтера: она упирается в поддержку Oxlint’ом кастомных правил/фиксеров и в переписывание этих двух правил на его API. Правила, сводящиеся к путям импорта (layer-boundaries, no-imaginary-layers, bounded-context-isolation), переносятся легче. Допустим промежуточный режим: Oxlint в pre-commit, ESLint в CI как страховка.

Эволюция и качество

Архитектуру мало спроектировать — её нужно безопасно вводить и удерживать от деградации.

  • Миграция без big-bang: Strangler Fig — новый слой рядом со старым, постепенное вытеснение legacy.
  • Проверка контрактов: Contract Test — реализация действительно соблюдает порт.
  • Quality Gates: автоматические проверки в CI: линтер + фитнес-функции.
  • Живые документы: обязательная документация — Glossary, Dependency Map, Business Rules Inventory.

Всё это — продолжение идеи fitness-функций: архитектура держится автоматикой, а не памятью команды.

Fitness Functions (фитнес-функции)

Fitness-функция — это автоматическая проверка, которая удерживает систему в заданных архитектурных границах. Термин из книги Building Evolutionary Architectures (Ford, Parsons, Kua).

Зачем

Архитектурное соглашение, не подкреплённое автоматической проверкой, деградирует за 2–3 спринта: оно держится только на памяти ревьюеров и размывается с каждым код-ревью, особенно при росте команды. Fitness-функция переносит правило из режима «помним и следим вручную» в «проверяет машина в IDE и CI».

В PDA

Первая и обязательная fitness-функция — архитектурный линтер. В первой итерации это 5 правил: 4 инварианта для всех профилей (barrel-only-index, public-api-only, layer-boundaries, no-imaginary-layers) плюс bounded-context-isolation для профиля Large. Линтер:

  • работает в IDE (мгновенная обратная связь);
  • блокирует merge в CI при нарушениях;
  • читает профиль из манифеста.

Дальше — любые другие фитнес-функции под нужды проекта: соответствие графа зависимостей манифесту (запрещённые рёбра, циклы), contract-тесты, бюджеты размера/производительности и прочие инварианты по решению архитектора. Подробнее — ветка Quality Gates.

Gateway

Gateway — унифицированный вход к внешнему сервису или SDK. В отличие от репозитория (данные/коллекции сущностей) gateway оборачивает сервис или возможность: капчу, фичефлаги, аналитику, observability.

Зачем

Вендорские SDK (Firebase, Sentry, провайдер капчи) — это нестабильные внешние детали с громоздким API. Если звать их прямо из domain/application, бизнес-логика оказывается прибита к конкретному вендору: сменить провайдера или замокать его в тесте невозможно без правок сценария. Gateway прячет вендора за узким доменным интерфейсом — наружу торчит только то, что сценарию реально нужно.

Как работает

// Port (application) — потребность сценария, без имени вендора
export interface FeatureFlagGateway {
  isEnabled(flag: string): Promise<boolean>;
}
// Реализация (integrations) обернёт конкретный remote-config SDK.

Типичные шлюзы: FeatureFlagGateway, CaptchaGateway, AnalyticsPort, ObservabilityPort.

Где в PDA

  • Интерфейсы — application;
  • Реализации — integrations (analytics, remote-config, observability).

Главная польза: domain/application не импортируют вендоров напрямую. Это и проверяет layer-boundaries ребром domain/application → external:*.

Когда применять

Как только появляется обращение к внешнему сервису, у которого может смениться поставщик или который надо подменять в тестах. Для разового тривиального вызова без логики gateway может быть избыточен — но обычно дешевле ввести его сразу, чем выпутывать SDK из домена позже.

JSON Schema (валидация манифеста)

Манифест architecture.manifest.json имеет описанную JSON Schema и валидируется при загрузке.

В пакете линтера за это отвечает helper loadManifest(dir): он читает файл, проверяет его по схеме и разворачивает пресеты (например, для nuxt-* подставляет allowedLayers/forbiddenEdges).

Зачем схема:

  • ловит опечатки в именах полей/профилей на этапе подключения, а не в рантайме;
  • документирует допустимую форму манифеста;
  • даёт автодополнение в IDE (через $schema).

Линтер (архитектурные fitness-функции)

Общий пакет компании: @qicgroupdoha/eslint-plugin-qic-rules (плагин pda, ключи правил вида pda/<rule>).

В первой итерации — четыре инвариантных правила, общих для всех профилей, плюс одно для Large:

  1. barrel-only-index — barrel только для re-export;
  2. public-api-only — без глубоких импортов (с autofix);
  3. layer-boundaries — направление зависимостей;
  4. no-imaginary-layers — фиксированный набор слоёв;
  5. bounded-context-isolation — изоляция контекстов (только large).

Подключение — одной строкой:

import { createConfig } from '@qicgroupdoha/eslint-plugin-qic-rules';
export default [ createConfig({ files: ['src/**/*.{ts,vue}'] }) ];

Правила задуманы декларативно (whitelists + JSON-манифест, без императивного JS в правилах) — это критично для будущей миграции на Oxlint.

Обязательная документация проекта

Линтер обеспечивает структурные инварианты, но не отвечает на вопрос «о чём этот проект». Часть архитектуры живёт в документах (ADR-6, §2.6).

Каждый проект (независимо от профиля) ведёт три живых документа:

  • Domain Glossary — словарь доменных терминов;
  • Dependency Map — карта текущих зависимостей и циклов;
  • Business Rules Inventory — инвентарь бизнес-правил и их расположение в коде.

Это не одноразовые артефакты: они обновляются по мере развития проекта и пересматриваются при смене профиля.

Architecture Manifest

Файл architecture.manifest.json в корне проекта — единственный источник правды о профиле и слоях. Его читают линтер и follow-up ADR.

{
  "profile": "medium",
  "allowedLayers": ["app", "integrations", "adapters", "domains", "platform"],
  "forbiddenEdges": [
    { "from": "@/domains/*/domain/**", "to": ["external:*"] }
  ],
  "publicApiEntrypoints": ["index.ts"],
  "deepImportAllowList": [],
  "transition": { "to": "large", "start": "2026-04-01" }
}

Смена профиля — это явный MR/ADR с обоснованием, а не дрейф. Паттерны в манифесте всегда пишутся с каноничным префиксом @/; на этапе подключения он переписывается на реальный alias проекта (~, @app, …).

Поля разобраны в дочерних нодах.

manifest · allowedLayers

Белый список имён слоёв верхнего уровня (string[]).

"allowedLayers": ["app", "integrations", "adapters", "domains", "platform"]

Любая новая папка под src/ или новый alias-префикс @/<layer>, которого нет в списке, — ошибка правила no-imaginary-layers, пока имя не добавлено в манифест (и не сопровождено ADR).

Это главный барьер против «дрейфа терминологии» (views vs pages, services vs application).

Для nuxt-* может быть опущено — берётся из пресета.

manifest · deepImportAllowList

Glob-паттерны (по полному aliased-импорту), для которых public-api-only разрешает глубокие импорты — слайсы, у которых намеренно нет barrel.

"deepImportAllowList": ["@/shared/ui/**"]

Profile-driven: FSD-профили (fsd, nuxt-fsd) по умолчанию включают @/shared/ui/**, остальные — [].

  • значение в манифесте заменяет (не дополняет) дефолт профиля;
  • явный пустой массив [] — полностью отключает исключения.

Типичный кейс: UI-примитивы в shared/ui импортируются по файлам ради tree-shaking — единый barrel на все компоненты вредит сборке.

manifest · forbiddenEdges

Запрещённые рёбра импортов ({ from, to[] }[]) — задают направление зависимостей для правила layer-boundaries.

"forbiddenEdges": [
  { "from": "@/domains/*/domain/**", "to": ["external:*"] }
]
  • from — glob по пути файла, который импортирует;
  • to — куда импортировать нельзя: префикс слоя (@/app), пакет (vue), glob (@sentry/*) или спец-токен external:* (любой внешний импорт).

external:* удобно, чтобы одним правилом изолировать domain от всех сторонних библиотек/SDK, не ведя список вручную.

manifest · profile

Выбранный профиль проекта. Обязательное поле.

Допустимые значения:

small | medium | large | fsd | nuxt-small | nuxt-medium | nuxt-large | nuxt-fsd

Профиль определяет:

  • какие имена слоёв разрешены (allowedLayers);
  • какие рёбра импортов запрещены (forbiddenEdges);
  • какие правила линтера активны (например, bounded-context-isolation включается только для large);
  • значения по умолчанию для deepImportAllowList.

Для nuxt-* часть полей берётся из встроенного пресета.

manifest · publicApiEntrypoints

Имена barrel-файлов, считающихся публичным API модуля (string[]).

"publicApiEntrypoints": ["index.ts"]

По умолчанию — ["index.ts"].

Эти файлы:

  • проверяются правилом barrel-only-index (только re-export, без логики);
  • являются единственной разрешённой точкой импорта для правила public-api-only.

manifest · transition

Фиксирует ситуацию миграции между профилями.

"transition": { "to": "large", "start": "2026-04-01" }
  • to — целевой профиль, на который мигрируем (исходный — это сам profile, не дублируется);
  • start — ISO-дата начала миграции.

Что даёт:

  • временно расширяет whitelist слоёв (сосуществование двух наборов слоёв в духе Strangler Fig);
  • start используется fitness-функцией для метрики «сколько проект живёт в transit-режиме» — сигнал, что миграция затянулась.

null — миграции нет.

Mapper

Mapper — преобразование между моделями соседних слоёв (wire-DTO ↔ домен ↔ ViewModel), которое не создаёт зависимости между ними. Это «клей», позволяющий каждому слою иметь свою модель.

Зачем

Каждый слой хочет свою форму данных: integrations — wire-DTO с формы backend, domain — доменную модель, UI — ViewModel под экран. Если переиспользовать одну структуру на всех — слои жёстко связываются, и изменение формата ответа backend дотягивается до шаблона. Mapper разрывает связь: он знает про обе стороны, а сами слои друг о друге — нет. Поэтому маппер — однонаправленная функция, которую тривиально протестировать.

Как работает

export const mapApiErrorToApplicationError = (status: number, code?: string): ApplicationError => {
  if (status === 429) return { type: 'RATE_LIMIT', message: 'Too many requests' };
  if (code === 'no_name_error') return { type: 'NOT_FOUND', message: 'Policy not found' };
  return { type: 'UNKNOWN', message: 'Unexpected error' };
};

Где в PDA

  • в ACL (integrations/api/*/mappers) — wire-DTO ↔ доменные модели;
  • в UI-адаптерах (adapters) — доменные модели → ViewModel (Presenter).

Mapper, ACL, Presenter — как соотносятся

Mapper — это приём (направленное преобразование). ACL и Presenter — места, где этот приём применяется: ACL мапит на границе с внешним миром (защита домена), Presenter — на границе с UI (подготовка к показу). Часто работает в паре с DTO.

Минимально достаточный профиль

Ключевой принцип выбора в PDA: берём минимально достаточный профиль.

  • Усложнение (Small → Medium → Large) — только через явный ADR на проекте, фиксирующий причину перехода.
  • Профиль nuxt — это про стек, а не «вместо» градации сложности.

Это прикладная форма принципа YAGNI: не вводим слои, порты и агрегаты, пока нет фактической боли. Соответствует Proportional architecture.

Сигналы, что профиль стал тесным

  • несколько команд конфликтуют в одних модулях;
  • нужны версионируемые публичные API между подсистемами;
  • доменная модель обязательна для compliance/аудита.

PDA — Profile-Driven Architecture

PDA — это не «ещё одна архитектура» вместо FSD или Clean. Это способ управлять архитектурой проекта.

Идея в трёх пунктах:

  1. Профиль. У проекта есть профиль — конкретная архитектура из фиксированного списка (Small, Medium, Large, FSD, Nuxt), выбранная соразмерно сложности продукта.
  2. Явное объявление. Профиль записан в манифесте architecture.manifest.json, а не подразумевается. Смена профиля — это явный ADR/MR с обоснованием, а не «архитектурный дрейф».
  3. Общий тулинг. Один пакет линтера читает манифест и включает набор правил для выбранного профиля.

Поэтому «у нас PDA» означает «наша архитектура объявлена в манифесте и поддержана общим линтером», а конкретный проект с профилем Medium корректно описать как «у нас Clean Architecture». Это разные уровни ответа на один вопрос: PDA — про метод выбора и поддержки архитектуры, профиль — про её конкретное содержание.

Начните с ветки «Зачем PDA» (мотивация), затем Profiles и Manifest (механика), затем Clean Architecture и DDD (словарь паттернов).

Ports & Adapters (Hexagonal)

Архитектурный стиль Алистера Кокбёрна (он же Hexagonal). Внутренние слои задают интерфейсы — ports, а конкретные реализации подключаются снаружи как adapters. Это механизм, которым исполняется Dependency Rule.

Зачем

Это ответ на вопрос «как внутреннему слою дотянуться до внешнего мира, не зная о нём». Порт — это потребность сценария, выраженная на его языке («мне нужно получить котировку»). Адаптер — конкретный способ её удовлетворить («сходить в REST API и смаппить ответ»). Разделение даёт две вещи: домен/сценарии тестируются с подставными портами, а замена транспорта (REST → GraphQL, SDK v1 → v2) не трогает бизнес-логику.

Как работает

// Port (application) — ЧТО нужно сценарию, на его языке
export interface QuoteRepository {
  getQuote(cmd: GetQuoteCommand): Promise<QuoteResultDto>;
}

// Adapter (integrations) — КАК это сделано
export class ApiQuoteRepository implements QuoteRepository {
  async getQuote(cmd: GetQuoteCommand) { /* вызов backend + маппинг */ }
}

То же без класса — порт это type, адаптер это фабрика:

// Port (application) — ЧТО нужно сценарию
export type QuoteRepository = { getQuote(cmd: GetQuoteCommand): Promise<QuoteResultDto> };

// Adapter (integrations) — КАК это сделано
export const makeApiQuoteRepository = (http: HttpClient): QuoteRepository => ({
  getQuote: (cmd) => http.post('/quotes', cmd) /* + маппинг */,
});

Паттерн не про классы, а про направление зависимости: порт-тип объявлен внутри (application), фабрика-реализация живёт снаружи (integrations) и подключается в точке сборки. Порт принадлежит внутреннему слою (это его потребность), реализация — внешнему. В тесте вместо ApiQuoteRepository (или makeApiQuoteRepository) подставляется фейк — сценарий проверяется без сети.

Где в PDA

  • Порты — в application каждого контекста.
  • Адаптеры к внешним системам — в integrations (Repository, Gateway).
  • Адаптеры к UI — в adapters (stores, presenters).

Когда применять

На Medium/Large, где есть реальные внешние зависимости и сценарии. На Small порт между view и integrations — лишняя прослойка: компонент зовёт API-клиент напрямую. Не плодите порт там, где реализация заведомо одна и тестировать нечего.

Presenter / ViewModel

Presenter (он же ViewModel-mapper) готовит данные домена в форму, удобную конкретному экрану, чтобы UI-компонент остался «глупым» — только отрисовка.

Зачем

Доменная модель оптимальна для бизнес-логики, но не для верстки: экрану нужны строки в нужном формате, флаги «показывать ли кнопку», производные поля. Если считать это прямо в компоненте, шаблон обрастает логикой, её нельзя протестировать без рендера, а одно и то же преобразование дублируется по экранам. Presenter выносит «подготовку к показу» в отдельное место: её легко покрыть тестом, а компонент получает готовую ViewModel.

Три формы ViewModel в реактивном Vue

Чистая функция-маппер возвращает снимок: посчитали один раз — и всё. Но состояние экрана в Vue почти всегда реактивное (источник меняется — VM должна пересчитаться). Поэтому в реальном коде presenter принимает одну из трёх форм; выбор зависит от того, есть ли у VM собственное состояние и где оно живёт.

1. Чистая функция-маппер

Когда преобразование разовое и без своего состояния — это просто DTO → VM. Реактивность добавляется снаружи, обёрткой в computed:

export const toQuoteViewModel = (quote: QuoteResultDto): QuoteVm => ({
  id: quote.quoteId,
  schemeCount: quote.schemes.length,
  canProceedToPayment: quote.schemes.length > 0,   // решение «показывать кнопку» — здесь, не в шаблоне
});

// в компоненте/композабле источник реактивный → пересчёт автоматический
const vm = computed(() => toQuoteViewModel(quote.value));

Функцию легко тестировать в изоляции — её и стоит покрывать unit-тестами, даже если используется она внутри store/композабла.

2. Composable, возвращающий реактивную VM

Когда у VM есть производное состояние, локальное для одного экрана (или поддерева) и делиться им между несвязанными компонентами не нужно. Composable связывает реактивный источник, computed-поля и действия:

export function useQuoteVm(params: Ref<QuoteParams>) {
  const quote = ref<QuoteResultDto | null>(null);
  const isLoading = ref(false);

  const schemeCount = computed(() => quote.value?.schemes.length ?? 0);
  const canProceedToPayment = computed(() => schemeCount.value > 0);

  async function load() {
    isLoading.value = true;
    try {
      quote.value = await getQuote(params.value);   // use case из application
    } finally {
      isLoading.value = false;
    }
  }

  return { isLoading, schemeCount, canProceedToPayment, load };
}

3. Pinia store (основной носитель состояния экрана)

Когда состояние общее или живёт дольше одного компонента — несколько экранов, сохранение между переходами, доступ из разных мест. Это типовая форма presenter в Medium/Large. В setup-синтаксисе роли ложатся на привычные примитивы Vue:

  • ref — UI-state;
  • computed (getters) — производная ViewModel (то, что раньше считал маппер);
  • actions — вызов use case, маппинг ошибок в прикладные типы.
export const useQuoteStore = defineStore('quote', () => {
  // UI-state
  const quote = ref<QuoteResultDto | null>(null);
  const isLoading = ref(false);

  // getters = реактивная ViewModel (производные поля для шаблона)
  const schemeCount = computed(() => quote.value?.schemes.length ?? 0);
  const canProceedToPayment = computed(() => schemeCount.value > 0);

  // action = оркестрация сценария: вызвать use case, разложить результат по state
  async function load(params: QuoteParams) {
    isLoading.value = true;
    try {
      quote.value = await getQuote(params);   // use case из application
    } finally {
      isLoading.value = false;
    }
  }

  return { quote, isLoading, schemeCount, canProceedToPayment, load };
});

В компоненте состояние/getters достаём через storeToRefs (чтобы не потерять реактивность), а actions — напрямую:

const store = useQuoteStore();
const { isLoading, schemeCount, canProceedToPayment } = storeToRefs(store);
const { load } = store;

Сложный маппинг при этом не обязан жить внутри store: getter может звать чистую функцию toQuoteViewModel — тогда store отвечает за состояние, а функция остаётся изолированно тестируемой.

Как выбрать форму

ФормаКогда
Чистая функцияразовое DTO → VM без своего состояния; вызывается внутри computed/композабла/getter
Composableпроизводное состояние локально для одного экрана; делиться между несвязанными компонентами не нужно
Pinia storeсостояние общее или живёт дольше компонента (несколько экранов, сохранение между переходами, доступ из разных мест)

Все три не исключают друг друга: store/композабл держит реактивное состояние, а внутри зовёт чистую функцию-маппер для сложного преобразования.

Где в PDA

Слой adapters (stores / presenters / view-mappers). Pinia-store — тонкий адаптер состояния: UI-state + вызов use case + маппинг в VM. Он НЕ держит бизнес-инварианты, HTTP-детали и retry-политику — это слои domain/application/integrations. Getters store = ViewModel, actions = вызов use case, без знания DTO бэкенда и вендорских SDK.

Когда применять

Как только у экрана появляется производное состояние или ветвление отображения. На совсем простом экране отдельный presenter не обязателен — маппинг можно сделать в composable; форму наращивают по мере роста компонента (функция → composable → store). Идея восходит к Presentation Model (Fowler) и лежит в основе MVVM (профиль Small).

Профиль FSD (Feature-Sliced Design)

Особый случай: команда осознанно остаётся в FSD, и продукт укладывается в его модель.

Слои

app → pages → widgets → features → entities → shared

Импорты разрешены «сверху вниз»; обратные рёбра запрещены.

Особенности в PDA

  • Те же 4 инвариантных правила линтера, только свой whitelist слоёв.
  • deepImportAllowList по умолчанию включает @/shared/ui/**: UI-примитивы импортируются по файлам (@/shared/ui/Button.vue) — единый barrel на все компоненты вредит tree-shaking.

Детали FSD-специфичных правил и требований к публичному API entities/widgets — отдельный ADR (ADR-fsd-conventions). См. также feature-sliced.design.

Профиль Large (Clean Architecture + full DDD)

Самый строгий профиль: всё из Medium плюс жёсткая изоляция контекстов и полный DDD.

Когда проект «большой»

  • несколько фронтенд-команд в одном продукте (микрофронтенды / module federation / монорепо с публикуемыми пакетами);
  • долгоживущий SPA со сложными UX-флоу;
  • фронтенд-артефакты публикуются между командами (design system, внутренний SDK, встраиваемые виджеты);
  • серьёзная доменная логика на клиенте (расчёты, валидации, а не «тонкая обёртка над backend»);
  • строгие NFR: SSR/SSG, offline-first, optimistic mutations, client observability.

«Минимально достаточный профиль» остаётся правилом выбора: до Large доходят осознанно, через ADR.

Что добавляет поверх Medium

1. Явные bounded contexts с публичным API

Контексты получают собственный публичный API между собой (даже внутри монорепо) и версионирование этого API — межконтекстный контракт становится единицей версионирования внутри проекта. Само версионирование — вне линтера (отдельный follow-up ADR).

2. Изоляция ядра контекста

Правило bounded-context-isolation включается для large автоматически: внутренние слои контекста (domain/application) не импортируют другой контекст — даже через его публичный API. Кросс-контекстные связи живут во внешних слоях (adapters/app) как anti-corruption-слои.

flowchart TB
  subgraph P["Контекст: policy"]
    Pouter["adapters / app"]
    Pappl["application"]
    Pdom["domain"]
    Pouter --> Pappl --> Pdom
  end
  subgraph B["Контекст: billing"]
    Bapi["public API (index)"]
  end
  Pouter -->|"через публичный API"| Bapi
  Pdom -. "запрещено" .-> Bapi

Именно это делает large строже medium: ядро каждого контекста развивается независимо и не ломается, когда сосед меняет внутренности. (Оба профиля уже запрещают глубокие кросс-импорты через public-api-only; large добавляет изоляцию ядра.)

Единственное исключение — DDD Shared Kernel domains/shared-kernel: внутренние слои могут импортировать его всегда (намеренно общая доменная модель). Это захардкожено на литеральное имя shared-kernel и не настраивается. Технический слой platform — отдельная история: это не доменная модель, а общий sink утилит/типов/портов внизу графа.

3. Полный DDD

Агрегаты, доменные сервисы, value objects, contract-тесты на порты/адаптеры — не выборочно, как в Medium, а как норма для серьёзной доменной логики.

Структура каталогов

Как в Medium, но у каждого контекста — публичный API-барьер (index.ts, версионируется), а ядро изолировано:

src/
  domains/
    <context>/
      index.ts            # публичный API контекста (единица версионирования)
      domain/             # ядро: НЕ импортирует другие контексты
      application/        # use-cases, ports — тоже изолированы от соседей
    shared-kernel/        # DDD Shared Kernel: единственный разрешённый кросс-контекст для ядра
      domain/             # общие VO/ошибки (минимум)
  integrations/           # реализации портов, ACL, SDK
    api/<context>/        # dto, mappers, repositories
  adapters/               # ЗДЕСЬ живёт кросс-контекст (anti-corruption)
    stores/               # Pinia: только UI-state
    ui/                   # pages, widgets, shared, presenters
  platform/               # технический sink: утилиты, базовые типы, generic-порты (не DDD)
  app/                    # bootstrap, сборка контекстов (composition root)

Доступ к чужому контексту — только через его index.ts и только из adapters/app. Это и проверяет bounded-context-isolation.

Состояние

Pinia — только UI-state; доменное состояние живёт в application/domain.

Связанные блоки

Bounded Context · Anti-Corruption Layer · Ports & Adapters · Shared Kernel

Профиль Medium (Clean Architecture + light DDD)

Сбалансированный профиль между «всё в Pinia/UI» и тяжёлым корпоративным DDD: целевая картинка Clean Architecture, но без её избыточности.

Когда проект «средний»

  • несколько связанных, но различимых бизнес-направлений (2–5 контекстов);
  • несколько интеграций (REST, analytics, флаги, капча, observability) — их не хочется размазывать по компонентам;
  • бизнес-правил уже не удержать в голове — часть должна жить отдельно от Vue и переиспользоваться;
  • ожидается изменение контрактов API — нужна устойчивость доменной модели к DTO;
  • нужны unit-тесты сценариев без поднятия всего приложения.

Меньше этого — смотрите Small; несколько команд и версионируемые API — Large.

Целевая модель слоёв

Зависимости направлены внутрь: домен не знает про Vue/HTTP.

flowchart TB
  subgraph outer["Внешние слои"]
    INFRA["Integrations"]
    ADAPTERS["Adapters"]
  end
  APP["Application"]
  DOM["Domain"]
  INFRA --> ADAPTERS
  INFRA --> APP
  ADAPTERS --> APP
  APP --> DOM

Литеральные слои манифеста: app · integrations · adapters · domains/<context> · platform. Внутри каждого контекста — Clean-подслои application → domain (framework-free). platform — технический sink (утилиты, базовые типы, generic-порты), а общую доменную модель держим в отдельном контексте domains/shared-kernel (см. Shared Kernel).

Bounded contexts: прагматичный разрез

Выделяем 2–5 контекстов там, где у модели разное значение терминов и разные инварианты. Контексты — папки верхнего уровня со своими application/domain, без отдельных npm-пакетов и event-driven интеграции, пока нет нужды. shared-kernel держим минимальным (общие VO/ошибки), не превращая в свалку утилит.

Структура каталогов

Допускает слияние мелких частей там, где нет боли:

src/
  domains/
    <context>/
      domain/
        entities/
        value-objects/
        aggregates/        # только где инварианты реально общие и сложные
        services/          # доменные сервисы (нормализация, eligibility)
      application/
        use-cases/
        dto/
        ports/             # интерфейсы Repository, Gateway
    shared-kernel/
      domain/
        errors/
        value-objects/     # PhoneNumber, PassportNumber, Money — по необходимости
  integrations/            # реализации портов и тех. интеграции
    api/<context>/
      dto/                 # wire-модели внешнего API
      mappers/             # ACL: Backend DTO ↔ application/domain модели
      repositories/        # реализуют application ports
    analytics/             # реализация AnalyticsPort
    remote-config/         # реализация FeatureFlag/RemoteConfig gateway
    observability/         # реализация ObservabilityPort
  adapters/                # граница UI и application
    stores/                # Pinia: UI-state + вызов use case + VM mapping
    ui/                    # весь экранный код
      shared/ widgets/ pages/ presenters/
  platform/                # технический sink: утилиты, базовые типы, generic-порты
  app/                     # bootstrap, router, провайдеры (composition root)

Что упрощаем относительно «идеальной» карты: один файл use case на сценарий (без базовых классов), presenters — только для сложных экранов, агрегаты — точечно, контракт-тесты — для критичных портов.

Распределение ответственности

СлойЧто держит
Domainинварианты, смысл данных, value objects
Applicationorchestration сценария, политика вызова API, маппинг ошибок в прикладные типы
IntegrationsHTTP, SDK, storage, retry, реализации портов
Adapters (UI)состояние экрана, VM для шаблона (тонкая Pinia)

Что обязательно

  • Use cases + Ports + ACL — первый вертикальный срез даёт максимум пользы при умеренной цене;
  • Gateway для SDK (флаги, аналитика, капча) — чтобы application не импортировал вендоров;
  • Pinia — тонкий адаптер состояния, без бизнес-инвариантов и знания DTO бэкенда.

Что выборочно

Value Object / Aggregate / Domain Service — точечно, где инварианты реально окупаются. Presenter — для сложных экранов. Contract-тесты — для критичных портов (оплата, поиск полиса), а не для каждого метода.

Когда расти

Несколько команд конфликтуют в одних модулях, нужны версионируемые публичные API между подсистемами, домен обязателен для compliance/аудита — пора в Large. Эволюция — наращивание жёсткости границ и тестов, а не перестройка с нуля.

Nuxt-профили (nuxt-*)

Nuxt — это указание стека, а не отдельная архитектура. Поэтому профиля «Nuxt» как такового нет — есть семейство профилей под Nuxt 4 с той же градацией сложности, что и обычные:

nuxt-small · nuxt-medium · nuxt-large · nuxt-fsd

Каждый — это соответствующий базовый профиль (Small / Medium / Large / FSD), адаптированный под фреймворк. Вариант выбирается по той же сложности продукта, что и обычный профиль.

Главная идея структуры

  • Верхний уровень: app/ (агрегатор страниц), layers/<bounded-context>/, общий shared//base/.
  • Внутри каждого Layer — Clean-слои: domain/, application/, integrations/, ui/ (components/ + composables/).
  • Bounded contexts организуются через Nuxt Layers; DI — через Nuxt Plugins и App Context; типобезопасность — через augmentation #app.

Из-за текущих багов Nuxt Layers временно архитектура строится внутри app/; переход на Layers — после стабилизации фичи.

Линтер

Те же 4 инвариантных правила, что и везде, — меняется только содержимое whitelist слоёв и добавляются Nuxt-специфичные проверки (алиасы #layers/<bounded-context>/..., изоляция server/, useFetch vs $fetch, разделение client/server composables). allowedLayers/forbiddenEdges берутся из встроенного Nuxt-пресета.

Детали — отдельный ADR-pda-nuxt-profile.

Профиль Small (MVVM)

Прагматичный каркас для нового небольшого фронтенда (типичный стек: Vue 3 + TypeScript + Pinia + Router + HTTP-клиент). Полный стек Clean Architecture + DDD здесь избыточен.

Когда проект «маленький»

Подходит, если верно большинство пунктов:

  • один основной сценарий или 2–3 простых экрана без тяжёлой orchestration;
  • мало интеграций (один backend, без «зоопарка» SDK);
  • бизнес-правил мало — их можно держать в голове, без отдельной доменной модели;
  • маленькая команда, короткие сроки, приоритет — скорость поставки.

Если уже на старте видно несколько независимых подсистем, нестабильный API и много правил валидации — сразу смотрите Medium.

Слои и поток данных

app · view · integrations · domain

MVVM + тонкая сервисная прослойка:

flowchart LR
  V["View / Vue"] --> VM["ViewModel (composable + Pinia)"]
  VM --> S["Services / api"]
  S --> API["Backend"]
  • View — разметка, события, локальный UI-state (открыть модалку, шаг формы).
  • ViewModel — composables/Pinia: состояние экрана, вызовы сервисов, маппинг «ответ API → удобная шаблону модель».
  • Services — функции getX/saveY, общие типы запросов/ответов, обработка сетевых ошибок.

На этой высоте view может звать integrations напрямую — без портов и DI (эта дисциплина вводится в Medium).

Структура каталогов

src/
  app/                 # bootstrap, router, провайдеры
  domain/              # данные и доступ к данным (тонкий слой)
    entities/          # типы/модели предметки для UI-задач
    services/          # сценарии чтения/записи, orchestration вызовов API
    repositories/      # контракты + реализации работы с данными
    mappers/           # mapDtoToModel / mapModelToDto (лёгкий ACL)
  integrations/        # технические зависимости
    api/               # HTTP-клиент + endpoint-модули
    analytics/         # опционально
    storage/           # local/session storage, если нужно
  view/                # только UI
    pages/             # page-level контейнеры/экраны
      <screen>/
        <Screen>Page.vue    # template + script setup (View + локальный ViewModel)
        composables/        # use<Screen>.ts, если логика страницы выросла
        <screen>.store.ts   # опционально, Pinia для экрана
    widgets/           # составные UI-блоки без логики
    shared/            # переиспользуемые UI-примитивы

Намеренно упрощено: нет отдельного «тяжёлого» доменного слоя и обязательных контракт-тестов портов — domain покрывает практические нужды маленького проекта.

Что НЕ вводим

  • отдельные bounded contexts;
  • богатые агрегаты и value objects «на вырост»;
  • обязательные contract-тесты портов — хватает unit-тестов на критичные мапперы/правила.

Clean Architecture здесь — только дисциплина: не смешивать всё в store, не тащить backend DTO в шаблоны, нетривиальное правило — в чистую функцию без Vue (её можно тестировать без поднятия компонента).

Паттерны: что брать выборочно

ПаттернНа Small
Use Caseопционально, как простая функция executeX(...) без класса и DI
Repository, Portsобычно нет — достаточно api.ts за интерфейсом функций
ACL / Mapperлёгкий: 1–2 функции mapDtoToVm рядом с экраном
Value Object, Aggregateтолько для критичных правил (паспорт, деньги)
Presenter / VMда — это ядро MVVM
Bounded Contextне вводить до появления разных поддоменов

Когда расти

Появились два независимых поддомена, API часто ломается, в store слишком много ветвлений и правил — пора в сторону Medium. Мигрируйте эволюционно (Strangler Fig): один вертикальный срез, без big-bang.

Profiles (профили)

Профиль — это конкретная архитектура из фиксированного списка, выбранная осознанно и соразмерно сложности.

Значения поля profile в манифесте: small | medium | large | fsd | nuxt-small | nuxt-medium | nuxt-large | nuxt-fsd.

Профиль≈ Что этоКогда
SmallMVVM1 сценарий, 2–3 экрана
MediumClean + light DDD2–5 контекстов, нестабильный API
LargeClean + full DDDнесколько команд, версионируемые API
FSDFeature-Sliced Designкоманда осознанно в FSD
nuxt-*те же профили на стеке Nuxt 4проекты на Nuxt 4

Главное правило выбора — минимально достаточный профиль. Миграция между профилями допускается эволюционно (Strangler Fig) и фиксируется в манифесте полем transition.

Proportional architecture (соразмерность)

Сложность архитектуры выбирается пропорционально сложности продукта.

ПродуктПрофильЧто платим
2–3 экрана, один backendSmall (MVVM)почти ничего
2–5 контекстов, нестабильный APIMedium (Clean + light DDD)use cases, порты, ACL
Несколько команд, версионируемые APILarge (Clean + DDD)полный DDD + контракт-тесты

Почему это важно

  • Маленькому проекту тяжёлая структура замедляет поставку и не окупается.
  • Большому проекту лёгкой структуры не хватает — она становится потолком.

Поэтому усложнение профиля — всегда осознанный шаг через ADR, а не «на вырост». См. Минимальный профиль и Fitness Functions.

Quality Gates / Architecture Fitness Functions

Quality Gates — автоматические архитектурные проверки, которые не дают нарушать целевые границы.

Слои защиты в PDA

  1. Линтер (основной gate) — 4 инвариантных правила в IDE и CI; pnpm lint блокирует merge.
  2. Фитнес-функции (второй слой) — автоматические проверки любого архитектурного свойства, которое нужно удержать: соответствие графа зависимостей манифесту (запрещённые рёбра, циклы), бюджеты размера/производительности, доступность и другие инварианты по решению архитектора.
  3. Покрытие — наличие тестов на use cases / доменные инварианты.
// Идея правила: widgets/views не ходят напрямую в api/store
const forbidden = [
  { from: 'src/widgets/**', disallow: ['src/shared/api/**', 'src/shared/store/**'] },
  { from: 'src/views/**',   disallow: ['src/shared/api/**', 'src/shared/store/**'] },
];

Без quality gates любые границы «разъезжаются» за 2–3 спринта — см. Fitness Functions.

Repository

Repository даёт сценарию доступ к данным так, будто это коллекция доменных объектов в памяти — скрывая, что под капотом HTTP, БД или кеш. Это один из видов порта (Ports & Adapters), специализированный на «получить/сохранить сущности».

Зачем

Без репозитория use case вызывал бы http-клиент напрямую — и сразу зависел бы от формата URL, заголовков, формы ответа. Это нарушает Dependency Rule и делает сценарий непроверяемым без сети. Репозиторий разрывает эту связь: сценарий формулирует потребность на доменном языке (fetchByPassport), а как именно достаются данные — забота реализации.

Как работает

// Port — в application: язык домена, без намёка на транспорт
export interface PolicyRepository {
  fetchByPassport(passport: string, birthDate: string): Promise<PolicyListItemDto[]>;
}

// Adapter — в integrations: HTTP + маппинг ответа в доменную модель
export class ApiPolicyRepository implements PolicyRepository { /* ... */ }

То же без класса — порт это type, адаптер это фабрика-функция:

// Port — тип набора операций
export type PolicyRepository = {
  fetchByPassport(passport: string, birthDate: string): Promise<PolicyListItemDto[]>;
};

// Adapter — фабрика замыкает http-клиент и возвращает реализацию порта
export const makeApiPolicyRepository = (http: HttpClient): PolicyRepository => ({
  fetchByPassport: (passport, birthDate) => http.get('/policies', { passport, birthDate }) /* + маппинг */,
});

В юнит-тесте сценария вместо ApiPolicyRepository (или makeApiPolicyRepository) подставляется фейковый репозиторий — обычный объект, реализующий тот же тип, — проверяется логика, а не сеть.

Repository vs Gateway

Repository работает с коллекцией доменных объектов (полисы, котировки). Gateway оборачивает сервис/возможность (капча, флаги, аналитика). Если по сути это «достать/положить сущности» — репозиторий; если «дёрнуть внешнюю операцию» — gateway.

Где в PDA

  • Интерфейсы — application (ports);
  • Реализации — integrations/api/*/repositories.

Соблюдение контракта между портом и реальным API проверяется contract-тестом; преобразование wire-DTO в доменную модель — забота ACL/Mapper.

Правило: barrel-only-index

Профили: все (инвариант). Severity: error. Autofix: нет.

В index.ts, который служит публичным API модуля, разрешены только import и export (включая re-export). Любая логика — объявления функций/классов/констант, side-effect-выражения — запрещена.

// ❌ неправильно — в index.ts есть логика
export function apiBuilder() { /* ... */ }
const client = createClient();

// ✅ правильно — только ре-экспорт
export { apiBuilder } from './apiBuilder';
export { ApiErrors } from './errors';
export type { ApiClient } from './types';

Зачем

index.ts остаётся стабильной витриной модуля, тривиально читаемой в diff, без скрытой логики. Autofix намеренно отсутствует: вынос логики — это осознанный refactor-коммит.

Список barrel-файлов берётся из publicApiEntrypoints.

Правило: bounded-context-isolation

Профили: только large (включается автоматически). Severity: error. Autofix: нет.

Внутренние Clean-слои контекста (domain, application) не могут импортировать другой bounded context — даже через его публичный API. Кросс-контекстные связи живут во внешних слоях (adapters, app).

// ❌ domain контекста policy тянет другой контекст
// file: @/domains/policy/domain/rules.ts
import { Invoice } from '@/domains/billing';

// ✅ кросс-контекст — во внешнем слое
// file: @/domains/policy/adapters/billing-gateway.ts
import { Invoice } from '@/domains/billing';

// ✅ исключение — DDD Shared Kernel можно тянуть и из ядра
// file: @/domains/policy/domain/rules.ts
import { Money } from '@/domains/shared-kernel';

Исключение — Shared Kernel

Внутренние слои всегда могут импортировать domains/shared-kernel (Shared Kernel) — намеренно общую доменную модель. Это единственный санкционированный кросс-контекстный импорт, он захардкожен на литеральное имя shared-kernel и не настраивается — другой контекст «общим» не объявить. Технический слой platform к этому отношения не имеет: это не доменная модель.

Зачем

Это и делает large строже medium. Ядро каждого контекста развивается независимо; связи между контекстами явные и живут в anti-corruption-слоях. (Оба профиля уже запрещают глубокие кросс-импорты через public-api-only; large добавляет изоляцию ядра.)

Правило: layer-boundaries

Профили: все (рёбра — профиль-специфичны). Severity: error (новый код) / warn (baseline). Autofix: нет.

Импорты разрешены только в направлении, заданном профилем (allowedLayers + forbiddenEdges). Обратные рёбра запрещены всегда.

Направления по профилям

  • FSD: app → pages → widgets → features → entities → shared
  • Small: app → view → integrations → domain
  • Medium/Large (верхний уровень): app → integrations → adapters → domains → platform

Ключевой инвариант: domain не зависит от Vue, Pinia, Axios и внешних SDK. Это выражается ребром @/domains/*/domain/** → external:*.

// ❌ domain тянет внешнюю зависимость
// file: @/domains/billing/domain/policy.ts
import axios from 'axios';

// ✅ domain зависит только от своего domain / platform
import { Money } from '@/domains/billing/domain/money';

Реализует Dependency Rule из Clean Architecture.

Правило: no-imaginary-layers

Профили: все (whitelist — профиль-специфичен). Severity: error. Autofix: нет.

Разрешены только имена слоёв из allowedLayers. Любая новая top-level папка под src/ или новый alias-префикс @/<layer> — ошибка, пока имя не добавлено в манифест (+ ADR).

// allowedLayers: ["app", "integrations", "adapters", "domains", "platform"]

// ❌ services не объявлен как слой
import { authService } from '@/services/auth';
// ✅ объявленный слой
import { auth } from '@/domains/auth';

Зачем

Главный источник архитектурной энтропии — дрейф терминологии: синонимы views vs pages, models vs domain, services vs application. Правило не даёт им появиться. Запрещённые «универсальные» имена (helpers, utils, business-logic как слои) уточняются отдельным ADR.

Правило: public-api-only

Профили: все (инвариант). Severity: error. Autofix: да.

Запрещён импорт из внутренностей чужого модуля — только через публичный вход @/<layer>/<module> (или .../index).

// ❌ глубокий импорт во внутренности
import { usePolicy } from '@/entities/policy/lib/usePolicy.ts';
// ✅ через публичный API
import { usePolicy } from '@/entities/policy';

Autofix

Фиксер обрезает путь до barrel (@/x/y/lib/z → @/x/y). Он может намеренно вскрыть новую ошибку «символ не экспортирован из публичного API» — это ожидаемо:

deep import → autofix → правило зелёное → загорается no-unresolved / TS-ошибка
→ разработчик добавляет export в index.ts или признаёт символ внутренним

Исключения

  1. Внутри одного модуля — файлы модуля импортируют друг друга напрямую.
  2. Слайсы из deepImportAllowList — например @/shared/ui/**.

Shared Kernel

Shared Kernel — небольшой набор доменных типов (ошибки, базовые value objects), который несколько bounded-контекстов сознательно разделяют, договорившись поддерживать его совместно.

export class DomainError extends Error {
  constructor(public readonly code: string, message: string) { super(message); }
}

То же без класса — ошибка как значение (tagged type) + фабрика:

export type DomainError = { readonly _tag: 'DomainError'; readonly code: string; readonly message: string };

export const domainError = (code: string, message: string): DomainError =>
  ({ _tag: 'DomainError', code, message });

Тег _tag позволяет различать ошибки в switch, а возвращать такую ошибку как значение (например, в Result<T, DomainError>) — а не бросать — это привычный FP-подход к обработке ошибок. Базовые value objects (Money) shared kernel так же отдаёт в виде type + smart constructor (см. Value Object).

Зачем нужен

Иногда контексты неизбежно говорят об одних и тех же базовых понятиях (Money, DomainError). Дублировать их в каждом контексте — расхождения и копипаста; заворачивать каждое через ACL — оверинжиниринг. Shared Kernel — компромисс: маленькая общая часть с явным договором об изменении.

Когда применять

  • понятие по-настоящему общее и стабильное (деньги, ошибки, базовые VO);
  • команды готовы согласовывать правки (менять kernel в одиночку нельзя — сломаешь соседей).

Если понятие нестабильно или у контекстов на него разный взгляд — это не kernel, а повод для отдельных моделей + ACL.

Где в PDA

Это domain-уровень: код лежит в domains/shared-kernel/domain — это опциональный bounded context, а не top-level слой. Не путать с техническим слоем platform (framework-agnostic утилиты, базовые типы, generic-порты): platform — не доменная модель. На Large правило bounded-context-isolation делает domains/shared-kernel единственным разрешённым кросс-контекстным импортом для внутренних слоёв; исключение захардкожено на это литеральное имя и не настраивается — другой контекст «общим» не объявить (см. Слои).

Важно

Держите его минимальным. Главный антипаттерн — превратить shared kernel в свалку утилит: тогда он становится точкой жёсткой связности между контекстами и тормозит их независимую эволюцию.

Strangler Fig (эволюционная миграция)

Паттерн Мартина Фаулера: новый код внедряется рядом со старым, постепенно «удушая» (вытесняя) legacy-ветки, без big-bang переписывания.

// Временный флаг для безопасного перехода
if (featureFlags.useNewGetQuoteFlow) {
  return getQuoteUseCase.execute(input); // новый путь
}
return legacyStoreGetQuote(input);        // старый путь

Где в PDA

  • Основа transit-режима в layer-boundaries: во время миграции допускается сосуществование двух наборов слоёв (с запретом обратных рёбер в обоих).
  • Фиксируется полем transition в манифесте.

Как мигрировать

Берётся один вертикальный срез (например, сценарий расчёта): выносится use case + порт + адаптер-репозиторий, UI и роутинг не трогаются. Затем — следующий срез. См. refactoring-plan.md, «Инкремент №1».

Ubiquitous Language (единый язык)

Ubiquitous Language — общий язык предметной области, единый в коде, документации и обсуждениях. Если бизнес говорит «Traveller», «Holder», «Policy Duration» — ровно так называются типы, методы и переменные.

Зачем

Каждый раз, когда название в коде («user», «item», «data») не совпадает с тем, как говорят бизнес и аналитики («traveller», «policy»), возникает скрытый перевод. Этот перевод живёт в голове разработчика, нигде не зафиксирован и при каждой передаче знаний теряется — отсюда баги «недопоняли требование». Единый язык убирает перевод: код читается как описание домена, а обсуждение фичи и её реализация используют одни и те же слова.

Как работает

// Явный доменный язык вместо абстракций user/item/flag
export interface Traveller {
  id: string;
  relationCode: string;   // 'OWNER' | ...
  contactsSame: boolean;
}

Язык — не разовая договорённость, а живой словарь: появился новый термин в обсуждении — он попадает и в код, и в глоссарий. Переименовали понятие в бизнесе — переименовали везде.

Где живёт в PDA

Обязательный документ Domain Glossary хранит знания единого языка: ключевые термины с однозначными определениями и связями. Приоритет глоссария: при расхождении кода, доков и задач прав глоссарий.

Почему это первый, а не последний шаг

Ubiquitous Language — фундамент всего остального DDD: Bounded Context — это граница, внутри которой язык однозначен; Entity, Value Object, агрегаты — это слова из этого языка. Поэтому моделирование начинают с языка, а не с классов.

Use Case / Interactor

Use case изолирует один прикладной сценарий и его оркестрацию: «получить список полисов», «оформить покупку». Это «глагол» приложения — одна команда пользователя или системы, доведённая до результата.

Зачем

Use case — это место, где живёт последовательность шагов сценария, отдельно и от UI, и от чистого домена. Без него оркестрация (проверить капчу → сходить в репозиторий → применить политику) расползается по компоненту и стору, дублируется между экранами и не покрывается тестом. Собрав её в один класс, мы получаем читаемый «оглавление сценария» и точку, которую легко протестировать с подставными портами.

Как работает

export class FetchPolicyListUseCase {
  constructor(
    private readonly policyRepository: PolicyRepository,   // порт
    private readonly captchaGateway: CaptchaGateway,        // порт
  ) {}

  async execute(input: FetchPolicyListInput): Promise<PolicyListResult> {
    await this.captchaGateway.verify(input.captchaToken);
    return this.policyRepository.fetchByPassport(input.passport, input.birthDate);
  }
}

То же без класса — фабрика-функция, зависимости в замыкании вместо конструктора:

type Deps = { policyRepository: PolicyRepository; captchaGateway: CaptchaGateway };  // порты

export const makeFetchPolicyList =
  ({ policyRepository, captchaGateway }: Deps) =>
  async (input: FetchPolicyListInput): Promise<PolicyListResult> => {
    await captchaGateway.verify(input.captchaToken);
    return policyRepository.fetchByPassport(input.passport, input.birthDate);
  };

DI здесь — частичное применение: makeFetchPolicyList(deps) возвращает execute, а в тесте порты подставляются тем же вызовом фабрики. Это тот же приём, что и упомянутый ниже простой executeX(...) на Small, только зависимости вынесены в замыкание.

Зависит use case только от портов и domain — никогда от Vue/HTTP/SDK напрямую. Бизнес-правила он не реализует сам, а вызывает (Domain Service, Domain Policy); его работа — порядок шагов, а не сами правила.

Где живёт и как масштабируется

Слой application, один файл на сценарий, без абстрактных базовых классов.

  • На Small допустим как простая функция executeX(...) без класса и DI.
  • На Medium/Large — класс с внедрёнными портами.

Когда применять

Когда у сценария больше одного шага или он переиспользуется. Тривиальный «прочитать одно поле» в use case заворачивать не нужно. При миграции легаси use case — первый кандидат на вынос: даёт быстрый выигрыш в тестируемости (Strangler Fig, «Инкремент №1»).

Value Object

Value Object (VO) — неизменяемый тип без идентичности, который инкапсулирует значение вместе с его валидацией и смыслом. Два VO равны, если равны их значения (в отличие от Entity, сравниваемой по id).

Зачем

«Сырые» string/number ничего не гарантируют: строка-паспорт может оказаться пустой, число-деньги — отрицательным, и проверки этого расползаются по UI и сценариям, повторяясь и расходясь. Value Object собирает валидацию и правила в одном месте и делает невозможным существование невалидного значения: если объект создан — он корректен. Код начинает оперировать Money и PassportNumber вместо безымянных примитивов, и опечатку «передал телефон вместо паспорта» ловит компилятор.

Как работает

export class PassportNumber {
  private constructor(public readonly value: string) {}        // неизменяемый
  static create(raw: string): PassportNumber {
    const v = raw.trim();
    if (!/^(?=.*\d)[A-Za-z0-9]{1,20}$/.test(v)) throw new Error('Invalid passport number');
    return new PassportNumber(v);                              // создан ⇒ валиден
  }
}

То же без класса — брендированный тип + smart constructor (функция-фабрика):

type PassportNumber = string & { readonly __brand: 'PassportNumber' };

export const passportNumber = (raw: string): PassportNumber => {
  const v = raw.trim();
  if (!/^(?=.*\d)[A-Za-z0-9]{1,20}$/.test(v)) throw new Error('Invalid passport number');
  return v as PassportNumber;                                  // создан ⇒ валиден
};

Бренд __brand существует только в типах (в рантайме это обычная строка), но мимо passportNumber() значение типа PassportNumber не получить — та же защита от подмены аргументов держится и без класса. Неизменяемость даётся бесплатно: это примитив.

Приватный конструктор + фабрика create (или функция-фабрика в FP) — типичный приём: мимо валидации объект не создать.

Когда вводить

Для значений, у которых есть правила или смысл: деньги, паспорт, телефон, диапазон дат. Не заворачивайте в VO каждый примитив — флаг или технический id этого не требуют. На Medium вводят выборочно (паспорт, деньги), на Large — шире.

Где в PDA

Слой domain, в т.ч. общий Shared Kernel для VO, переиспользуемых несколькими контекстами (Money). См. Value Object (Fowler).

Зачем PDA

PDA рождается из одного тезиса: архитектура — это инструмент управления сложностью, а не самоцель. За каждое архитектурное решение платит команда: правила импортов, лишние слои, дополнительные тесты, время на onboarding.

Значит архитектура должна быть соразмерна сложности продукта. И недостаток границ, и их избыток одинаково вредны:

  • всё в одном слое (UI + state + бизнес + API вперемешку) — нельзя тестировать и менять;
  • богатый DDD на CRUD-приложении — «архитектурный налог», который никогда не окупится.

Что PDA хочет решить

  • Сделать архитектуру адаптивной (профили под размер), а не one-size-fits-all.
  • Сделать выбор и поддержку архитектуры объявленными (манифест + автоматические quality gates), а не подразумеваемыми.
  • Признать, что архитектурный линтер — первая и обязательная fitness-функция: без неё любое решение деградирует за пару спринтов.

Подветки объясняют каждую из этих идей по отдельности.