Перейти к содержанию
Новое AiManual теперь в MAX Подписаться
Публикация AiManual

Contract-First: Как защитить шов между фронтом и бэкендом от деградации под LLM

LLM-агенты генерируют код, который проходит компиляцию, но ломает HTTP-контракты между фронтендом и бэкендом. Разбираем, как contract-first подход с OpenAPI и а

Коротко

Что будет в материале

  1. 01

    Почему LLM-агенты ломают API там, где не ждали

  2. 02

    Code-First: почему выведение спеки из кода не спасает

  3. 03

    Contract-First: OpenAPI как единый источник правды

  4. 04

    Реализация на практике: Java/Spring Boot и Next.js

Почему LLM-агенты ломают API там, где не ждали

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

Эта проблема специфична для LLM-агентов. Человек-разработчик держит контракт в голове или сверяется с документацией. Агент действует по вероятностной логике: он видит фрагмент кода, генерирует правдоподобное продолжение и не осознает, что изменил сигнатуру метода, удалил обязательное поле или переключил формат даты. Компилятор проверяет корректность внутри модуля, но не видит, что клиент отправил user_id строкой, а сервер ожидает число.

Шов, который не видит никто

Шов - это HTTP-вызов между двумя независимыми системами: фронтендом и бэкендом. Внутри каждого слоя работают компиляторы, статические анализаторы и юнит-тесты. Они ловят внутреннюю деградацию: несовместимость типов, отсутствующие импорты, битые ссылки на методы. Но шов находится вне их юрисдикции.

Пример: агент рефакторит бэкенд и меняет формат поля created_at с ISO 8601 на Unix timestamp. Бэкенд-тесты проходят, потому что они проверяют бизнес-логику, а не формат сериализации. Фронтенд продолжает ожидать строку, вызывает new Date(response.created_at) и получает Invalid Date. Ошибка всплывает только в браузере пользователя.

Ситуация усугубляется тем, что LLM-агент не видит полной картины. Он работает с отдельными файлами или фрагментами кодовой базы, умещающимися в контекстное окно. Контракт между системами распределен по десяткам файлов и не попадает в один снимок. Агент буквально не знает, что его изменение на бэке сломает фронт.

Три типичных сценария деградации от LLM-агента

Сценарий 1: Симуляция бэкенда, оторванная от реальности. Агент генерирует мок-сервер для фронтенд-разработки. Он берет несколько примеров ответов из документации и строит правдоподобный, но неполный API. Пропущенные эндпоинты возвращают 404, необязательные поля отсутствуют, формат ошибок не соответствует реальному. Фронтенд пишется под этот мок, а при интеграции с реальным бэкендом ломается.

// Мок, сгенерированный агентом
app.get('/api/users/:id', (req, res) => {
  res.json({ id: req.params.id, name: "John" });
  // Отсутствуют поля email, role, created_at
});

Сценарий 2: Нетипизированные ответы. Агент генерирует клиентский код с any вместо строгой DTO. TypeScript-компилятор пропускает такой код, потому что any совместим с чем угодно. Обращение к несуществующему полю response.address.street не вызывает ошибки на этапе сборки, но падает в рантайме, когда бэкенд возвращает объект без вложенного address.

// Код, сгенерированный агентом
const user: any = await api.getUser(id);
console.log(user.profile.settings.theme);
// Ошибка только в рантайме: Cannot read properties of undefined

Сценарий 3: Рассинхронизация DTO. Агент добавляет поле phone_number в бэкенд-DTO и обновляет базу данных. Но клиентскую DTO он не трогает, потому что она находится в другом репозитории или не попала в контекст. Бэкенд начинает отправлять новое поле, фронтенд его игнорирует. Обратная ситуация опаснее: агент удаляет поле из бэкенда как «неиспользуемое», а фронтенд продолжает на него полагаться.

Эти сценарии объединяет общее свойство: ошибка невидима на этапе сборки. Она проявляется только при интеграции или в продакшене. Традиционный процесс разработки полагается на код-ревью, но когда 80% кода генерируется агентом, человек физически не успевает вычитать все изменения. Подробнее этот риск разобран в статье о когнитивной ловушке код-агентов.

Code-First: почему выведение спеки из кода не спасает

Code-first подход предполагает, что спецификация API генерируется из аннотаций в коде. В Spring Boot это @RestController и @GetMapping, в FastAPI - декораторы и Pydantic-модели. Инструменты вроде Swagger Core собирают OpenAPI-спеку автоматически. Выглядит удобно: написал код - получил документацию.

Проблема в том, что code-first не создает барьера для ошибок агента. Спека - это зеркало кода. Если агент нарушил контракт в коде, зеркало послушно отразит нарушение. Никакого сигнала о breaking change не возникает, потому что инструмент не сравнивает новую версию спеки со старой - он просто перезаписывает её.

Как агент обманывает code-first генерацию

Рассмотрим конкретный пример. Есть контроллер на Spring Boot:

@RestController
public class UserController {
    @GetMapping("/api/users/{id}")
    public UserResponse getUser(@PathVariable Long id) {
        return userService.findById(id);
    }
}

// DTO с тремя полями
public class UserResponse {
    private Long id;
    private String name;
    private String email;
}

Агент решает «оптимизировать» DTO и удаляет поле email как неиспользуемое в текущем контексте. Code-first инструмент генерирует новую OpenAPI-спеку без поля email. Фронтенд, который зависит от этого поля, получает undefined. Ни компилятор бэкенда, ни генератор спеки не сигнализируют о проблеме - с их точки зрения, код корректен.

Code-first не имеет «источника правды». Есть только текущее состояние кода, которое может быть ошибочным. Контракт растворен в аннотациях и не существует как самостоятельный артефакт, который можно проверить, сравнить с предыдущей версией и защитить от случайных изменений.

Contract-First: OpenAPI как единый источник правды

Contract-first переворачивает процесс: спецификация пишется вручную или полуавтоматически в формате OpenAPI, а код генерируется из неё. Спека становится единственным источником правды. Любое изменение API начинается с правки YAML-файла, после чего перегенерируются серверные интерфейсы и клиентский код. Расхождение с контрактом превращается в ошибку компиляции.

Этот подход не нов. Финансовые и телеком-компании использовали его задолго до появления LLM, когда интеграционная надежность была критична. Но с приходом AI-агентов contract-first из практики «для высоконагруженных систем» превращается в необходимость для любой команды, где код генерируется машиной.

Механика защиты: от спеки к ошибке компиляции

Цепочка защиты работает так:

  1. Спецификация OpenAPI описывает все эндпоинты, форматы запросов и ответов, коды ошибок.
  2. Из спеки генерируются интерфейсы для бэкенда. Контроллеры обязаны реализовать эти интерфейсы.
  3. Из спеки генерируется типизированный HTTP-клиент для фронтенда.
  4. Если агент меняет сигнатуру бэкенд-метода - код перестает компилироваться, потому что интерфейс требует другую сигнатуру.
  5. Если агент меняет структуру ответа - фронтенд не собирается, потому что сгенерированный клиент ожидает конкретные поля.

Контракт становится физическим барьером. Агент не может «незаметно» изменить API, потому что изменение требует явной правки спеки, которая находится под версионным контролем и проходит ревью. Шов перестает быть слепой зоной.

Связка «типизированный контракт - ошибка компиляции» работает аналогично тому, как типизированные Pydantic-схемы устраняют галлюцинации в RAG-системах. Мы рассматривали этот паттерн в статье о шаблонах контрактов генерации для RAG - принцип тот же: строгая схема на входе исключает целый класс ошибок на выходе.

Реализация на практике: Java/Spring Boot и Next.js

Разберем сквозную реализацию contract-first для стека Spring Boot (бэкенд) и Next.js (фронтенд). Исходная точка - OpenAPI-спека в YAML-формате, которая описывает API пользователей.

openapi: 3.0.3
info:
  title: User API
  version: 1.0.0
paths:
  /api/users/{id}:
    get:
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
            format: int64
      responses:
        '200':
          description: User found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserResponse'
components:
  schemas:
    UserResponse:
      type: object
      required: [id, name, email]
      properties:
        id:
          type: integer
          format: int64
        name:
          type: string
        email:
          type: string
          format: email

Генерация серверных интерфейсов и контроллеров

Подключаем openapi-generator-maven-plugin в pom.xml. Плагин на этапе генерации исходного кода создает интерфейс с аннотациями Spring:

// Сгенерированный интерфейс
public interface UserApi {
    @GetMapping(value = "/api/users/{id}",
                produces = "application/json")
    ResponseEntity<UserResponse> getUser(
        @PathVariable("id") Long id
    );
}

Контроллер обязан реализовать этот интерфейс:

@RestController
public class UserController implements UserApi {
    @Override
    public ResponseEntity<UserResponse> getUser(Long id) {
        UserResponse response = userService.findById(id);
        return ResponseEntity.ok(response);
    }
}

Если агент попытается изменить сигнатуру метода - например, добавить параметр или поменять возвращаемый тип - компилятор выдаст ошибку: метод не соответствует интерфейсу. Если агент удалит поле из UserResponse, сгенерированный класс перестанет совпадать с маппингом из базы данных, и тесты упадут.

Типизированный клиент для Next.js

На фронтенде используем openapi-typescript-codegen для генерации клиента:

npx openapi-typescript-codegen \
  --input ./specs/user-api.yaml \
  --output ./src/api/generated \
  --client axios

Сгенерированный клиент строго типизирован:

// Сгенерированный клиентский код
import { UserResponse } from './models/UserResponse';

export class UserService {
  public static async getUser(id: number): Promise<UserResponse> {
    const response = await axios.get<UserResponse>(
      `/api/users/${id}`
    );
    return response.data;
  }
}

В компоненте Next.js используем готовый типизированный клиент:

const user = await UserService.getUser(42);
console.log(user.email); // TypeScript гарантирует наличие поля

Если бэкенд-разработчик или агент удалит поле email из спеки, перегенерированный клиент потеряет это поле, и TypeScript-компилятор укажет на все места, где оно использовалось. Сборка фронтенда упадет до того, как код попадет в продакшен.

Архитектурные тесты с ArchUnit

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

@Test
public void controllers_should_implement_generated_interfaces() {
    JavaClasses importedClasses = new ClassFileImporter()
        .importPackages("com.example.controller");

    ArchRule rule = classes()
        .that().resideInAPackage("..controller..")
        .and().areAnnotatedWith(RestController.class)
        .should().implement(HasName.Predicates
            .nameMatching(".*Api"));

    rule.check(importedClasses);
}

Этот тест проверяет, что каждый класс с аннотацией @RestController реализует интерфейс, имя которого заканчивается на Api. Любой контроллер в обход контракта упадет на этапе тестирования. ArchUnit-тесты запускаются в CI/CD и блокируют пулл-реквесты с нарушениями.

Политика ломающих изменений и карта «UI ↔ контракт»

Контракт эволюционирует. Добавляются новые поля, меняются форматы, появляются версии API. Contract-first не запрещает изменения - он делает их явными и контролируемыми.

Как вносить изменения, не ломая всё

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

  1. Разработчик правит user-api.yaml: добавляет поле phone в схему UserResponse.
  2. Запускается генерация серверных интерфейсов и клиента.
  3. Компилятор бэкенда требует реализовать маппинг нового поля - разработчик добавляет его в сервисный слой.
  4. Компилятор фронтенда не ломается, потому что новое поле опционально.

Для ломающих изменений - удаления поля, смены типа, переименования эндпоинта - создается новая версия спеки (v2). Старая версия поддерживается в течение переходного периода. Инструмент openapi-diff автоматически проверяет обратную совместимость при каждом пулл-реквесте в репозиторий со спеками.

Карта симуляций: синхронизация фронта и бэка

Отдельная проблема - моки на фронтенде. Когда бэкенд еще не готов, фронтенд-разработчики используют симуляции ответов. Без contract-first агент генерирует моки на основе примеров из документации, и они часто расходятся с реальностью.

Решение - карта «UI ↔ контракт». Это таблица, где для каждого эндпоинта зафиксирован эталонный пример ответа, взятый из OpenAPI-спеки (секция examples):

ЭндпоинтЭталонный ответ из спекиИспользуется в моке
GET /api/users/{id}{"id": 1, "name": "John", "email": "john@example.com"}Да
POST /api/users{"id": 2, "name": "Jane", "email": "jane@example.com"}Да

Карта хранится в том же репозитории, что и спека. При изменении контракта примеры обновляются, и моки автоматически перегенерируются из актуальной спеки. Агент не может «придумать» ответ - он берет его из утвержденного источника.

Обратная сторона: слепые зоны кодогенерации

Contract-first решает проблему деградации шва, но создает новую: кодогенерация забирает артефакты, на которых держались архитектурные правила и бизнес-инварианты.

Когда кодогенерация забирает слишком много

Рассмотрим рукописную DTO с бизнес-валидацией:

public class CreateUserRequest {
    @NotNull
    @Email(regexp = "^[a-zA-Z0-9._%+-]+@company\\.com$")
    private String email;

    @NotNull
    @Size(min = 2, max = 50)
    private String name;

    // Кастомная валидация: имя не должно содержать цифр
    @AssertTrue(message = "Name must not contain digits")
    public boolean isNameValid() {
        return !name.matches(".*\\d.*");
    }
}

При генерации DTO из OpenAPI-спеки аннотации @Email, @Size и кастомный валидатор теряются. Сгенерированный класс содержит только поля и геттеры/сеттеры. Валидация исчезает, и агент может создать пользователя с именем «123» и email-ом на внешнем домене.

Стратегия смягчения: вынести валидацию на уровень сервиса или использовать отдельные доменные объекты. Сгенерированная DTO становится «транспортным» объектом, а бизнес-правила применяются при маппинге в доменную модель:

public User createUser(CreateUserRequestDto dto) {
    // Валидация на уровне сервиса
    validateEmailDomain(dto.getEmail());
    validateName(dto.getName());

    User user = new User();
    user.setEmail(dto.getEmail());
    user.setName(dto.getName());
    return user;
}

Это увеличивает количество кода, но сохраняет контроль над бизнес-правилами. Альтернативный путь - использовать x-extensions в OpenAPI для описания валидаций и кастомный шаблон генератора, который добавляет аннотации. Оба подхода требуют инвестиций в инфраструктуру кодогенерации.

Когда стоит переходить на Contract-First

Contract-first - не серебряная пуля. Для маленького проекта с одним разработчиком издержки на поддержку спеки и кодогенерации могут превысить выгоду. Но есть четкие критерии, когда переход оправдан.

Вы активно используете LLM-агентов в разработке. Если больше 30% кода генерируется машиной, ручное ревью перестает справляться с контролем контрактов. Contract-first превращает ошибки агента в ошибки сборки.

У вас распределенная команда. Фронтенд и бэкенд разрабатываются разными людьми или даже разными командами. Спека как контракт фиксирует интерфейс и позволяет командам работать параллельно, не ломая интеграцию.

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

Градуальный переход снижает риски: начните с новых эндпоинтов, опишите их в OpenAPI, настройте генерацию клиента. Постепенно мигрируйте существующие эндпоинты. Старые контроллеры продолжают работать, новые требуют реализации сгенерированных интерфейсов.

Contract-first - это инвестиция в предсказуемость. Она окупается при масштабировании команды и активном использовании AI-инструментов. Шов между фронтендом и бэкендом перестает быть слепой зоной и становится еще одним местом, где ошибка ловится автоматически, до того как попадет к пользователю. В мире, где код все чаще пишут машины, такие барьеры - базовая гигиена разработки.

Подписаться на канал