Я делаю 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
Я выбрал простой «рельс»:
OpenAPI фиксирует контракт: какие поля, какие типы, какие структуры, какие версии API.
Всё, что может быть сгенерировано из контракта — генерируется, а не пишется вручную.
В рантайме клиент подтверждает, что получил именно контракт, а не «что-то похожее».
Почему важно именно 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» (чтобы не словить дрейф)
Чтобы читатель мог повторить путь, вот короткий чек-лист:
Сделай реализацию в
UI/API/Controllers/Entities/*Controller+Actions/Tasks.Добавь версионный метод в
UI/API/Controllers/V1/*Controller:OpenAPI attributes (
path,operationId,requestBody,responses)return parent::method(...)
Создай схемы:
Data/Schemas/V1/Requests/...*RequestSchema.phpData/Schemas/V1/Responses/...*ResponseSchema.php
Прогони генерацию:
php artisan sdk:generate
На фронте используй только:
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
Она делает три вещи:
запускает
api:openapi:generateгенерирует TypeScript SDK (OpenAPI generator,
typescript-axios)генерирует 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|nulllist → либо unwrap первого объекта, либо
null(по договору)приведение типов (например
id: "123"→id: 123) только в пределах безопасного правила
В домене Resume это особенно важно для area, contacts, education и подобных JSON-полей.
Dev-процесс как часть архитектуры (то, что я реально «продаю»)
Я оформил это не как «рекомендации», а как правила процесса:
перед изменениями фронта — grep на запрещённые паттерны;
перед изменениями API/DTO/Schema — обязательный
sdk:generate;SDK/Zod руками не правятся;
контрактные тесты по доменам — обязательны.
Почему это важно: это превращает консистентность данных из «настроения разработчика» в системное свойство.
Пошаговая инструкция для повторения (с минимальным входом)
Если ты хочешь внедрить это у себя — делай так:
Выбери один эталонный домен и доведи его до “идеала”:
OpenAPI атрибуты на версиях,
request/response schemas,
фронт: SDK + Zod + validateResponse.
Раздели контроллеры:
Entities/*Controller— реализация,V1/*Controller— т��лько OpenAPI иparent::....
Сделай генерацию:
api:openapi:generate(строгий JSON),sdk:generate(SDK + Zod).
Добавь контрактные тесты домена:
“все endpoints под тегом X имеют валидные схемы”.
Запрети обходные пути на фронте:
никакого ручного
axios/fetch“пока нет времени”,никакого
response.dataбезvalidateResponse,никакого “адаптера”, который скрывает несоответствие API.
Почему это важно именно для продуктов, а не «для красоты»
В hhbro.ru это напрямую влияет на ценность:
AI анализ вакансии (red flags, salary, interview prep),
мультипоиск и массовые сценарии,
генерация/улучшение резюме под ATS,
расширение/десктоп, которые должны жить независимо,
монетизация через кредиты, кеширование и повторное использование результатов.
Если контракт дрейфует — продукт выглядит «сырой». Если контракт стабилен — ты можешь быстро добавлять фичи, не боясь снежного кома регрессий.
Что дальше (если бы я улучшал эту систему ещё)
сделать CI-джобу, которая:
запускает
sdk:generate,проверяет, что рабочее дерево не изменилось (значит в PR не забыли сгенерить артефакты),
гоняет доменные OpenAPI-тесты.
закрывать “дыры” OpenAPI так, чтобы на фронте не было прямых вызовов без SDK/Zod.
Если ты дочитала до конца и хочешь внедрить такой подход в своём проекте — начни с одного домена, сделай его эталонным, и дальше «раскатывай рельсы» по остальным: это даёт самый быстрый эффект на качество и скорость разработки.
