Я делаю 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.
Если ты дочитала до конца и хочешь внедрить такой подход в своём проекте — начни с одного домена, сделай его эталонным, и дальше «раскатывай рельсы» по остальным: это даёт самый быстрый эффект на качество и скорость разработки.
