Сначала — базовое понятие. 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, и в репозитории — и легко разъезжается. Агрегат собирает правило в одном месте и не даёт собрать невалидное состояние.
Точечно — там, где есть группа объектов с общим инвариантом, который иначе не удержать. Не превращайте весь граф сущностей в агрегаты: лишние границы усложняют модель. В PDA агрегаты — часть full DDD (профиль Large); на Medium — выборочно.
Слой domain (подслой внутри domains/<context>/). См. Effective Aggregate Design.
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 не трогаются.
Зона integrations/api/*/{dto,mappers}. Главное правило: backend-DTO не протекают в domain/application — туда попадают только доменные модели.
Всегда на границе с внешним API, чья модель не совпадает с доменной (а она почти всегда не совпадает). В PDA ACL обязателен уже на Medium. Если же контракт тривиально совпадает с доменом и стабилен — отдельный маппер может быть избыточен, но это редкий случай. См. Anti-Corruption Layer (Microsoft).
В PDA архитектура объявляется, а не подразумевается.
architecture.manifest.json.«Архитектурный дрейф» — ситуацию, когда любая часть структуры может незаметно измениться: появляются слои-синонимы (views vs pages), бизнес-правила переезжают в Pinia, границы размываются код-ревью за код-ревью.
Когда архитектура — это код (манифест + правила линтера), она:
Bounded Context — независимый контекст со своей моделью, языком и правилами. Один и тот же термин в разных контекстах может значить разное: «Policy» при покупке и «Policy» при скачивании документа — это две разные модели.
Попытка сделать одну «универсальную» модель на всё приложение приводит к объекту-монстру: Policy обрастает полями из покупки, скачивания, тарификации, и любое изменение в одной фиче рискует сломать другую. Bounded Context разрезает систему по швам разного смысла: внутри границы язык однозначен и модель заточена под одну задачу. Контексты эволюционируют независимо — это главный инструмент управления сложностью в большой системе.
Внутри контекста — свои domain/application; наружу контекст торчит публичным API. Связь между контекстами явная и идёт через внешние слои (адаптеры, ACL), а не через прямой доступ к чужому домену.
Примеры: Purchase, PolicyDownload, FeatureToggle.
Выделяйте 2–5 контекстов там, где у модели разное значение терминов и разные инварианты. Не дробите по техническим признакам и не плодите контексты «на вырост» — пустая граница только мешает.
domains/<context>/ со своими domain/application, без отдельных npm-пакетов и event-driven интеграции, пока нет нужды.domain/application одного контекста не могут импортировать другой даже через его публичный API.Четыре концептуальных слоя (снаружи внутрь):
В PDA те же слои названы иначе — это литеральные имена в манифесте (allowedLayers):
| Clean Architecture | Слой в PDA |
|---|---|
| Infrastructure | integrations |
| Interface Adapters | adapters |
| Application | application (подслой внутри domains/<context>/) |
| Domain | domain (подслой внутри 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 (автор Роберт Мартин) — это способ так разложить код по слоям, чтобы бизнес-логика не зависела от фреймворка, БД и 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).
Это даёт:
Разобраны в дочерних нодах: Слои, Dependency Rule, Ports & Adapters, Use Case, Repository, Gateway, Presenter/VM.
См. также родственные модели: Onion Architecture, Hexagonal.
Главный принцип PDA (ADR-6, §1.1): архитектура существует, чтобы управлять сложностью, и стоит ровно столько, сколько за неё платит команда. Значимость решения измеряется стоимостью его изменения: заранее продумываем то, что потом дорого переделать — выбор технологий, границы и декомпозицию, — а остальное оставляем эволюции.
Та же мера и для проектирования наперёд: расписать всю архитектуру до первой строчки кода так же вредно, как не проектировать вовсе. Стартовый дизайн задаёт направление, а не финальный идеал — за часы-дни понять, что строим и взлетит ли это, а дальше развивать по мере появления знаний.
Выбираем минимально достаточный набор слоёв и правил. Усложняем структуру только когда появляется реальная боль (несколько подсистем, нестабильный API, много инвариантов), и фиксируем это решение в ADR.
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);
});
});
Тестовый слой между application (порт) и integrations (адаптер). Покрывают критичные порты (оплата, поиск полиса) и ACL-мапперы, а не каждый метод.
Цель — поймать рассинхрон, когда адаптер «дрейфует» от того, что ожидает use case. Для межсистемных контрактов (фронтенд ↔ backend) применяют consumer-driven contract testing.
Domain-Driven Design — подход, где предметная область и её язык становятся центром проектирования кода. Цель — чтобы структура кода повторяла структуру бизнеса, а не технических деталей.
Когда правил домена много и они меняются, «анемичная» модель (данные в DTO + логика, размазанная по сервисам) расплывается: одно правило живёт в трёх местах и со временем разъезжается. DDD собирает правило туда, где ему место — в доменные объекты с понятным языком, — и тем удерживает сложность.
Дозированно, по профилю:
Начинайте со стратегии — как минимум с единого языка, а дальше границы контекстов и тактику вводите по ситуации. Не разворачивайте весь арсенал сразу.
Стратегические (организуют границы между доменами): Ubiquitous Language, Bounded Context, Shared Kernel, Anti-Corruption Layer.
Тактические (моделируют сам домен): Entity, Value Object, Aggregate, Domain Service, Domain Policy.
Добавляйте блок, когда его стоимость окупается инвариантами и языком домена.
Главное правило 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 идёт внутрь, к интерфейсу.
Правило автоматизировано линтером layer-boundaries: ребро domain → external:* запрещено, обратные рёбра между слоями — тоже. Нарушение — ошибка сборки, а не вопрос code review. Это превращает архитектурный принцип в fitness function.
integrations в domain «просто чтобы переиспользовать» — это уже обратная стрелка;Инвентарь бизнес-правил проекта и их размещения: где правило живёт сейчас и где должно жить по целевому профилю.
Таблица правил:
| Поле | Описание |
|---|---|
| ID | идентификатор правила |
| Описание | что за правило (на языке бизнеса) |
| Текущее место | где живёт сейчас (часто Pinia/composable) |
| Целевой слой | domain / application / integrations / adapters |
Документ про бизнес-уровень правил, а не их текущее расположение в коде. У каждого правила определён целевой слой; правила без него — открытый архитектурный вопрос. Это вход для миграции (Strangler Fig).
Объективная карта текущих зависимостей и пересечений слоёв в проекте, включая циклы.
Дополняет автоматический сигнал линтера человекочитаемой картой. Проблемные зависимости явно перечислены и связаны с задачами на исправление. Критерий готовности: выделены проблемные crossing-layer зависимости.
Единый словарь доменных терминов — источник истины для имён сущностей, ролей и концепций, встречающихся в коде, документации и обсуждениях.
Это материализация Ubiquitous Language. Один источник, на который ссылаются ревью, код и follow-up ADR. Критерий готовности: один источник истины для терминов.
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 акцентирует решение/критерий («выбрать», «допустить ли»), Domain Service — вычисление/преобразование. На практике политику можно считать частным видом сервиса.
Слой domain; вызывается из application (use case). Часть full DDD (Large); на Medium — для правил, которые реально меняются и заслуживают имени.
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) — правило домена, не знает про порты, HTTP, сценарий.application) — оркестрация шагов сценария, знает про порты и вызывает доменные правила.Слой domain. Часть full DDD (Large); на Medium — по необходимости.
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.
Спросите: важна ли здесь идентичность или только значение?
Это базовое различение DDD — Evans Classification.
Слой domain (подслой внутри domains/<context>/). Часто сущность живёт внутри агрегата под управлением его корня (Aggregate) — тогда снаружи к ней обращаются только через корень, а не напрямую.
Сейчас линтер реализован на ESLint; стратегическая цель — переход на Oxlint (написан на Rust → скорость + единый toolchain).
В PDA пять архитектурных правил: четыре инварианта (работают во всех профилях) — barrel-only-index, public-api-only, layer-boundaries, no-imaginary-layers — плюс bounded-context-isolation, который включается только в профиле Large (full DDD). Переносить нужно все пять.
import/no-internal-modules, no-restricted-paths для правил, которые сводятся к проверке путей импорта..ts).Замысел — описать правила декларативно (whitelists + JSON-манифест, без императивного JS), тогда миграция свелась бы к замене рантайма. Но не все правила декларативно выражаются:
@/x/y/lib/z → @/x/y); в ADR-6 помечено как риск R1 и требует unit-тестов. Это императивная логика, а не конфиг.index.ts (только re-export’ы, без Function/Class/Variable-деклараций). Тоже кастомное AST-правило, а не whitelist путей.Поэтому миграция — не просто смена линтера: она упирается в поддержку Oxlint’ом кастомных правил/фиксеров и в переписывание этих двух правил на его API. Правила, сводящиеся к путям импорта (layer-boundaries, no-imaginary-layers, bounded-context-isolation), переносятся легче. Допустим промежуточный режим: Oxlint в pre-commit, ESLint в CI как страховка.
Архитектуру мало спроектировать — её нужно безопасно вводить и удерживать от деградации.
Всё это — продолжение идеи fitness-функций: архитектура держится автоматикой, а не памятью команды.
Fitness-функция — это автоматическая проверка, которая удерживает систему в заданных архитектурных границах. Термин из книги Building Evolutionary Architectures (Ford, Parsons, Kua).
Архитектурное соглашение, не подкреплённое автоматической проверкой, деградирует за 2–3 спринта: оно держится только на памяти ревьюеров и размывается с каждым код-ревью, особенно при росте команды. Fitness-функция переносит правило из режима «помним и следим вручную» в «проверяет машина в IDE и CI».
Первая и обязательная fitness-функция — архитектурный линтер. В первой итерации это 5 правил: 4 инварианта для всех профилей (barrel-only-index, public-api-only, layer-boundaries, no-imaginary-layers) плюс bounded-context-isolation для профиля Large. Линтер:
Дальше — любые другие фитнес-функции под нужды проекта: соответствие графа зависимостей манифесту (запрещённые рёбра, циклы), contract-тесты, бюджеты размера/производительности и прочие инварианты по решению архитектора. Подробнее — ветка Quality Gates.
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.
application;integrations (analytics, remote-config, observability).Главная польза: domain/application не импортируют вендоров напрямую. Это и проверяет layer-boundaries ребром domain/application → external:*.
Как только появляется обращение к внешнему сервису, у которого может смениться поставщик или который надо подменять в тестах. Для разового тривиального вызова без логики gateway может быть избыточен — но обычно дешевле ввести его сразу, чем выпутывать SDK из домена позже.
Манифест architecture.manifest.json имеет описанную JSON Schema и валидируется при загрузке.
В пакете линтера за это отвечает helper loadManifest(dir): он читает файл, проверяет его по схеме и разворачивает пресеты (например, для nuxt-* подставляет allowedLayers/forbiddenEdges).
Зачем схема:
$schema).Общий пакет компании: @qicgroupdoha/eslint-plugin-qic-rules (плагин pda, ключи правил вида pda/<rule>).
В первой итерации — четыре инвариантных правила, общих для всех профилей, плюс одно для Large:
large).Подключение — одной строкой:
import { createConfig } from '@qicgroupdoha/eslint-plugin-qic-rules';
export default [ createConfig({ files: ['src/**/*.{ts,vue}'] }) ];
Правила задуманы декларативно (whitelists + JSON-манифест, без императивного JS в правилах) — это критично для будущей миграции на Oxlint.
Линтер обеспечивает структурные инварианты, но не отвечает на вопрос «о чём этот проект». Часть архитектуры живёт в документах (ADR-6, §2.6).
Каждый проект (независимо от профиля) ведёт три живых документа:
Это не одноразовые артефакты: они обновляются по мере развития проекта и пересматриваются при смене профиля.
Файл 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, …).
Поля разобраны в дочерних нодах.
Белый список имён слоёв верхнего уровня (string[]).
"allowedLayers": ["app", "integrations", "adapters", "domains", "platform"]
Любая новая папка под src/ или новый alias-префикс @/<layer>, которого нет в списке, — ошибка правила no-imaginary-layers, пока имя не добавлено в манифест (и не сопровождено ADR).
Это главный барьер против «дрейфа терминологии» (views vs pages, services vs application).
Для nuxt-* может быть опущено — берётся из пресета.
Glob-паттерны (по полному aliased-импорту), для которых public-api-only разрешает глубокие импорты — слайсы, у которых намеренно нет barrel.
"deepImportAllowList": ["@/shared/ui/**"]
Profile-driven: FSD-профили (fsd, nuxt-fsd) по умолчанию включают @/shared/ui/**, остальные — [].
[] — полностью отключает исключения.Типичный кейс: UI-примитивы в shared/ui импортируются по файлам ради tree-shaking — единый barrel на все компоненты вредит сборке.
Запрещённые рёбра импортов ({ from, to[] }[]) — задают направление зависимостей для правила layer-boundaries.
"forbiddenEdges": [
{ "from": "@/domains/*/domain/**", "to": ["external:*"] }
]
from — glob по пути файла, который импортирует;to — куда импортировать нельзя: префикс слоя (@/app), пакет (vue), glob (@sentry/*) или спец-токен external:* (любой внешний импорт).external:* удобно, чтобы одним правилом изолировать domain от всех сторонних библиотек/SDK, не ведя список вручную.
Выбранный профиль проекта. Обязательное поле.
Допустимые значения:
small | medium | large | fsd | nuxt-small | nuxt-medium | nuxt-large | nuxt-fsd
Профиль определяет:
large);Для nuxt-* часть полей берётся из встроенного пресета.
Имена barrel-файлов, считающихся публичным API модуля (string[]).
"publicApiEntrypoints": ["index.ts"]
По умолчанию — ["index.ts"].
Эти файлы:
Фиксирует ситуацию миграции между профилями.
"transition": { "to": "large", "start": "2026-04-01" }
to — целевой профиль, на который мигрируем (исходный — это сам profile, не дублируется);start — ISO-дата начала миграции.Что даёт:
start используется fitness-функцией для метрики «сколько проект живёт в transit-режиме» — сигнал, что миграция затянулась.null — миграции нет.
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' };
};
integrations/api/*/mappers) — wire-DTO ↔ доменные модели;adapters) — доменные модели → ViewModel (Presenter).Mapper — это приём (направленное преобразование). ACL и Presenter — места, где этот приём применяется: ACL мапит на границе с внешним миром (защита домена), Presenter — на границе с UI (подготовка к показу). Часто работает в паре с DTO.
Ключевой принцип выбора в PDA: берём минимально достаточный профиль.
nuxt — это про стек, а не «вместо» градации сложности.Это прикладная форма принципа YAGNI: не вводим слои, порты и агрегаты, пока нет фактической боли. Соответствует Proportional architecture.
PDA — это не «ещё одна архитектура» вместо FSD или Clean. Это способ управлять архитектурой проекта.
Идея в трёх пунктах:
architecture.manifest.json, а не подразумевается. Смена профиля — это явный ADR/MR с обоснованием, а не «архитектурный дрейф».Поэтому «у нас PDA» означает «наша архитектура объявлена в манифесте и поддержана общим линтером», а конкретный проект с профилем Medium корректно описать как «у нас Clean Architecture». Это разные уровни ответа на один вопрос: PDA — про метод выбора и поддержки архитектуры, профиль — про её конкретное содержание.
Начните с ветки «Зачем PDA» (мотивация), затем Profiles и Manifest (механика), затем Clean Architecture и DDD (словарь паттернов).
Архитектурный стиль Алистера Кокбёрна (он же 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) подставляется фейк — сценарий проверяется без сети.
application каждого контекста.integrations (Repository, Gateway).adapters (stores, presenters).На Medium/Large, где есть реальные внешние зависимости и сценарии. На Small порт между view и integrations — лишняя прослойка: компонент зовёт API-клиент напрямую. Не плодите порт там, где реализация заведомо одна и тестировать нечего.
Presenter (он же ViewModel-mapper) готовит данные домена в форму, удобную конкретному экрану, чтобы UI-компонент остался «глупым» — только отрисовка.
Доменная модель оптимальна для бизнес-логики, но не для верстки: экрану нужны строки в нужном формате, флаги «показывать ли кнопку», производные поля. Если считать это прямо в компоненте, шаблон обрастает логикой, её нельзя протестировать без рендера, а одно и то же преобразование дублируется по экранам. Presenter выносит «подготовку к показу» в отдельное место: её легко покрыть тестом, а компонент получает готовую ViewModel.
Чистая функция-маппер возвращает снимок: посчитали один раз — и всё. Но состояние экрана в Vue почти всегда реактивное (источник меняется — VM должна пересчитаться). Поэтому в реальном коде presenter принимает одну из трёх форм; выбор зависит от того, есть ли у VM собственное состояние и где оно живёт.
Когда преобразование разовое и без своего состояния — это просто 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/композабла.
Когда у 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 };
}
Когда состояние общее или живёт дольше одного компонента — несколько экранов, сохранение между переходами, доступ из разных мест. Это типовая форма presenter в Medium/Large. В setup-синтаксисе роли ложатся на привычные примитивы Vue:
ref — UI-state;computed (getters) — производная ViewModel (то, что раньше считал маппер);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/композабл держит реактивное состояние, а внутри зовёт чистую функцию-маппер для сложного преобразования.
Слой 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, и продукт укладывается в его модель.
app → pages → widgets → features → entities → shared
Импорты разрешены «сверху вниз»; обратные рёбра запрещены.
deepImportAllowList по умолчанию включает @/shared/ui/**: UI-примитивы импортируются по файлам (@/shared/ui/Button.vue) — единый barrel на все компоненты вредит tree-shaking.Детали FSD-специфичных правил и требований к публичному API entities/widgets — отдельный ADR (ADR-fsd-conventions). См. также feature-sliced.design.
Самый строгий профиль: всё из Medium плюс жёсткая изоляция контекстов и полный DDD.
«Минимально достаточный профиль» остаётся правилом выбора: до Large доходят осознанно, через ADR.
Контексты получают собственный публичный API между собой (даже внутри монорепо) и версионирование этого API — межконтекстный контракт становится единицей версионирования внутри проекта. Само версионирование — вне линтера (отдельный follow-up ADR).
Правило 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 утилит/типов/портов внизу графа.
Агрегаты, доменные сервисы, 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
Сбалансированный профиль между «всё в Pinia/UI» и тяжёлым корпоративным DDD: целевая картинка Clean Architecture, но без её избыточности.
Меньше этого — смотрите 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).
Выделяем 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 |
| Application | orchestration сценария, политика вызова API, маппинг ошибок в прикладные типы |
| Integrations | HTTP, SDK, storage, retry, реализации портов |
| Adapters (UI) | состояние экрана, VM для шаблона (тонкая Pinia) |
application не импортировал вендоров;Value Object / Aggregate / Domain Service — точечно, где инварианты реально окупаются. Presenter — для сложных экранов. Contract-тесты — для критичных портов (оплата, поиск полиса), а не для каждого метода.
Несколько команд конфликтуют в одних модулях, нужны версионируемые публичные API между подсистемами, домен обязателен для compliance/аудита — пора в Large. Эволюция — наращивание жёсткости границ и тестов, а не перестройка с нуля.
nuxt-*)Nuxt — это указание стека, а не отдельная архитектура. Поэтому профиля «Nuxt» как такового нет — есть семейство профилей под Nuxt 4 с той же градацией сложности, что и обычные:
nuxt-small · nuxt-medium · nuxt-large · nuxt-fsd
Каждый — это соответствующий базовый профиль (Small / Medium / Large / FSD), адаптированный под фреймворк. Вариант выбирается по той же сложности продукта, что и обычный профиль.
app/ (агрегатор страниц), layers/<bounded-context>/, общий shared//base/.domain/, application/, integrations/, ui/ (components/ + composables/).#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.
Прагматичный каркас для нового небольшого фронтенда (типичный стек: Vue 3 + TypeScript + Pinia + Router + HTTP-клиент). Полный стек Clean Architecture + DDD здесь избыточен.
Подходит, если верно большинство пунктов:
Если уже на старте видно несколько независимых подсистем, нестабильный API и много правил валидации — сразу смотрите Medium.
app · view · integrations · domain
MVVM + тонкая сервисная прослойка:
flowchart LR
V["View / Vue"] --> VM["ViewModel (composable + Pinia)"]
VM --> S["Services / api"]
S --> API["Backend"]
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 покрывает практические нужды маленького проекта.
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.
Профиль — это конкретная архитектура из фиксированного списка, выбранная осознанно и соразмерно сложности.
Значения поля profile в манифесте: small | medium | large | fsd | nuxt-small | nuxt-medium | nuxt-large | nuxt-fsd.
| Профиль | ≈ Что это | Когда |
|---|---|---|
| Small | MVVM | 1 сценарий, 2–3 экрана |
| Medium | Clean + light DDD | 2–5 контекстов, нестабильный API |
| Large | Clean + full DDD | несколько команд, версионируемые API |
| FSD | Feature-Sliced Design | команда осознанно в FSD |
nuxt-* | те же профили на стеке Nuxt 4 | проекты на Nuxt 4 |
Главное правило выбора — минимально достаточный профиль. Миграция между профилями допускается эволюционно (Strangler Fig) и фиксируется в манифесте полем transition.
Сложность архитектуры выбирается пропорционально сложности продукта.
| Продукт | Профиль | Что платим |
|---|---|---|
| 2–3 экрана, один backend | Small (MVVM) | почти ничего |
| 2–5 контекстов, нестабильный API | Medium (Clean + light DDD) | use cases, порты, ACL |
| Несколько команд, версионируемые API | Large (Clean + DDD) | полный DDD + контракт-тесты |
Поэтому усложнение профиля — всегда осознанный шаг через ADR, а не «на вырост». См. Минимальный профиль и Fitness Functions.
Quality Gates — автоматические архитектурные проверки, которые не дают нарушать целевые границы.
pnpm lint блокирует merge.// Идея правила: 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 даёт сценарию доступ к данным так, будто это коллекция доменных объектов в памяти — скрывая, что под капотом 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 работает с коллекцией доменных объектов (полисы, котировки). Gateway оборачивает сервис/возможность (капча, флаги, аналитика). Если по сути это «достать/положить сущности» — репозиторий; если «дёрнуть внешнюю операцию» — gateway.
application (ports);integrations/api/*/repositories.Соблюдение контракта между портом и реальным API проверяется contract-тестом; преобразование wire-DTO в доменную модель — забота ACL/Mapper.
Профили: все (инвариант). 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.
Профили: только 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';
Внутренние слои всегда могут импортировать domains/shared-kernel (Shared Kernel) — намеренно общую доменную модель. Это единственный санкционированный кросс-контекстный импорт, он захардкожен на литеральное имя shared-kernel и не настраивается — другой контекст «общим» не объявить. Технический слой platform к этому отношения не имеет: это не доменная модель.
Это и делает large строже medium. Ядро каждого контекста развивается независимо; связи между контекстами явные и живут в anti-corruption-слоях. (Оба профиля уже запрещают глубокие кросс-импорты через public-api-only; large добавляет изоляцию ядра.)
Профили: все (рёбра — профиль-специфичны). Severity: error (новый код) / warn (baseline). Autofix: нет.
Импорты разрешены только в направлении, заданном профилем (allowedLayers + forbiddenEdges). Обратные рёбра запрещены всегда.
app → pages → widgets → features → entities → sharedapp → view → integrations → domainapp → 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.
Профили: все (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.
Профили: все (инвариант). Severity: error. Autofix: да.
Запрещён импорт из внутренностей чужого модуля — только через публичный вход @/<layer>/<module> (или .../index).
// ❌ глубокий импорт во внутренности
import { usePolicy } from '@/entities/policy/lib/usePolicy.ts';
// ✅ через публичный API
import { usePolicy } from '@/entities/policy';
Фиксер обрезает путь до barrel (@/x/y/lib/z → @/x/y). Он может намеренно вскрыть новую ошибку «символ не экспортирован из публичного API» — это ожидаемо:
deep import → autofix → правило зелёное → загорается no-unresolved / TS-ошибка
→ разработчик добавляет export в index.ts или признаёт символ внутренним
@/shared/ui/**.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 — компромисс: маленькая общая часть с явным договором об изменении.
Если понятие нестабильно или у контекстов на него разный взгляд — это не kernel, а повод для отдельных моделей + ACL.
Это domain-уровень: код лежит в domains/shared-kernel/domain — это опциональный bounded context, а не top-level слой. Не путать с техническим слоем platform (framework-agnostic утилиты, базовые типы, generic-порты): platform — не доменная модель. На Large правило bounded-context-isolation делает domains/shared-kernel единственным разрешённым кросс-контекстным импортом для внутренних слоёв; исключение захардкожено на это литеральное имя и не настраивается — другой контекст «общим» не объявить (см. Слои).
Держите его минимальным. Главный антипаттерн — превратить shared kernel в свалку утилит: тогда он становится точкой жёсткой связности между контекстами и тормозит их независимую эволюцию.
Паттерн Мартина Фаулера: новый код внедряется рядом со старым, постепенно «удушая» (вытесняя) legacy-ветки, без big-bang переписывания.
// Временный флаг для безопасного перехода
if (featureFlags.useNewGetQuoteFlow) {
return getQuoteUseCase.execute(input); // новый путь
}
return legacyStoreGetQuote(input); // старый путь
Берётся один вертикальный срез (например, сценарий расчёта): выносится use case + порт + адаптер-репозиторий, UI и роутинг не трогаются. Затем — следующий срез. См. refactoring-plan.md, «Инкремент №1».
Ubiquitous Language — общий язык предметной области, единый в коде, документации и обсуждениях. Если бизнес говорит «Traveller», «Holder», «Policy Duration» — ровно так называются типы, методы и переменные.
Каждый раз, когда название в коде («user», «item», «data») не совпадает с тем, как говорят бизнес и аналитики («traveller», «policy»), возникает скрытый перевод. Этот перевод живёт в голове разработчика, нигде не зафиксирован и при каждой передаче знаний теряется — отсюда баги «недопоняли требование». Единый язык убирает перевод: код читается как описание домена, а обсуждение фичи и её реализация используют одни и те же слова.
// Явный доменный язык вместо абстракций user/item/flag
export interface Traveller {
id: string;
relationCode: string; // 'OWNER' | ...
contactsSame: boolean;
}
Язык — не разовая договорённость, а живой словарь: появился новый термин в обсуждении — он попадает и в код, и в глоссарий. Переименовали понятие в бизнесе — переименовали везде.
Обязательный документ Domain Glossary хранит знания единого языка: ключевые термины с однозначными определениями и связями. Приоритет глоссария: при расхождении кода, доков и задач прав глоссарий.
Ubiquitous Language — фундамент всего остального DDD: Bounded Context — это граница, внутри которой язык однозначен; Entity, Value Object, агрегаты — это слова из этого языка. Поэтому моделирование начинают с языка, а не с классов.
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, один файл на сценарий, без абстрактных базовых классов.
executeX(...) без класса и DI.Когда у сценария больше одного шага или он переиспользуется. Тривиальный «прочитать одно поле» в use case заворачивать не нужно. При миграции легаси use case — первый кандидат на вынос: даёт быстрый выигрыш в тестируемости (Strangler Fig, «Инкремент №1»).
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 — шире.
Слой domain, в т.ч. общий Shared Kernel для VO, переиспользуемых несколькими контекстами (Money). См. Value Object (Fowler).
PDA рождается из одного тезиса: архитектура — это инструмент управления сложностью, а не самоцель. За каждое архитектурное решение платит команда: правила импортов, лишние слои, дополнительные тесты, время на onboarding.
Значит архитектура должна быть соразмерна сложности продукта. И недостаток границ, и их избыток одинаково вредны:
Подветки объясняют каждую из этих идей по отдельности.