Я делаю 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.php -
Data/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(...).
Смысл: контракт и версия вынесены на поверхность, а логика не дублируется.
Пример (упрощённо):
#[OAGet(
path: "/v1/resumes/{id}",
operationId: "getResumeV1",
responses: [
new OAResponse(
response: 200,
content: new OAJsonContent(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|null -
list → либо 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.
Если ты дочитала до конца и хочешь внедрить такой подход в своём проекте — начни с одного домена, сделай его эталонным, и дальше «раскатывай рельсы» по остальным: это даёт самый быстрый эффект на качество и скорость разработки.
Автор: DVZakusilo


