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

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

Обе статьи закрывали деградацию внутри одной кодовой базы. А тост «Сохранено» приехал из места, куда ни один из тех рецептов не достаёт: со шва между кодовыми базами. Про этот шов и поговорим.

Граница, которую не видит ни один анализатор

Контроль границ, на котором держались обе прошлые статьи, устроен одинаково на любом стеке:

  1. Вы объявляете границы: слои, контексты, публичные API модулей.

  2. Анализатор строит по коду граф реальных зависимостей.

  3. Сверяет его с объявленным.

  4. Расхождение — ненулевой код выхода на pre-commit и в CI.

Работает прекрасно — но ровно до тех пор, пока работает язык. Анализатор видит то, что попадает в граф: импорт, наследование, аннотацию. HTTP-вызов в граф не попадает. Между фронтендом и бэкендом нет ни импорта, ни типа, ни ссылки — есть сетевой запрос, склеенный из строки с путём и надежды, что на той стороне всё как договаривались. Компилятор бэкенда не подозревает о существовании фронтенда, компилятор фронтенда — наоборот. Для любого статического анализа на этом месте просто ничего нет.

Хуже того: я сам заложил эту мину и сам же её задокументировал. В чек-листе внедрения из второй статьи есть пункт: «поднять генерацию API-клиента из OpenAPI-спеки бэкенда». Звучит разумно, я так и жил. Только спека при этом рождалась из аннотаций контроллеров — фреймворк собирал её из кода на лету, а фронт генерировал из неё клиент:

код бэкенда → спека → код фронтенда

Это code-first. Спека здесь — производная кода, а производная кода не может поймать в нём ни одной ошибки: код не бывает неправ относительно самого себя. Что бы бэкенд ни делал, спека прилежно опишет это как норму, а фронт прилежно под это сгенерируется. Стрелка смотрела не туда — но чтобы объяснить, куда она должна смотреть, придётся сходить в 2005 год, и мы туда обязательно сходим.

Сначала — место действия. Всё дальнейшее происходит на втором пет-проекте: сервис семейных финансов, Java 21 / Spring Boot, DDD/CQRS, монорепо, фронт на Next.js. Первый проект (AI-обработка фото, Symfony + Next.js) в этой статье почти не появится: на нём я настрадался, на втором — применял выводы. Масштаб: 660 коммитов, 148 операций в API, спека на 6000 строк. Пишет всё по-прежнему агент — и, к слову, быстрее, чем раньше: чем плотнее проект обвешан проверками, тем увереннее агент едет и тем быстрее втыкается в следующую неохраняемую границу. Путь «медовый месяц → похмелье», занявший на первом проекте три месяца, здесь уложился в две недели. Похмелье пришло откуда не ждали — со шва.

Четыре способа сгнить незаметно

Record<string, unknown>, или типизация вслепую. Фронтовому агенту нужна форма ответа эндпоинта. Он её не знает — и типизирует «как-нибудь»:

export async function fetchCreditReport(
  familyId: string,
  importId: string,
): Promise<Record<string, unknown>> {
  ...
  return result as unknown as Record<string, unknown>;
}

Формально типизировано, фактически — any в костюме. Каждое обращение к полю такого объекта дальше по коду написано наугад, и ошибка в нём всплывёт только в рантайме. У меня таких обёрток накопилось около сорока — и целое семейство эндпоинтов статистики прожило в этом виде до самой миграции.

Свободный object — дыра, оформленная как контракт. Один эндпоинт принимал тело как JsonNode, другой возвращал Map<String, Object>. Фреймворк добросовестно вывел это в спеку:

requestBody:
  content:
    application/json:
      schema:
        type: object

Генератор клиента так же добросовестно превратил это в unknown. Получился типизированный хук, внутри которого пустота — худший вариант из возможных: выглядит как контракт, не гарантирует ничего.

Две правды об одной сущности. DTO на Java и интерфейс на TypeScript, описывающие одно и то же, написаны разными сессиями агента с разницей в несколько дней. Пока совпадают — всё хорошо. Потом на бэке поле переименовали, и фронт узнал об этом в браузере у пользователя: для анализатора бэкенда фронта не существует, для анализатора фронта — наоборот.

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

Общее у всех четырёх: ошибка не имеет адреса. Ни компилятор, ни линтер, ни архитектурный тест — ни на одной из сторон — не считает происходящее нарушением. Все проверки зелёные. Продукт сломан.

Агент не умеет спрашивать

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

У агента экономика обратная. «Спросить» для него дорогая операция, «сгенерировать» — дефолтная. Не хватает данных — достроит правдоподобное, и правдоподобное выйдет убедительным: createdAt, items, total — модель прекрасно знает, как обычно выглядят такие ответы. Она не знает, как выглядит ваш.

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

Отсюда центральная мысль статьи:

Контракт — это сжатие контекста на границе. Тридцать строк схемы вместо десяти файлов чужого стека.

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

Остаётся вопрос enforcement. Правило «фронт и бэк должны совпадать» — такая же джентльменская договорённость, как «не инжекти репозиторий в домен», а LLM, как мы выяснили ещё в первой статье, не джентльмен. Только на шве enforcement нельзя получить анализом кода — анализатору там нечего анализировать. Нужен общий артефакт, из которого обе стороны выводятся механически.

Что возвращает нас лет на двадцать назад.

Всё новое — хорошо забытое старое

Разработчики, заставшие нулевые, уже поняли, куда я клоню.

В девяностые была CORBA с её IDL: интерфейс описывался на отдельном языке, из описания генерировались стабы для C++, Java, чего угодно. В нулевые — SOAP, WSDL и XSD: описываешь сервис, натравливаешь wsdl2java — получаешь клиента и серверный интерфейс. Контракт был первичным артефактом, код — производным. Никто это не любил: XML, многословный тулинг, ломавшийся от неправильного namespace, слово «энтерпрайз» в худшем его значении. Но у мучения было свойство, которое мы потом выплеснули вместе с водой: сторона, разошедшаяся с контрактом, не компилировалась.

В десятые пришёл REST, а с ним Swagger — и перевернул стрелку. Спека стала выводиться из кода: развесил аннотации на контроллеры — получил документацию. Я радовался вместе со всеми. Больше никакого рассинхрона между документацией и реальностью — документация теперь всегда правдива, потому что она и есть код!

Code-first работал. Но не потому, что был технически лучше, а потому, что потребителем контракта был человек. Человек открывал Swagger UI, читал, понимал намерение — и адаптировался. Видел type: object — хмыкал и шёл читать код или дёргать коллегу. Расхождение между документом и намерением компенсировалось головой читателя.

Теперь читатель сменился. Агент намерений не восстанавливает — он продолжает образец. Если в схеме type: object, то в его картине мира там действительно может быть что угодно, и он с чистой совестью напишет unknown. Он не хмыкнет и не пойдёт спрашивать — некому и незачем.

Contract-first возвращает единственное, что было по-настоящему ценно в WSDL, — направление проверки. Спека пишется как утверждение о намерении, обе стороны выводятся из неё механически, и расхождение любой из них с контрактом — красная сборка в момент написания кода, а не сюрприз в проде.

Замечу, что маятник качнулся не для всех: gRPC с Protobuf и GraphQL с его SDL от contract-first никогда и не уходили — там схему нельзя не написать. И, по моим наблюдениям, агенты в этих экосистемах заметно увереннее. Не потому что протоколы лучше — потому что схема обязательна.

Мораль ретроспективы не в том, что раньше было лучше. Раньше было хуже, WSDL был отвратителен. Мораль в том, что от contract-first мы отказались ради удобства читателя — а читатель сменился. Со статической типизацией уже случилась ровно такая же история: её выбросили из скриптовых языков как бюрократию и вернули в JavaScript через TypeScript, когда проекты перестали помещаться в голову. Контракт возвращается, когда проект перестал помещаться в контекст.

Бэкенд: контроллер, обязанный контракту

Теперь конкретика. Источник правды — один файл в репозитории:

docs/api/
├── openapi.yaml     # единственный источник правды
├── openapi.json     # синхронная копия для тулинга, НЕ альтернативный контракт
├── .spectral.yaml   # линтер контракта
└── contract-change-process.md  # процесс изменения

Из него на этапе generate-sources собираются серверные интерфейсы:

<plugin>
    <artifactId>openapi-generator-maven-plugin</artifactId>
    <executions>
        <execution>
            <id>generate-api-interfaces</id>
            <phase>generate-sources</phase>
            <goals><goal>generate</goal></goals>
            <configuration>
                <inputSpec>${project.basedir}/../../docs/api/openapi.yaml</inputSpec>
                <generatorName>spring</generatorName>
                <apiPackage>com.denci.openapi.api</apiPackage>
                <schemaMappings>...</schemaMappings>
                <configOptions>
                    <interfaceOnly>true</interfaceOnly>
                    <skipDefaultInterface>true</skipDefaultInterface>
                    <useSpringBoot3>true</useSpringBoot3>
                    <useTags>true</useTags>
                    <useResponseEntity>true</useResponseEntity>
                    <annotationLibrary>none</annotationLibrary>
                </configOptions>
            </configuration>
        </execution>
    </executions>
</plugin>

Две опции здесь важнее остальных. interfaceOnly — генерируются только интерфейсы, без заглушек реализации. skipDefaultInterface — методы без default-тел: не реализовать метод контракта нельзя, это ошибка компиляции.

Контроллер такой интерфейс реализует:

/**
 * HTTP mappings, parameters and response types are inherited from the generated
 * {@link TasksControllerApi}, which is produced from the OpenAPI contract
 * (docs/api/openapi.yaml). Any deviation of a method signature from the contract
 * is a compile error. Business annotations such as {@link FamilyAccess} stay here.
 */
@RestController
public class TasksController implements TasksControllerApi {

    @Override
    @FamilyAccess(FamilyAccess.AccessType.COMMAND)
    public ResponseEntity<CreateTaskResponse> createTask(String familyId, CreateTaskRequest body) {
        ...
    }
}

Посмотрите, чего в этом классе нет: @RequestMapping, @PostMapping, @PathVariable, @RequestBody. Пути, параметры, типы ответов — всё унаследовано от сгенерированного интерфейса. Осталось только то, что контракту не принадлежит: @RestController и бизнес-аннотации вроде @FamilyAccess.

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

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

schemaMappings: без него всё разваливается

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

По умолчанию генератор создаёт для каждой схемы контракта собственный DTO-класс. У вас уже есть TaskView в слое приложения — генератор кладёт рядом свой TaskView. Дальше кто-то (угадайте кто) начинает писать мапперы из одного в другой, и вы получаете слой перекладывания данных, который нужно синхронизировать руками, — то есть ровно ту болезнь, от которой лечились.

schemaMappings говорит генератору: не создавай тип, возьми мой.

TaskView=com.denci.backend.core.tasks.application.view.TaskView,
CreateTaskRequest=com.denci.backend.app.api.http.tasks.request.CreateTaskRequest,
FamilyView=com.denci.backend.core.family.application.FamilyView,
...

Схема контракта отображается на существующий тип проекта. Никакого промежуточного слоя: то, что возвращает хендлер, и есть то, что описано в контракте.

Оборотная сторона, чтобы не выходила реклама: в реальном pom.xml это 126 пар «схема=FQCN», 10 757 символов, и всё — одной строкой, потому что переносы плагин не переваривает. Читать невозможно, ревьюить тем более, а конфликт слияния в этой строке — конфликт во всём контракте сразу. Живёт эта строка только потому, что редактирует её агент. Начинал бы заново — генерировал бы параметр скриптом из отдельного человеческого конфига.

Куда делся springdoc

Рантайм-генератор спеки из проекта не выкинут — Swagger UI в деве удобен. Но роль источника у него отобрана, о чём напоминает комментарий прямо в Makefile:

# Refresh the OpenAPI contract baseline from the running backend (bootstrap only;
# docs/api/openapi.yaml is the hand-maintained source of truth under API-First).
openapi-export:
	$(COMPOSE) exec -T backend curl -s http://localhost:8080/api-docs.yaml > docs/api/openapi.yaml

Комментарий здесь важнее команды. Без него агент однажды решит, что расхождение спеки с кодом чинится «обновлением спеки из бэкенда», выполнит экспорт — и молча вернёт проект в code-first, затерев намерение фактом. По-хорошему, такую цель вообще стоит прятать в скрипт с интерактивным подтверждением.

Внутренние границы: ArchUnit против deptrac

Контракт охраняет шов, но внутренние границы бэкенда — слои, контексты, направление зависимостей — по-прежнему нуждаются в собственном страже. На первом проекте им был deptrac, на втором, вслед за сменой языка, — ArchUnit. Разница между ними оказалась интереснее, чем я ожидал: это два разных класса инструментов.

deptrac декларативен: слои описываются коллекторами, разрешения — правилами в YAML. Сила в том, что конфиг читается целиком за минуту — и человеком, и агентом, которому он попадёт в контекст. Потолок в том, что выразить можно ровно один вид утверждений: кто кого может видеть.

ArchUnit — это обычные тесты на языке проекта, а значит, выразить можно что угодно. Рядом с ожидаемыми «core не зависит от app» и «контексты не видят друг друга напрямую» у меня живут правила, которые в YAML не записываются в принципе:

@ArchTest
static final ArchRule controllers_must_stay_thin = classes()
    .that().resideInAPackage("..app.api..")
    .should(notExceedSourceLines(200));

«Контроллеры должны быть тонкими» — вечное благое пожелание из гайдлайнов. Здесь это падающий тест. А вот правило посерьёзнее:

@ArchTest
static final ArchRule families_mappings_require_family_access = classes()
    .that().resideInAPackage("..app.api..")
    .should(haveFamilyAccessOnAllMappings())
    .because("all /api/families mappings must enforce family access unless explicitly whitelisted");

Каждый HTTP-маппинг под /api/families обязан нести аннотацию @FamilyAccess — кроме явного белого списка: публичный просмотр приглашения и банковский вебхук с HMAC-подписью. Это уже не про слои — это авторизационный инвариант, поднятый до архитектурного теста. Забытый агентом @FamilyAccess — не стилистика, а доступ к чужим финансам.

Чем больше правил живёт в исполняемом коде, тем больше инвариантов проекта можно превратить в ошибку сборки, включая те, что вовсе не про зависимости. Но у выразительности есть цена:

Декларативное правило нельзя написать неправильно молча. Кодовое — можно.

YAML либо покрывает класс, либо нет, и это видно глазами. Код может содержать условие, при котором правило тихо выключает само себя. Запомните эту фразу — она ещё выстрелит.

Фронтенд: клиент, который нельзя написать руками

Фронт генерируется из того же файла:

export default defineConfig({
  denci: {
    input: { target: "../../docs/api/openapi.yaml" },
    output: {
      mode: "tags-split",
      client: "react-query",
      httpClient: "fetch",
      target: "src/shared/api/generated/endpoints",
      schemas: "src/shared/api/generated/model",
      override: {
        mutator: { path: "src/shared/api/generated/mutator.ts", name: "orvalFetch" },
      },
    },
  },
  denciZod: {
    input: { target: "../../docs/api/openapi.yaml" },
    output: {
      mode: "tags-split",
      client: "zod",
      target: "src/shared/api/generated/zod",
    },
  },
});

Выхода два, и это не украшательство. Первый — хуки TanStack Query с TypeScript-моделями: контракт времени компиляции, проверяющий, что фронт ожидает правильное. Второй — zod-схемы: контракт времени выполнения, проверяющий в тестах и деве, что бэк отдаёт правильное. Утверждения разные, нужны оба.

Мутатор orvalFetch — единственное место фронтенда, где живут базовый URL, авторизация и обработка ошибок; рукописный fetch запрещён. А самая полезная строчка во всей фронтовой части выглядит так:

"openapi:check": "orval --config orval.config.ts && prettier --write \"src/shared/api/generated/**/*.ts\" && git diff --exit-code -- src/shared/api/generated"

Перегенерируй — и упади, если результат отличается от закоммиченного. Одна строчка ловит два самых частых греха агента. Первый: увидел ошибку типа в сгенерированном файле — починил её там же (это его естественный рефлекс, generated-код для него такой же код, как остальной). Второй: поправил спеку — забыл регенерировать. Оба означают дрейф клиента от контракта, оба теперь — красный CI.

Само сгенерированное при этом выведено из-под всех остальных проверок фронта — линтера, поиска мёртвого кода, анализатора границ, i18n-гварда:

{ "ignore": ["src/shared/api/generated/**"] }

Иначе гейты краснеют на машинном коде, и агент кидается «чинить» генерацию.

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

Спека — тоже код

Раз спека стала источником правды, к ней применимо всё, что применяется к коду: линтер, версионирование, защита от ломающих изменений.

Линтер. Spectral с небольшим ruleset: осмысленный title, description, запрет trailing slash в путях и — главное — semver в версии:

info-version-semver:
  description: "API info version must follow semantic versioning (MAJOR.MINOR.PATCH)."
  given: $.info.version
  severity: error
  then:
    - function: pattern
      functionOptions:
        match: "^[0-9]+\\.[0-9]+\\.[0-9]+$"

Правил немного, и дело не в их количестве: спека перестала быть свободным текстом и стала объектом линтинга.

Политика ломающих изменений. Скрипт в CI диффует спеку рабочего дерева против master; классификацию изменений делает oasdiff, решение принимает политика:

// Rules:
//   - If the spec on master does not exist, skip (first PR or branch rename).
//   - If the current info.version is not a valid semver string, fail.
//   - If no breaking changes: succeed regardless of version bump.
//   - If breaking changes: fail unless current major > master major (MAJOR bump).
//     A MAJOR bump implies breaking changes are accepted by policy → exit 0.

Зачем это в проекте, который пишет агент: агент никогда сам не осознает, что сломал контракт. Он честно выполнил «переименуй поле», регенерировал обе стороны, всё скомпилировалось, тесты зелёные — работа с его точки зрения безупречна. Что при этом сломались все уже выкаченные потребители, он не скрыл — он об этом не подумал: в его контексте нет ни мобильного приложения, ни внешних клиентов. Классификация «breaking / non-breaking» — внешний сигнал, который иначе в его картину мира не попадает.

Процесс. Документ с таблицей «что считается ломающим» и порядком действий: правим спеку → бампим версию → регенерируем обе стороны → чиним потребителей → один атомарный коммит. Пункт «PR ревьюят бэкендер и фронтендер» в соло-проекте, честно говоря, фикция. Но одна привычка оттуда работает и в одиночку: diff спеки — обязательная часть моего собственного ревью. Он на порядок читаемее диффа кода: двадцать строк YAML говорят о смысле изменения больше, чем четыреста строк Java и TypeScript вокруг. Из всего, что агент наделал за сессию, первым делом я смотрю именно их.

Всё перечисленное собрано в make check вместе с проверками из прошлых статей и висит на pre-commit-хуке, который версионируется в репозитории.

Правило, которое дороже всех конфигов

Осталась симуляция — тот самый тост «Сохранено». Её не ловит ни один конфиг: формально ничего не нарушено, код фронта чист, контракт не тронут. Лечится она правилом в инструкциях проекта:

Правило сохранения UI-операций: если запрошенное UI-действие не имеет бэкенд-поддержки в API-контракте, необходимо расширить контракт (OpenAPI) и реализовать соответствующий backend path, а не убирать UI-элемент и не симулировать локальное сохранение, если пользователь явно не просит иное.

Формулировка кажется очевидной, пока не поймёшь, зачем она написана. Дефолтная стратегия агента — «сделать так, чтобы выглядело выполненным», и у него есть два кратчайших пути к зелёному прогону: убрать кнопку («в задаче не сказано, что она обязательна») или сохранить в локальный стейт и показать тост. Оба выглядят как выполненная задача, оба катастрофичны. Правило перекрывает оба и оставляет единственный выход — расширить контракт.

Но правила мало: разрыв нужно ещё видеть. Для этого в проекте живёт карта «UI ↔ контракт», где у каждого пользовательского сценария есть статус: A — контракт есть и используется, B — контракт есть, но UI работает на локальной логике, C — контракта не хватает.

| Поток                       | Статус | Комментарий                        | Приоритет |
| 2FA и устройства/сессии     |   B    | модалка есть, данные — demo        |    P2     |
| Депозиты: список/projection |   C    | локальный калькулятор; ADR-0008    |    P1     |
| Внешние интеграции          |   C    | UI показывает «недоступно»         |    P1     |

Это дешёвый заменитель фронтендера, стучащегося к бэкендеру: разрыв получает адрес и статус. Заметьте строку про депозиты — локальный калькулятор там по-прежнему есть, но он записан. Разница между симуляцией, о которой знает документ, и симуляцией, о которой не знает никто, — это разница между техдолгом и миной.

Миграция

Проект начинался с code-first, и выгруженная из работающего бэкенда спека была не контрактом, а слепком: свободные object, ответы «200 OK» без тела, теги вида tasks-controller — прямой отпечаток имён Java-классов (кое-где он жив в спеке до сих пор).

Перевод занял три фазы, и порядок здесь важнее содержания. Сначала — замкнуть контур на одном модуле, от спеки до UI, со всеми мелочами. Не ради модуля, а ради образца: дальше агенту показываешь пальцем — «сделай с ledger так же, как сделано с tasks». Один вылизанный образец экономит десятки итераций объяснений — это вообще главный приём работы с LLM на больших однотипных задачах. Потом — ужесточить спеку, заткнув дыры code-first: скучная работа ровно того сорта, который агент делает хорошо, а человек плохо. И только потом — раскатывать по проекту партиями, по ограниченным контекстам, с тестами на каждой партии, а не одним героическим заходом.

Итог: 37 контроллеров из 39 реализуют сгенерированные интерфейсы, версия контракта дошла до 2.0.0 — через настоящий мажорный бамп, которого потребовала политика ломающих изменений.

Показательно, что понадобился ещё один заход — уже после «завершения» миграции. Я прошёлся по фронту grep’ом и нашёл те самые сорок выживших кастов. Винить фронтового агента было не в чем: почти все они сидели поверх дыр в самой спеке. Create-эндпоинты возвращали свободный object, потому что контроллеры отвечали Map.of("id", ...); генератор честно выдавал { [key: string]: unknown }, и фронту ничего не оставалось, кроме как выковыривать поля руками. Где контракт слаб, генерация слабость не лечит — она её честно транслирует на другую сторону.

Лечилось по рецептам этой же статьи. Вместо шестнадцати свободных ответов — две общие схемы, CreatedIdResponse { id } и StatusResponse { status }; на бэке типизированные record’ы вместо Map.of(); регенерация обеих сторон — и касты снялись сами, потому что типы наконец приехали. А чтобы обёртки не отросли обратно (агент обязательно попробует — каст для него дешевле, чем поход в спеку), в проверки фронта добавился запрет as unknown as в API-слое: ещё одно пойманное нарушение, превращённое в правило сборки. Свободный object остался только там, где ему место, — вебхуки и health-check; и часть потоков в карте UI по-прежнему в статусе C, о чём карта честно и сообщает.

Цена

Слепая зона

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

Вспомните правило families_mappings_require_family_access — то, что требует @FamilyAccess на каждом семейном эндпоинте. Вот как оно определяет, что перед ним семейный контроллер:

private boolean checkRequestMapping(JavaClass javaClass) {
    var rm = javaClass.tryGetAnnotationOfType(
        "org.springframework.web.bind.annotation.RequestMapping");
    if (rm.isPresent() && hasFamiliesPath(rm.get().as(RequestMapping.class).value())) {
        return true;
    }
    // ...то же самое для @RequestMapping на методах
    return false;
}

Правило ищет аннотацию @RequestMapping с путём /api/families, физически присутствующую на классе или его методах. ArchUnit не резолвит аннотации, унаследованные от интерфейса.

А что мы сделали при переходе на contract-first? Убрали MVC-аннотации из контроллеров — маппинги переехали в сгенерированные интерфейсы. Счёт: @RestController в проекте — 39, из них с собственным @RequestMapping — один. Для остальных тридцати восьми checkRequestMapping возвращает false, и правило молча выходит, не проверив ничего. Тест зелёный. Проверки авторизации нет. Она была, я её написал, я на неё полагался — и она умерла в тот момент, когда я улучшал архитектуру. Никто мне об этом не сообщил: не существует проверки, которая проверяет, что проверка проверяет.

Вот и выстрелила фраза из середины статьи: декларативное правило нельзя сломать молча, кодовое — можно. И вот обобщение, ради которого весь этот раздел:

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

А сгенерированное мы своими руками вывели из-под всех чекеров — на бэке по пакету, на фронте по папке. И вывели правильно! Иначе гейты краснеют на машинном коде. Но пересечение двух разумных решений дало зону, где правило существует, выполняется и не проверяет ничего. Под LLM это опаснее обычного: агент видит зелёный прогон и считает инвариант живым; я вижу тот же прогон и считаю так же. Отрицательный результат проверки неотличим от её отсутствия — для нас обоих.

Что делать. Опирать правила на то, что осталось в вашем коде после генерации, — здесь надёжный признак implements *ControllerApi, он никуда не денется. Ещё лучше — валидировать инвариант против самой спеки: пути и так лежат в openapi.yaml, это более прямой источник, чем аннотации. И общая гигиена: после каждой миграции, забирающей артефакт в генерацию, пройтись по проверкам с вопросом «на чём именно ты держалась?» — а каждое архитектурное правило хотя бы раз сломать нарочно и убедиться, что оно краснеет. Правило, которое ни разу не падало, не доказано. Моё не падало никогда, и я считал это признаком хорошей дисциплины.

Остальные издержки

Спека на 6000 строк — ещё один артефакт, который надо ревьюить и в котором заводится бардак. Сгенерированный код в git обязателен — без него не проверить дрейф, — но раздувает диффы в разы. Цикл изменения удлинился: добавить одно поле — это спека, регенерация двух сторон, версия, потребители, атомарный коммит; на быстром прототипировании такой налог убил бы меня. И OpenAPI объективно слаб на нестандартном: multipart, бинарные загрузки, вебхуки, стриминг описываются неудобно — мои оставшиеся дыры в основном там.

Когда это не нужно

Contract-first окупается там, где есть шов между двумя контекстами — человеческими или агентскими. Одна кодовая база, один агент, один потребитель API — вы платите налог, не получая страховки. Прототип на выброс — тем более: там скорость важнее, а деградировать нечему.

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

Выводы

  1. Проект под LLM гниёт первым там, где не проверяет ни один компилятор. Анализ кода видит импорты и наследование; HTTP-вызов для него не существует. Шов между фронтом и бэком остаётся открытым, даже когда обе половины идеально чисты внутри.

  2. Контракт — это сжатие контекста на границе. Тридцать строк схемы вместо десяти файлов чужого стека; дешёвый путь агента совпадает с правильным.

  3. Code-first документирует факт, contract-first проверяет намерение. Производная кода не ловит ошибок в коде. Под LLM стрелка обязана смотреть в обратную сторону: код выводится из контракта.

  4. Кодогенерация с обеих сторон — и есть enforcement на шве. Расхождение становится ошибкой компиляции, а ошибку компиляции агент чинит сам.

  5. Спека — тоже код: линтер, semver, политика ломающих изменений. «Breaking / non-breaking» — внешний сигнал; сам агент никогда не поймёт, что сломал чужих потребителей.

  6. Самое дорогое поведение агента на шве — не ошибка, а симуляция. Разрыв контракта должен иметь адрес и статус в живом документе, иначе агент закроет его демо-данными и тостом «Сохранено».

  7. Кодогенерация создаёт слепую зону. Всё, что уехало в сгенерированный код, вышло из-под ваших проверок — вместе с инвариантами, которые на нём держались. Правило, которое ни разу не падало, не доказано: ломайте нарочно.

  8. Ничего нового мы не изобрели. Это WSDL, вернувшийся под другим соусом: от contract-first отказались ради удобства читателя, а читатель сменился.

Чек-лист внедрения:

  • [ ] Один файл спеки в репозитории, объявленный источником правды; рантайм-экспорт из фреймворка — только для бутстрапа, с предупреждением рядом.

  • [ ] Серверные интерфейсы генерируются из спеки на этапе сборки, контроллеры их реализуют; доменные типы подставлены через маппинг схем, чтобы не завёлся параллельный DTO-зоопарк.

  • [ ] Клиент и рантайм-схемы фронта генерируются из той же спеки; рукописный HTTP-вызов и нетипизированные касты поверх сгенерированных типов запрещены проверкой.

  • [ ] Проверка дрейфа: перегенерировать в CI и упасть, если закоммиченное отличается.

  • [ ] Сгенерированное — билд-артефакт: исключено из остальных чекеров, руками не правится.

  • [ ] Линтер спеки и политика ломающих изменений с semver-бампом относительно основной ветки.

  • [ ] Правило в инструкциях проекта: нет контракта под UI-операцию — расширяем контракт, а не симулируем и не прячем кнопку.

  • [ ] Живая карта «UI ↔ контракт» со статусами: разрыв обязан быть виден.

  • [ ] После миграции пройтись по архитектурным правилам: не уехало ли их основание в сгенерированный код. Каждое правило сломать нарочно и убедиться, что краснеет.

  • [ ] Всё это — одной командой, на pre-commit и в CI.

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

Комментарии (0)