Skip to main content

ADR #008: Schema & Validation Approach

Статус: Принято Дата: 2026-03-03 Контекст: Двойная система схем (JSON Schema + Zod) в backend


Проблема

В проекте сосуществуют два формата описания схем:

ФорматГде используетсяПример файла
JSON SchemaFastify route schema: (AJV валидация), Swagger UIadmin-achievements.schemas.ts
ZodTypeScript типы через z.infer<>, иногда runtime-валидация в сервисахwebhook.schemas.ts

Это два источника правды для одних и тех же данных. При добавлении поля нужно обновить оба места — JSON Schema (для валидации) и Zod (для типа). Рассогласование приводит к тому, что TypeScript "видит" поле, а runtime его не пропускает, или наоборот.

Масштаб

  • 90 schema-файлов, ~30 000 строк
  • 78 route-файлов, ~458 endpoint с schema:
  • Паттерн стабилен по всем ~31 доменам

Как сложилось

Это не осознанный архитектурный выбор, а результат эволюции:

  1. Fastify из коробки работает с JSON Schema (AJV). Первые эндпоинты писались с JSON Schema — это давало runtime-валидацию + Swagger UI автоматически.
  2. Zod добавлялся позже для удобства типизации. z.infer<typeof schema> генерит TypeScript тип из схемы — не нужно дублировать интерфейсы вручную.
  3. fastify-type-provider-zod (единый подход — только Zod) никогда не подключался, потому что к моменту его появления уже было 80+ файлов с JSON Schema.

Альтернатива: fastify-type-provider-zod

Современный подход в экосистеме Fastify — единый источник правды через Zod:

// Один раз определяешь Zod-схему
const CreateUserSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
});

// Fastify автоматически:
// 1. Валидирует request (AJV из сгенеренной JSON Schema)
// 2. Генерит Swagger (из той же JSON Schema)
// 3. Типизирует request.body (через z.infer)
app.post('/users', {
schema: { body: zodToJsonSchema(CreateUserSchema) },
handler: async (request, reply) => {
// request.body уже типизирован как { name: string; email: string }
},
});

Преимущества

  • Одна схема → один источник правды
  • Невозможно рассогласование между типом и валидацией
  • Zod поддерживает transform, refine, discriminatedUnion — мощнее JSON Schema

Почему не мигрируем

ПричинаДетали
Объём85 файлов, 30k строк. Механическая конвертация — 3+ дней, плюс неделя ловли регрессий
Response serializationFastify использует response JSON Schema для fast-json-stringify. Zod-генеренная JSON Schema может не совпасть — поля начнут пропадать из ответов без ошибок
Swagger edge casesz.discriminatedUnion, z.transform, z.refine не всегда корректно конвертируются в JSON Schema. Узнаешь на runtime
Нет тестовИнтеграционных тестов на HTTP-уровне мало. Регрессию поймаешь на staging или на проде
ROIНулевой user-facing benefit. Только DX улучшение для разработчиков

Решение

Продолжаем текущий паттерн. Две системы сосуществуют.

Текущий паттерн

Каждый домен содержит schemas/ с файлами двух видов:

1. JSON Schema — для Fastify route schema: и Swagger

// admin-achievements.schemas.ts
export const CreateAchievementSchema = {
summary: 'Create new achievement',
tags: ['Admin - Achievements'],
body: {
type: 'object',
required: ['title', 'description', 'category', 'icon', 'iconColor', 'targetProgress', 'reward'],
properties: {
title: { type: 'string', minLength: 1 },
conditions: AchievementConditionsSchema, // переиспользуемый блок
},
},
response: {
201: { /* ... */ },
400: StandardResponses[HTTP_STATUS.BAD_REQUEST],
},
} as const;

Используется в routes:

fastify.post('/achievements', {
schema: CreateAchievementSchema,
handler: AdminAchievementController.create,
});

2. Zod — для TypeScript типов и опциональной runtime-валидации

// webhook.schemas.ts
export const rustBatchWebhookPayloadSchema = z.object({
serverId: z.string().min(1),
apiKey: z.string().min(1).optional(),
eventType: z.literal('QUEST_PROGRESS_BATCH'),
timestamp: z.string(),
webhookId: z.string().uuid(),
players: z.record(z.string().min(1), z.object({
updates: z.array(z.object({ /* ... */ })),
minutesPlayed: z.number().int().min(0).optional(),
})),
});

export type RustBatchWebhookPayloadInput = z.infer<typeof rustBatchWebhookPayloadSchema>;

Тип используется в сервисах для валидации batch webhook payload.

3. Base schemas — DRY-переиспользование

// achievement-base.schemas.ts
export const AchievementConditionsSchema = { /* ... */ };
export const RewardInputSchema = { /* ... */ };
export const RewardOutputSchema = { /* ... */ };

// Импортируется в user- и admin- schemas

Правила для новых эндпоинтов

  1. JSON Schema обязателен — он делает реальную работу (валидация + Swagger)
  2. Zod опционален — добавляй если нужен z.infer для типизации body/params
  3. Не дублируй бизнес-правила — если валидация в JSON Schema, не повторяй её в Zod. Zod — только для типа
  4. Base schemas — общие блоки выносить в {domain}-base.schemas.ts

Когда пересмотреть

Пересмотреть решение если:

  • Начнётся новый проект с нуля (сразу на fastify-type-provider-zod)
  • Появятся интеграционные тесты на HTTP-уровне для всех эндпоинтов (безопасная миграция)
  • Рассогласования между JSON Schema и Zod начнут вызывать production-баги регулярно

Связанные документы