Пятая кнопка за вечер. Архитектура фронтенда под контекст LLM. ai-агенты.. ai-агенты. dependency check.. ai-агенты. dependency check. feature-sliced design.. ai-агенты. dependency check. feature-sliced design. llm.. ai-агенты. dependency check. feature-sliced design. llm. next.js.. ai-агенты. dependency check. feature-sliced design. llm. next.js. openapi.. ai-агенты. dependency check. feature-sliced design. llm. next.js. openapi. ReactJS.. ai-агенты. dependency check. feature-sliced design. llm. next.js. openapi. ReactJS. tanstack query.. ai-агенты. dependency check. feature-sliced design. llm. next.js. openapi. ReactJS. tanstack query. TypeScript.. ai-агенты. dependency check. feature-sliced design. llm. next.js. openapi. ReactJS. tanstack query. TypeScript. искусственный интеллект.. ai-агенты. dependency check. feature-sliced design. llm. next.js. openapi. ReactJS. tanstack query. TypeScript. искусственный интеллект. качество кода.. ai-агенты. dependency check. feature-sliced design. llm. next.js. openapi. ReactJS. tanstack query. TypeScript. искусственный интеллект. качество кода. Проектирование и рефакторинг.. ai-агенты. dependency check. feature-sliced design. llm. next.js. openapi. ReactJS. tanstack query. TypeScript. искусственный интеллект. качество кода. Проектирование и рефакторинг. чистая архитектура.

В прошлой статье я рассказывал, как полгода писал пет-проект руками LLM-агентов, как он за три месяца проехал путь «от стартапа до болота» и что с этим сделал бэкенд: чистая архитектура, два конфига deptrac и субагент-ревьювер. Главный тезис был такой: архитектура — это не эстетика, а способ минимизировать объём контекста, необходимый для одного изменения.

Фронтенду в той статье досталась одна секция, и та отрицательная: «DDD туда не тащите». В комментариях резонно спросили: хорошо, не тащить — а что тащить? Эта статья — ответ. Тот же проект, тот же принцип, но другая половина стека: Next.js вместо Symfony, feature-sliced вместо ограниченных контекстов, dependency-cruiser вместо deptrac. И одно неожиданное открытие про Tailwind.

Долг из первой статьи

Коротко для тех, кто не читал первую часть (лучше прочитать — эта опирается на неё). Проект — сервис AI-обработки изображений: пользователь загружает фото, выбирает инструмент, платит кредитами, получает результат. Бэкенд — модульный монолит на Symfony, фронтенд — Next.js App Router, TypeScript, TanStack Query, Tailwind. Пишется всё практически полностью LLM-агентами.

Выводы первой статьи, которые понадобятся здесь:

  1. LLM деградирует на плохой архитектуре так же, как команда людей, только быстрее: высокий coupling означает, что для безопасной правки нужно загрузить в контекст больше, чем туда влезает.

  2. Правила, которые не проверяются автоматически, для LLM не существуют. Намерение — в правилах проекта, факт — в статическом анализе.

  3. Сообщение об ошибке — лучший способ доставить знание в контекст агента ровно в тот момент, когда оно нужно.

Всё это стек-агностично. А вот инструменты — нет. Поехали.

Почему фронт деградирует ещё быстрее бэкенда

Первый месяц фронтовый vibe coding шёл даже бодрее бэкендового: экраны собирались на глазах, вёрстка — это то, что LLM делает эффектнее всего. Похмелье тоже наступило раньше. К концу второго месяца у меня было:

  • Пять кнопок. Не пять использований одной кнопки — пять компонентов Button, PrimaryButton, ActionButton, SubmitBtn и просто <button className="..."> с одинаковым набором классов, скопированным в шестнадцать мест. Плюс три модалки и четыре спиннера.

  • Компонент страницы инструмента на 700 строк, в котором жили: загрузка файла, опрос статуса задачи, локальная копия баланса кредитов, логика цен и разметка. Любая правка любой из этих вещей означала «загрузи в контекст все 700 строк и молись».

  • useEffect-спагетти. Состояние синхронизировалось с другим состоянием через эффекты, эффекты триггерили друг друга, и агент чинил бесконечные ре-рендеры добавлением ещё одного эффекта с флагом.

  • Рассинхрон данных. Сервер сказал «кредитов 40», шапка показывает 40, а модалка покупки — 50, потому что где-то по дороге серверный ответ скопировали в useState и забыли обновить.

Почему на фронте это происходит быстрее? У меня два объяснения.

Первое — обучающая выборка. Если бэкендный говнокод на GitHub разбавлен хотя бы энтерпрайзом с ревью, то фронтовая выборка — это ещё и пятнадцать лет туториалов, jQuery-эры и копипасты со StackOverflow. Хуже того: в ней вперемешку лежат три несовместимых поколения React — классовые компоненты, эра «фетчим в useEffect» и современные серверные компоненты. Модель не знает, какой сейчас год в вашем проекте. Без жёсткого образца она тянет паттерны из всех трёх эпох сразу, иногда в одном файле.

Второе — сама природа JSX. На бэкенде, чтобы смешать слои, нужно хотя бы сделать импорт, и deptrac его увидит. На фронте разметка, логика, стили и состояние легально живут в одном файле. Это сила компонентной модели — и открытая дверь для god-компонентов: агенту всегда «дешевле» дописать ещё один useState и ещё один блок JSX в существующий файл, чем выделить новый. God-класс на бэкенде растёт месяцами — god-компонент вырастает за неделю.

Тот же тезис, новая проекция

Применим оптику первой статьи. Возьмём типовую задачу: «поменяй поведение кнопки покупки кредитов — добавь состояние загрузки и обработку отказа платежа».

В плохом фронте контекст этой правки: компонент страницы на 700 строк + глобальный стор, где лежит копия баланса + CSS-файл, если стили отдельно + три места, где кнопка используется + где-то ещё логика опроса статуса платежа. Это 20+ тысяч токенов только на «понять, что трогаю». Агент прочитает половину и начнёт править на ощупь — со всеми последствиями из первой статьи.

В хорошем фронте контекст правки — одна папка:

features/buy-credits/
├── PurchaseButton.tsx
├── PurchaseDialog.tsx
├── model.ts
└── index.ts

Всё, что меняется вместе, лежит рядом. Это принцип колокации, и для LLM он даже важнее, чем для человека: человек держит в голове проект между задачами, у агента каждый диалог начинается с нуля. Папка слайса — это готовая «порция контекста»: прочитал одну директорию — можешь безопасно править.

Собственно, это и есть фронтовый аналог ограниченного контекста из DDD. Только граница проходит не по бизнес-инвариантам (их на фронте нет — они на бэкенде), а по пользовательским сценариям: «купить кредиты», «загрузить фото», «выбрать инструмент». В комментариях к первой статье это сформулировали точнее меня: на фронте не приживается тактическая часть DDD — агрегаты, репозитории, доменные сервисы. А вот стратегическая — единый язык и границы контекстов — работает и там. Слайсы и есть её фронтовое воплощение: имена фич говорят на языке продукта, границы проходят по сценариям.

Feature-Sliced Design, урезанный до трёх слоёв

Готовая методология с такими границами давно существует — Feature-Sliced Design. Если совсем коротко: код режется на горизонтальные слои с фиксированным направлением зависимостей, а внутри слоёв — на вертикальные слайсы по смыслу, и слайсы одного слоя друг о друге не знают.

Канонический FSD предлагает до шести слоёв: app, pages, widgets, features, entities, shared. Я честно начал раскладывать по канону — и быстро поймал себя на том самом грехе, за который ругал LLM в первой статье: слои ради слоёв. У каждого «виджета» оказывался ровно один потребитель, у каждой «сущности» — ровно одна фича. Для продуктового SPA моего размера рабочими оказались три слоя:

apps/frontend/
├── app/             # маршруты Next.js: тонкая композиция фич — как контроллеры
└── src/
    ├── features/    # пользовательские сценарии: upload-photo, buy-credits, run-tool
    └── shared/      # фундамент: api-клиент, ui-kit, lib, i18n — не знает о домене

Правила игры — считайте, ruleset из первой статьи:

  • Импорты только вниз: appfeaturesshared. Никогда вверх и никогда вбок.

  • Слайсы изолированы: features/buy-credits не может импортировать из features/upload-photo. Нужно общее — оно спускается в shared.

  • У каждого слайса есть публичный APIindex.ts. Снаружи можно импортировать только его: внутренности слайса — приватная зона.

Внутри слайса никакой обязательной структуры нет: компоненты, model.ts с логикой и types.ts лежат плоско, пока фича обозрима одним взглядом; сегменты ui/ и model/ появляются, когда перестаёт. Слои entities и widgets из канона я держу в уме как направление роста: понадобится одна сущность в трёх фичах — появится entities; растолстеет композиция в app/ — появятся widgets. Но заводить их заранее — ровно та «преждевременная гибкость», которую мы же у LLM и лечим. Методология — словарь, а не устав караульной службы.

Что это даёт агенту — ровно то же, что неймспейсы CoreBillingDomain... давали на бэкенде: карту, читаемую без чтения кода. Путь features/buy-credits/model.ts — это уже документация: сценарий покупки кредитов, логика, а не разметка. Задача «добавь фичу отмены задачи» превращается в «создай features/cancel-task по образцу соседних» — а LLM, как мы помним из первой статьи, машина продолжения паттернов. Дайте ей десять однотипных слайсов, и одиннадцатый она сделает правильно почти без инструкций.

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

Домен фронта живёт на бэкенде

В первой статье я говорил, что фронту вместо доменной модели нужен типизированный API-клиент, сгенерированный из контрактов бэкенда. Теперь покажу, почему это оказался самый рентабельный инструмент во всём фронтовом стеке.

Точка правды — OpenAPI-спека, которую бэкенд генерирует из своих контроллеров. Из неё orval генерирует в shared/api/generated типы и готовые хуки TanStack Query:

// orval.config.ts
export default {
  flemo: {
    input: '../backend/var/openapi.json',
    output: {
      target: 'src/shared/api/generated',
      client: 'react-query',
      mode: 'tags-split',
    },
  },
};
// features/buy-credits/PurchaseDialog.tsx
const { data: plans } = useGetCreditPlans();          // сгенерировано
const { mutate: purchase } = usePurchaseCredits();    // сгенерировано

Что это даёт в LLM-разработке:

Агент не может выдумать поле. Без генерации агент пишет task.status === 'completed', а на сервере статус называется done — и это обнаруживается в рантайме, в лучшем случае на ревью. Со сгенерированными типами это ошибка компиляции через секунду после написания.

Изменение контракта распространяется само. Бэкенд переименовал поле → перегенерили клиент → tsc красный ровно в тех местах фронта, которые отстали. Дальше происходит то же, что с deptrac в первой статье: агент читает ошибки компиляции и чинит каждую — ему не нужно объяснять, что изменилось, список ошибок и есть задача. Ошибка компиляции — это обучающий сигнал, доставленный точно в контекст в нужный момент.

Исчезает целый класс ручного кода. Никаких рукописных fetch-обёрток, типов ответов и ключей кэша — то есть никаких мест, где агент мог бы их продублировать чуть-чуть по-разному.

С генерацией связано и главное правило состояния — водораздел, который я в итоге прописал жирным шрифтом:

Серверными данными владеет TanStack Query. В сторе и useState — только UI-состояние.

Баланс кредитов, список задач, статусы — это кэш серверных данных, и живёт он в query-кэше с инвалидацией. Открыта ли модалка, какой шаг визарда активен, что введено в форму — это UI-состояние, ему место в useState или маленьком zustand-сторе. Тот рассинхрон «в шапке 40, в модалке 50» был ровно нарушением этого водораздела: агент скопировал данные запроса в стор, потому что в его обучающей выборке так делают миллион раз. Запрещённый приём номер один во фронтовой секции CLAUDE.md: useState(data) от результата запроса.

Enforcement: deptrac для фронта

Всё перечисленное — слои, изоляция слайсов, публичные API, запрет на ручной fetch — пока что джентльменское соглашение. А LLM, как мы выяснили в первой статье, не джентльмен: правила из CLAUDE.md он читает и всё равно нарушает, потому что «в обучающей выборке так нормально».

На бэкенде факт проверял deptrac. На фронте ту же роль играет его ближайший родственник — dependency-cruiser: тот же принцип «построить граф импортов и проверить против декларативных правил», только для JS/TS. Архитектурная часть моего конфига:

// .dependency-cruiser.cjs — архитектурные правила (сокращено)
module.exports = {
  forbidden: [
    {
      name: "feature-public-api",
      severity: "error",
      comment:
        "app/ импортирует фичу только через её index.ts. " +
        "Внутренности слайса — приватная зона.",
      from: { path: "^app/" },
      to: { path: "^src/features/", pathNot: "index\.ts$" },
    },
    {
      name: "no-shared-to-features-app",
      severity: "error",
      comment: "shared/ — нижний слой: ему нельзя знать о фичах и app.",
      from: { path: "^src/shared/" },
      to: { path: "^(src/features/|app/)" },
    },
    {
      name: "no-features-to-app",
      severity: "error",
      comment: "Фичи не тянутся вверх: только features → shared.",
      from: { path: "^src/features/" },
      to: { path: "^app/" },
    },
    {
      name: "no-cross-feature",
      severity: "error",
      comment: "Фича не импортирует другую фичу. Общее — вниз, в shared/.",
      from: { path: "(^src/features/)([^/]+)/" },
      to: { path: "^$1", pathNot: "$1$2/" },
    },
    {
      name: "no-circular",
      severity: "error",
      comment: "Циклическая зависимость. Разорвать: инверсия или вынос в shared/.",
      from: {},
      to: { circular: true },
    },
  ],
  options: {
    tsConfig: { fileName: "tsconfig.json" }, // алиасы @/* резолвятся сами
    tsPreCompilationDeps: true,              // видеть и type-only импорты
  },
};

Разберём, что здесь зашито.

Вертикаль и горизонталь — в одном файле. Правила no-shared-to-features-app и no-features-to-app держат направление слоёв, no-cross-feature — изоляцию слайсов, feature-public-api — приватность внутренностей. Помните грабли из первой статьи — почему deptrac потребовал два взаимоисключающих конфига? Там класс принадлежал двум слоям сразу, и каждая зависимость порождала перекрёстные ложные проверки. Здесь этой проблемы нет по построению: правило dependency-cruiser — это просто пара «регексп from → регексп to», модуль не обязан «состоять в слоях». Один конфиг вместо двух — редкий случай, когда фронту досталось проще.

Изоляция слайсов — один регексп, а не матрица. Посмотрите на no-cross-feature: from захватывает имя слайса capture-группой, to запрещает лезть в features/, кроме собственной папки ($1$2/). Не нужно перечислять пары фич — новый слайс попадает под защиту автоматически, конфиг не растёт вместе с проектом. На бэкенде каждая связь контекстов добавлялась в ruleset осознанным коммитом; на фронте связей между слайсами не бывает вообще, поэтому и перечислять нечего.

no-circular — то, чего у deptrac-конфигов из первой статьи не было. На фронте циклы импортов — бытовая травма (два компонента импортируют друг друга через барреллы), и LLM создаёт их не задумываясь.

Нарушение выглядит так (репортёр err-long печатает comment правила прямо под ошибкой):

  error no-cross-feature: src/features/buy-credits/PurchaseDialog.tsx →
        src/features/upload-photo/model.ts
    Фича не импортирует другую фичу. Общее — вниз, в shared/.

✖ 1 dependency violations (1 errors, 0 warnings)

И дальше — тот же эффект, что с deptrac: агент читает ошибку и сам перестраивает код — спускает общее в shared или поднимает композицию в app/. Поэтому comment в правилах стоит писать не для себя, а для агента: это микро-CLAUDE.md, который доставляется в контекст точно в момент нарушения. Лучшей системы доставки знаний в голову LLM пока не придумали.

Бонусом dependency-cruiser даёт «санитарные» правила, которые в LLM-разработке оказались не менее ценными, чем архитектурные:

  • no-orphans — модуль, который никто не импортирует. После LLM-рефакторингов такие остаются пачками: «запасной» компонент, забытая утилита.

  • not-to-dev-dep — прод-код тянет пакет из devDependencies: агент поставил зависимость не в ту секцию, и это выяснилось бы только при сборке в проде.

  • not-to-spec — прод-код импортирует из теста. Да, агент так делает: нашёл в тесте удобный хелпер — и заимпортил.

Альтернативы, чтобы выбирать осознанно: eslint-plugin-boundaries делает похожее внутри ESLint (плюс подсветка в IDE на лету), steiger — официальный линтер FSD, знает про слои и публичные API из коробки. Я выбрал dependency-cruiser за то, что он ближе всех к deptrac по духу и умеет то, чего ESLint-плагины не умеют: циклы, orphans, контроль секций package.json. Запускается одной командой (pnpm arch:check) в pre-commit и CI.

К границам добавляются ещё три уровня enforcement:

TypeScript на максимальной строгости. strict: true — это минимум. Дальше noUncheckedIndexedAccess, exactOptionalPropertyTypes и запрет any через @typescript-eslint/no-explicit-any. Причина не академическая: каждый any — это дыра, через которую агент протащит что угодно, и типовая система перестанет быть тем самым «обучающим сигналом». LLM обожает as any как способ «починить» ошибку компиляции — это должно быть жёстко запрещено и линтером, и правилами.

knip против мёртвого кода. Точки входа для knip — маршруты app/ и публичные API слайсов (src/**/index.ts): всё, что недостижимо из них, — мёртвый код. Красивая симметрия: тот же index.ts, который для dependency-cruiser — граница приватности, для knip — граница живости. Человек копит мёртвые экспорты годами, агент — неделями; каждый удалённый файл — минус кусок контекста, который агент больше никогда не прочитает зря.

Свои проверки дописываются скриптами. Не на всё есть готовый линтер. Пример: LLM патологически хардкодит тексты в разметку мимо i18n — «Save» и «Cancel» прямо в JSX, хотя в правилах написано «только через словарь». Лечится самописным CI-guard’ом: скрипт сканирует прод-код на строковые UI-литералы и сравнивает с закоммиченным baseline-инвентарём; любой новый литерал — красный CI. Легализовать строку можно только явным обновлением baseline в том же PR — и на ревью это видно как осознанное решение, а не проскользнувший хардкод. Это принцип «каждое пойманное нарушение → правило или проверка» из первой статьи, доведённый до конца: если нужной проверки не существует, её пишет тот же агент за полчаса.

Стили: почему Tailwind внезапно LLM-friendly

Признание: до этого проекта я Tailwind не любил. «Грязная разметка, стили размазаны по классам, CSS уже придумали». Проект с LLM-агентами заставил пересмотреть позицию, и вот почему.

Вспомним тезис про минимизацию контекста. Отдельный CSS-файл — это второй файл, который нужно затащить в контекст для любой правки внешнего вида, и который нужно не забыть обновить. Агент забывает: правит разметку, не правит стили, или наоборот — классический рассинхрон в двух файлах. С Tailwind стиль живёт в той же строке, что и разметка: правка локальна, контекст — один файл, рассинхрону физически негде возникнуть.

Второй аргумент — ограниченный словарь. В свободном CSS агент изобретает margin: 13px, color: #4a90d9 и z-index: 9999 в каждом новом компоненте — чуть-чуть по-разному. Tailwind принуждает к дискретной шкале токенов: p-4, text-primary, gap-2. Дизайн-токены в конфиге — это, по сути, ubiquitous language фронтенда: конечный набор слов, которыми описывается внешний вид, и модель с конечными словарями работает отлично. Плюс банальное: обучающая выборка по Tailwind огромна и однородна.

Дисклеймер для комментариев: это не аргумент в holy war. Колокейтед CSS Modules с токенами через custom properties дают похожий эффект. Пуанта не «Tailwind лучше», а «стили должны жить рядом с разметкой и говорить на конечном словаре» — Tailwind просто даёт это из коробки.

Но токены не решают проблему пятой кнопки — ту, с которой началась статья. Её решает связка:

  • shared/ui — единственный источник базовых компонентов. Кнопки, инпуты, модалки, спиннеры живут только там.

  • Правило в CLAUDE.md: «прежде чем создать компонент, проверь shared/ui и соседние слайсы; новый базовый компонент — только с явного согласия».

  • Пункт в чек-листе ревьювера: поиск дублей — потому что первые два пункта агент всё равно иногда проигнорирует.

Это фронтовая версия войны с DRY-болезнью из первой статьи: LLM всегда «дешевле» сгенерировать кнопку заново, чем найти существующую. Генерация — его естественный режим, поиск — дорогая операция. Значит, поиск надо сделать дешёвым (маленький, обозримый shared/ui с говорящими именами) и обязательным (правило + ревью).

CLAUDE.md: фронтовая секция

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

## Frontend

Карта: app/ (маршруты, тонкая композиция) → src/features/ (слайсы
сценариев) → src/shared/ (api, ui, lib, i18n). Импорты только вниз,
слайсы изолированы, снаружи слайса — только index.ts.

После любых изменений фронта: `make fe-validate`
(tsc --noEmit + eslint + depcruise + knip + tests).

Инварианты:
- Серверные данные — только через хуки из shared/api/generated.
  Руками fetch/axios не писать. Каталог generated/ не редактировать —
  только перегенерация (`make fe-api`).
- Серверными данными владеет TanStack Query. В stores/useState —
  только UI-состояние. Никогда: useState(data) от результата запроса.
- Новая фича = новый слайс в features/ по образцу соседних.
- Прежде чем создать компонент — проверь shared/ui и соседние слайсы.
  Новый базовый компонент — только с явного согласия.
- Тексты в UI — только через i18n-ключи, никаких строковых литералов
  в разметке.

Запрещено:
- useEffect для синхронизации состояния с состоянием. Нужное значение —
  деривация при рендере или selector. useEffect — только для внешних
  систем (подписки, DOM, таймеры).
- `as any`, `@ts-ignore`, исключения в .dependency-cruiser.cjs —
  никогда. Ошибка границы означает, что код лежит не в том слое.
- "use client" по умолчанию. Клиентским компонент делает только
  интерактив: обработчики, хуки состояния, браузерные API.

Про useEffect — отдельная боль, поэтому и отдельное правило. Синхронизация состояния эффектами — самый частый фронтовый анти-паттерн LLM: он тянется из эпохи, когда так писали все, и приводит к каскадам ре-рендеров, которые агент потом «чинит» флагами и таймаутами. Формулировка «useEffect — только для внешних систем» отсекает 90% таких случаев, а ревьювер добирает остальное.

Разомкнутый цикл: агент верстает вслепую

Теперь о главном отличии фронта, про которое молчат все разговоры об архитектуре.

На бэкенде цикл обратной связи замкнут: make validate зелёный — значит, поведение корректно с точностью до покрытия тестами. Агент может работать автономно часами, опираясь только на текстовый вывод инструментов.

На фронте типы, линтер и даже юнит-тесты не скажут главного: как это выглядит. Кнопка может уехать за экран, модалка — открыться под шапкой, тёмная тема — превратить текст в «чёрное на чёрном», и весь наш enforcement останется зелёным. Агент, который не видит экран, верстает вслепую — уверенно и с хорошим стилем кода.

Что с этим делаю я, по нарастающей стоимости:

  • Playwright e2e на критические сценарии (загрузка фото → обработка → результат; покупка кредитов). Ловят «сломалось совсем»: кнопка не кликается, форма не отправляется. Это всё ещё текстовый сигнал, агент отрабатывает его сам.

  • Скриншоты в тех же e2e. После прогона агент читает их мультимодально и сравнивает с ожиданием из плана. Ловит грубую вёрстку: наехало, уехало, пропало.

  • Браузер в руках агента (Playwright MCP): для интерактивных задач агент сам открывает страницу, кликает и смотрит на результат до и после правки.

Скажу честно: это самое слабое место конвейера. Скриншотное ревью дорогое, медленное и пропускает нюансы, которые человек видит за секунду. Долю ручной проверки на фронте мне пока сократить до бэкендового уровня не удалось — визуальный вкус остаётся за человеком. Но разница между «агент вообще не видит результат» и «агент видит хотя бы скриншот» — это разница между вёрсткой вслепую и вёрсткой в очках с сильно неправильными диоптриями. Второе заметно лучше.

Пайплайн: диф к первой статье

Сам конвейер не изменился ни на шаг: план → субагент developer → субагент reviewer с чистым контекстом → фиксы до принятия → make fe-validate → человек. Кто не читал, как это устроено и почему ревьювером должен быть отдельный агент, — это вторая половина первой статьи, повторяться не буду.

Единственное отличие — фронтовые пункты в чек-листе ревьювера:

Дополнительно для фронтенда:

7. **Дубли компонентов.** Поищи в shared/ui и соседних слайсах:
   нет ли уже такой кнопки/модалки/спиннера/хелпера?
8. **Состояние.** Серверные данные не скопированы в useState/store?
   Нет useEffect-синхронизации состояния с состоянием?
9. **Границы клиент/сервер.** "use client" только там, где есть
   интерактив? Секреты и тяжёлые зависимости не утекли в клиент?
10. **Доступность.** Интерактив — на элементах с ролями (button, a),
    фокус управляем, у иконок-кнопок есть aria-label?
11. **Скриншоты e2e.** Посмотри артефакты прогона: вёрстка соответствует
    плану? Ничего не наехало в обеих темах?

Пункт 7 — тот же DRY-пункт, что на бэкенде, просто с фронтовым адресом поиска. А вот пункты 9–11 бэкендового аналога не имеют: границы «use client», доступность и визуальная проверка — это чисто фронтовая поверхность ошибок, и статика её не покрывает.

Выводы

Сожму фронтовую часть опыта в несколько строк — нумерация продолжает первую статью по духу:

  1. Тезис «архитектура = минимизация контекста» пережил смену стека без правок. Поменялись только инструменты: слайс вместо ограниченного контекста, dependency-cruiser вместо deptrac, index.ts вместо портов.

  2. Фронту не нужен DDD — нужна колокация и карта. Feature-Sliced Design даёт агенту то же, что неймспейсы DDD на бэкенде: путь к файлу читается как документация, папка слайса — готовая порция контекста для правки. И слоёв нужно меньше, чем кажется: мне хватило трёх — заводить остальные «на всякий случай» значит болеть той же YAGNI-болезнью, которую лечим у LLM.

  3. Домен фронта живёт на бэкенде — и должен приезжать оттуда кодогенерацией. Сгенерированный из OpenAPI клиент превращает каждое расхождение с контрактом в ошибку компиляции, а ошибка компиляции — в задачу, которую агент решает сам. Водораздел «server state в TanStack Query, UI state в сторе» закрывает целый класс багов рассинхрона.

  4. Правила без enforcement по-прежнему не существуют. Слои, изоляция слайсов и публичные API — в dependency-cruiser (вся матрица границ — в одном конфиге: грабли «двух конфигов deptrac» здесь не растут по построению), контракт — в tsc на максимальной строгости, мёртвый код — в knip, а чего не хватает — дописывается скриптом с baseline, как guard против хардкода строк мимо i18n. И пишите comment в правилах для агента: это знание, которое доставляется в его контекст точно в момент нарушения.

  5. Стили должны жить рядом с разметкой и говорить на конечном словаре токенов. Tailwind даёт это из коробки, поэтому оказался LLM-friendly вопреки моим предубеждениям. Пятую кнопку лечит маленький shared/ui плюс ревью, а не токены сами по себе.

  6. Фронтовый цикл обратной связи разомкнут — замыкайте, чем можете. e2e, скриншоты, браузер в руках агента. Полностью замкнуть пока не получается; это честная цена фронтенда.

Чек-лист внедрения на существующем фронте:

  • [ ] Разметить слои — мне хватило трёх: app / features / shared; entities и widgets добавляйте, когда заболит. Правило: импорты вниз, слайсы изолированы, вход в слайс через index.ts.

  • [ ] Закодировать границы в dependency-cruiser (или eslint-plugin-boundaries / steiger); текущие нарушения — точечными исключениями, новые — в запрет. В comment каждого правила — объяснение для агента.

  • [ ] Поднять генерацию API-клиента из OpenAPI-спеки бэкенда; запретить рукописный fetch и правки в generated/.

  • [ ] Провести водораздел состояния: серверные данные → TanStack Query, UI-состояние → useState/стор. Найти и снести существующие копии серверных данных.

  • [ ] Выкрутить строгость: tsc strict+, запрет any, knip в CI (точки входа — маршруты и index.ts слайсов).

  • [ ] Собрать базовые компоненты в shared/ui, прописать правило «сначала ищи, потом создавай».

  • [ ] Одна команда make fe-validate — в pre-commit и CI.

  • [ ] Фронтовая секция в CLAUDE.md: карта, инварианты, запрещённые приёмы (useEffect-синхронизация, as any, “use client” по умолчанию).

  • [ ] e2e со скриншотами на критические сценарии — чтобы агент видел, что наверстал.

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

Автор: kotafey

Источник