ADR #008: Schema & Validation Approach
Статус: Принято Дата: 2026-03-03 Контекст: Двойная система схем (JSON Schema + Zod) в backend
Проблема
В проекте сосуществуют два формата описания схем:
| Формат | Где используется | Пример файла |
|---|---|---|
| JSON Schema | Fastify route schema: (AJV валидация), Swagger UI | admin-achievements.schemas.ts |
| Zod | TypeScript типы через z.infer<>, иногда runtime-валидация в сервисах | webhook.schemas.ts |
Это два источника правды для одних и тех же данных. При добавлении поля нужно обновить оба места — JSON Schema (для валидации) и Zod (для типа). Рассогласование приводит к тому, что TypeScript "видит" поле, а runtime его не пропускает, или наоборот.
Масштаб
- 90 schema-файлов, ~30 000 строк
- 78 route-файлов, ~458 endpoint с
schema: - Паттерн стабилен по всем ~31 доменам
Как сложилось
Это не осознанный архитектурный выбор, а результат эволюции:
- Fastify из коробки работает с JSON Schema (AJV). Первые эндпоинты писались с JSON Schema — это давало runtime-валидацию + Swagger UI автоматически.
- Zod добавлялся позже для удобства типизации.
z.infer<typeof schema>генерит TypeScript тип из схемы — не нужно дублировать интерфейсы вручную. 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 serialization | Fastify использует response JSON Schema для fast-json-stringify. Zod-генеренная JSON Schema может не совпасть — поля начнут пропадать из ответов без ошибок |
| Swagger edge cases | z.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
Правила для новых эндпоинтов
- JSON Schema обязателен — он делает реальную работу (валидация + Swagger)
- Zod опционален — добавляй если нужен
z.inferдля типизации body/params - Не дублируй бизнес-правила — если валидация в JSON Schema, не повторяй её в Zod. Zod — только для типа
- Base schemas — общие блоки выносить в
{domain}-base.schemas.ts
Когда пересмотреть
Пересмотреть решение если:
- Начнётся новый проект с нуля (сразу на
fastify-type-provider-zod) - Появятся интеграционные тесты на HTTP-уровне для всех эндпоинтов (безопасная миграция)
- Рассогласования между JSON Schema и Zod начнут вызывать production-баги регулярно
Связанные документы
- Domain Structure — структура
schemas/директории - Common Structure —
common/schemas/base.schemas.ts