Я делаю hhbro.ru один — и как разработчик, и как продукт. У проекта несколько клиентов (web, browser extension, desktop), а домены Resume и Vacancy постоянно эволюционируют: появляются новые поля, меняются структуры, добавляются платные/бесплатные флоу, кеширование, экспорт, AI-анализы.

В какой-то момент стало очевидно: самая дорогая ошибка — не “написал баг”, а “не синхронизировал контракт данных”. Это та категория проблем, которая:

  • проявляется не сразу (часто у части пользователей/клиентов),

  • плохо воспроизводится,

  • быстро размазывается по коду «временными костылями»,

  • и съедает время, которое должно идти в продукт.

Эта статья полезна, если у тебя:

  • больше одного клиента (хотя бы web + mobile/desktop/extension),

  • API живёт и меняется,

  • данные не плоские (JSON-поля, вложенные структуры, “object|null”, массивы, версии),

  • хочется воспроизводимого процесса, а не “держим в голове”.

По сути это не “история про OpenAPI”, а инструкция, как сделать так, чтобы консистентность стала свойством системы, а не внимательности разработчика.

  • OpenAPI — единственный источник истины (структуры request/response, версии).

  • Из OpenAPI автоматически генерятся:

    • TypeScript SDK (клиенты),

    • Zod-схемы (runtime-валидация ответов).

  • На фронте запрещены ручные axios/fetch, локальные DTO/типы и response.data без validateResponse().

  • На бэке — обязательные OpenAPI-схемы для каждого endpoint’а и контрактные unit-тесты, которые валят сборку, если схема забыта.

Результат: изменения в API перестали «внезапно» ломать клиенты. Любая неконсистентность проявляется на этапе генерации/валидации/тестов.

Контекст: почему «дрейф» вообще стал проблемой

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

  • Web (основной интерфейс),

  • Browser extension (обход ограничений источников и быстрый анализ),

  • Desktop (работа с несколькими резюме одновременно — удобно карьерным консультантам),

…а API при этом активно меняется, вылезают симптомы:

  • клиент продолжает ожидать старое поле → рантайм падение;

  • бэкенд уже вернул новую структуру → фронт не знает, что с ней делать;

  • в одном клиенте «пофиксили костылём», в другом забыли → хаос;

  • типы TS «успокаивают», но в рантайме прилетает не то.

В моём случае это совпало с тем, что вокруг домена Resume разрослась функциональность:

  • импорт резюме из файла → извлечение текста → AI-распознавание структуры;

  • AI-улучшение/генерация CV;

  • экспорт в PDF/DOCX/RTF;

  • анализ карьеры и gaps по навыкам;

  • связка с Vacancy/MultiSearch для matching и массовых сценариев.

И в какой-то момент стало ясно: надо перестать надеяться на внимательность разработчика. Нужна система, где консистентность — это свойство процесса.

Главный принцип: Single Source of Truth = строгий OpenAPI

Я выбрал простой «рельс»:

  1. OpenAPI фиксирует контракт: какие поля, какие типы, какие структуры, какие версии API.

  2. Всё, что может быть сгенерировано из контракта — генерируется, а не пишется вручную.

  3. В рантайме клиент подтверждает, что получил именно контракт, а не «что-то похожее».

Почему важно именно runtime:

  • типы TS не спасают, если API изменилось, а клиент не пересобран;

  • часть клиентов может жить отдельно (десктоп, расширение), и «заказчик багов» — пользователь.

Текстовая архитектура домена: куда класть файлы и зачем (шаблон)

1) «Скелет» домена (Apiato): контроллеры, схемы, DTO, бизнес-логика

Пример для домена Resume (но так же у меня устроен эталонный Vacancy):

www/
  app/
    Containers/
      AppSection/
        Resume/
          Actions/
            ListResumesAction.php
            ImportResumeFromFileAction.php
            RecognizeResumeAction.php
            EnhanceResumeAction.php
            ExportResumeAction.php
            GenerateAICVAction.php
            AnalyzeCareerPathAction.php
            AnalyzeSkillsGapAction.php
            ...

          Tasks/
            ExtractTextFromFileTask.php
            ResumeExportPipelineTask.php
            AnalyzeCareerPathTask.php
            AnalyzeSkillsGapTask.php
            ...

          Models/
            Resume.php
            ResumeCategory.php
            ResumeCareerAnalysis.php
            ...

          Data/
            DTOs/
              Entities/
                ResumeDTO.php
                CareerPathDTO.php
                SkillsGapDTO.php
              Requests/
                Resume/
                  UpdateResumeRequestDTO.php
                  ImportResumeFromFileRequestDTO.php
                  RecognizeResumeRequestDTO.php
                  ...
              Responses/
                Resume/
                  GetResumeResponseDTO.php
                  ListResumesResponseDTO.php
                  ImportResumeFromFileResponseDTO.php
                  ...
                CareerPathResponseDTO.php
                SkillsGapResponseDTO.php

            Mappers/
              ResumeMapper.php

            Schemas/
              V1/
                Entities/
                  ResumeSchema.php                (например: ResumeV1)
                Requests/
                  Resume/
                    UpdateResumeRequestSchema.php
                    RecognizeResumeRequestSchema.php
                    AnalyzeCareerPathRequestSchema.php
                    AnalyzeSkillsGapRequestSchema.php
                    ...
                Responses/
                  Resume/
                    GetResumeResponseSchema.php
                    ListResumesResponseSchema.php
                    ImportResumeFromFileResponseSchema.php
                    ...
                  Resume/
                    CareerPathResponseSchema.php
                    SkillsGapResponseSchema.php

          UI/
            API/
              Controllers/
                Entities/
                  ResumeController.php            (реализация: orchestration)
                V1/
                  ResumeController.php            (только OpenAPI attrs + parent::)
              Routes/
                V1/
                  ListResumes.v1.private.php
                  GetResume.v1.private.php
                  UploadResume.v1.private.php
                  RecognizeResume.v1.private.php
                  ...

          Tests/
            Functional/
              API/
                ListResumesTest.php
                AnalyzeCareerPathTest.php
                AnalyzeSkillsGapTest.php
                ...
            Unit/
              OpenAPI/
                ResumeOpenAPIComponentsTest.php
                ResumeCategoryOpenAPIComponentsTest.php

  resources/
    js/
      services/
        resume.js                               (SDK + validateResponse)
      sdk/
        v1/                                     (сгенерированный TS SDK)
      sdk/
        zod/
          v1/                                   (сгенерированные Zod схемы)
      utils/
        validateResponse.js
      services/
        sdk-config.ts

Короткая логика «зачем так»:

  • UI/API/Controllers/Entities: код поведения endpoint’ов (орchestrator), без OpenAPI-шумов.

  • UI/API/Controllers/V1: версионная поверхность + OpenAPI-атрибуты, без логики (только parent::...).

  • Data/Schemas/V1: контрактные схемы request/response/entities для генерации OpenAPI.

  • Data/DTOs/*: строгие DTO для входа и выхода (границы).

  • Actions/Tasks: бизнес-логика и инфраструктура без смешивания с HTTP.

  • Tests/Unit/OpenAPI: страховка от человеческой ошибки “забыл схему”.

  • resources/js/sdk + resources/js/sdk/zod: всё, что нужно клиентам, генерируется автоматически.

2) Минимальный «алгоритм добавления нового endpoint» (чтобы не словить дрейф)

Чтобы читатель мог повторить путь, вот короткий чек-лист:

  1. Сделай реализацию в UI/API/Controllers/Entities/*Controller + Actions/Tasks.

  2. Добавь версионный метод в UI/API/Controllers/V1/*Controller:

    • OpenAPI attributes (pathoperationIdrequestBodyresponses)

    • return parent::method(...)

  3. Создай схемы:

    • Data/Schemas/V1/Requests/...*RequestSchema.php

    • Data/Schemas/V1/Responses/...*ResponseSchema.php

  4. Прогони генерацию:

    • php artisan sdk:generate

  5. На фронте используй только:

    • SDK (@/sdk/v1/api)

    • Zod (@/sdk/zod/v1)

    • validateResponse()

Архитектурный паттерн, который оказался решающим

1) «Entities»-контроллер = реализация

В Apiato я держу фактическую реализацию в:

UI/API/Controllers/Entities/*Controller

Этот слой делает:

  • оркестрацию,

  • преобразование HTTP → DTO,

  • вызов Actions/Tasks,

  • формирование ответа.

Бизнес-логика живёт в Actions/Tasks.

2) Версионный контроллер (V1) = только OpenAPI + прокси

В UI/API/Controllers/V1/*Controller я оставляю «тонкую обёртку»:

  • на методах — OpenAPI-атрибуты (path/operationId/request/response schemas),

  • сам метод просто вызывает parent::method(...).

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

Пример (упрощённо):

#[OA\Get(
  path: "/v1/resumes/{id}",
  operationId: "getResumeV1",
  responses: [
    new OA\Response(
      response: 200,
      content: new OA\JsonContent(ref: "#/components/schemas/GetResumeResponseV1")
    ),
  ]
)]
public function show(int $id): JsonResponse
{
  return parent::show($id);
}

Откуда берётся OpenAPI: генерируем JSON из PHP-атрибутов

Artisan: api:openapi:generate

В проекте есть команда:

php artisan api:openapi:generate

Концептуально она:

  • сканирует app/Ship/OpenApi (общие Info/Servers/security),

  • затем проходит по доменам app/Containers/AppSection/*,

  • берёт только:

    • UI/API/Controllers/V1 (и V2),

    • Data/Schemas/V1 (и V2),

    • Enums,

  • формирует storage/openapi/openapi-v1.json и openapi-v2.json.

То есть я документирую не роуты Laravel, а контрактную поверхность.

Как я синхронизирую клиентов: SDK + Zod из OpenAPI

Artisan: sdk:generate

Команда:

php artisan sdk:generate
# или точечно:
php artisan sdk:generate --versions=v1

Она делает три вещи:

  1. запускает api:openapi:generate

  2. генерирует TypeScript SDK (OpenAPI generator, typescript-axios)

  3. генерирует Zod схемы (openapi-to-zod)

Выходные каталоги:

  • resources/js/sdk/v1/ — SDK-клиент и модели

  • resources/js/sdk/zod/v1/ — Zod-схемы

Почему SDK обязателен

Потому что SDK — это «сшивка» клиента с контрактом:

  • имена методов (operationId),

  • параметры,

  • структуры request/response,

  • ошибки в типах после генерации — это сигнал, что контракт менялся.

Почему Zod обязателен

Потому что Zod — это runtime-барьер:

  • если бэк вернул не контракт — клиент падает с понятной причиной (и не начинает «рисовать мусор»),

  • если кто-то “слегка поправил” данные в ручном коде — Zod это ловит.

Frontend-правила: «никаких адаптеров, никаких локальных DTO»

Это важно: если в проекте позволить «маленькие мапперы» на фронте, они быстро превращаются в свалку. Поэтому я зафиксировал правила как часть архитектуры.

Эталон: домен Vacancy

В Vacancy у меня эталонный сервисный слой:

  • импорт SDK-клиентов из @/sdk/v1/api,

  • импорт схем из @/sdk/zod/v1,

  • каждый ответ проходит validateResponse().

Пример паттерна:

import { ResumesApi } from '@/sdk/v1/api';
import { createV1Config } from '@/services/sdk-config';
import { validateResponse } from '@/utils/validateResponse';
import GetResumeResponseV1Schema from '@/sdk/zod/v1/zod-GetResumeResponseV1';

export async function getResume(id: number) {
  const api = new ResumesApi(createV1Config());
  const response = await api.getResumeV1(id);
  const validated = validateResponse(response.data, GetResumeResponseV1Schema);
  return validated.data;
}

А если endpoint отдаёт файл (PDF/DOCX/RTF), там честно остаётся Blob без Zod — потому что это бинарный ответ.

Контрактные тесты: как не забыть схему и не «сломать» SDK

Самая частая человеческая ошибка: endpoint написал, а *ResponseSchema для OpenAPI забыл.

Чтобы это ловилось автоматически, я добавил доменные тесты, которые:

  • требуют наличие storage/openapi/openapi-v1.json (и говорят “запусти sdk:generate”),

  • вынимают components.schemas,

  • проходят по endpoints определённого tag,

  • проверяют, что каждый 2xx ответ ссылается на существующую schema.

Упрощённый пример:

public function test_resume_endpoints_have_valid_response_schemas(): void
{
  $doc = json_decode(file_get_contents(storage_path('openapi/openapi-v1.json')), true);
  $schemas = $doc['components']['schemas'] ?? [];

  foreach ($doc['paths'] as $path => $methods) {
    foreach ($methods as $method => $op) {
      if (!in_array('Resumes', $op['tags'] ?? [], true)) {
        continue;
      }
      $ref = $op['responses']['200']['content']['application/json']['schema']['$ref'] ?? null;
      $this->assertNotNull($ref, "No schema for {$method} {$path}");
      $name = basename($ref);
      $this->assertArrayHasKey($name, $schemas, "Schema {$name} not found");
    }
  }
}

Для бинарных ответов (экспорт) тест делает исключение по mediaType.

Реальная неконсистентность: объект vs массив (и почему это ломает всё)

Одна из самых противных проблем — поля «объект или null», которые в базе иногда оказываются:

  • [] (пустой массив),

  • списком объектов (JSON list),

  • объектом с типами «строка вместо числа».

Это ломает Zod и ломает клиентов.

Как я решил

Я нормализую такие поля на бэкенде в одном месте (mapper), чтобы API всегда отдавал контракт.

Принципы нормализации:

  • [] → null, если по контракту ожидается object|null

  • list → либо unwrap первого объекта, либо null (по договору)

  • приведение типов (например id: "123" → id: 123) только в пределах безопасного правила

В домене Resume это особенно важно для areacontactseducation и подобных JSON-полей.

Dev-процесс как часть архитектуры (то, что я реально «продаю»)

Я оформил это не как «рекомендации», а как правила процесса:

  • перед изменениями фронта — grep на запрещённые паттерны;

  • перед изменениями API/DTO/Schema — обязательный sdk:generate;

  • SDK/Zod руками не правятся;

  • контрактные тесты по доменам — обязательны.

Почему это важно: это превращает консистентность данных из «настроения разработчика» в системное свойство.

Пошаговая инструкция для повторения (с минимальным входом)

Если ты хочешь внедрить это у себя — делай так:

  1. Выбери один эталонный домен и доведи его до “идеала”:

    • OpenAPI атрибуты на версиях,

    • request/response schemas,

    • фронт: SDK + Zod + validateResponse.

  2. Раздели контроллеры:

    • Entities/*Controller — реализация,

    • V1/*Controller — т��лько OpenAPI и parent::....

  3. Сделай генерацию:

    • api:openapi:generate (строгий JSON),

    • sdk:generate (SDK + Zod).

  4. Добавь контрактные тесты домена:

    • “все endpoints под тегом X имеют валидные схемы”.

  5. Запрети обходные пути на фронте:

    • никакого ручного axios/fetch “пока нет времени”,

    • никакого response.data без validateResponse,

    • никакого “адаптера”, который скрывает несоответствие API.

Почему это важно именно для продуктов, а не «для красоты»

В hhbro.ru это напрямую влияет на ценность:

  • AI анализ вакансии (red flags, salary, interview prep),

  • мультипоиск и массовые сценарии,

  • генерация/улучшение резюме под ATS,

  • расширение/десктоп, которые должны жить независимо,

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

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

Что дальше (если бы я улучшал эту систему ещё)

  • сделать CI-джобу, которая:

    • запускает sdk:generate,

    • проверяет, что рабочее дерево не изменилось (значит в PR не забыли сгенерить артефакты),

    • гоняет доменные OpenAPI-тесты.

  • закрывать “дыры” OpenAPI так, чтобы на фронте не было прямых вызовов без SDK/Zod.


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