Схема как источник истины

Валидация данных часто воспринимается как вспомогательный слой: проверка типов, обязательных полей и ограничений длины. В крупных системах схема становится центральным элементом архитектуры. Именно схема определяет:

  • структуру входящих данных;
  • ограничения доменной модели;
  • правила сериализации;
  • преобразование значений;
  • совместимость между сервисами;
  • поведение API;
  • контракты между слоями приложения.

В библиотеке Joi схема может выступать единым источником истины — объектом, вокруг которого строится работа приложения.


Проблема дублирования правил

Типичная проблема backend-приложений — размножение одинаковых ограничений в разных местах.

Например:

// frontend
if (username.length < 3)

// controller
if (!req.body.username)

// service
if (user.username.length > 30)

// database
VARCHAR(30)

При изменении бизнес-логики приходится обновлять множество слоёв.

Появляются расхождения:

  • API принимает значение;
  • сервис его отклоняет;
  • база данных выбрасывает ошибку;
  • frontend показывает некорректную валидацию.

Схема Joi позволяет централизовать эти правила.


Схема как контракт данных

Схема становится описанием объекта:

const userSchema = Joi.object({
  username: Joi.string()
    .min(3)
    .max(30)
    .required(),

  email: Joi.string()
    .email()
    .required(),

  age: Joi.number()
    .integer()
    .min(18)
})

Теперь вся информация о структуре пользователя хранится в одном месте:

  • обязательность;
  • типы;
  • ограничения;
  • допустимые значения;
  • вложенные объекты;
  • зависимости между полями.

Централизация бизнес-логики

Повторное использование схем

Схема может использоваться:

  • в HTTP API;
  • в WebSocket-сообщениях;
  • в CLI;
  • в очередях сообщений;
  • в тестах;
  • в микросервисах.
module.exports = {
  userSchema
}

Далее:

const { userSchema } = require('./schemas/user')

await userSchema.validateAsync(payload)

Единая модель данных

Пример архитектуры

Client
  ↓
HTTP API
  ↓
Joi schema
  ↓
Service layer
  ↓
Database

Сервисный слой получает уже валидированные данные.

Это означает:

  • меньше проверок;
  • меньше защитного кода;
  • меньше непредсказуемых состояний;
  • более чистую бизнес-логику.

Схема как средство нормализации

Joi умеет не только проверять данные, но и преобразовывать их.

Преобразование типов

const schema = Joi.object({
  age: Joi.number()
})
const result = schema.validate({
  age: '25'
})

Результат:

{
  value: {
    age: 25
  }
}

Строка автоматически преобразована в число.


Нормализация строк

const schema = Joi.object({
  email: Joi.string()
    .trim()
    .lowercase()
})
const result = schema.validate({
  email: '   ADMIN@SITE.COM '
})

Результат:

{
  value: {
    email: 'admin@site.com'
  }
}

Схема становится не только валидатором, но и механизмом стандартизации данных.


Исключение неоднозначности

Без схемы приложение начинает принимать множество форматов:

{
  active: "true"
}
{
  active: 1
}
{
  active: "yes"
}
{
  active: true
}

Схема устраняет неопределённость:

const schema = Joi.object({
  active: Joi.boolean().required()
})

Схема как документация

Joi-схема уже содержит описание модели.

Даже без внешней документации разработчик видит:

  • обязательные поля;
  • типы;
  • допустимые диапазоны;
  • зависимости;
  • ограничения.

Пример читаемой схемы

const productSchema = Joi.object({
  title: Joi.string()
    .max(200)
    .required(),

  price: Joi.number()
    .positive()
    .precision(2)
    .required(),

  category: Joi.string()
    .valid(
      'books',
      'electronics',
      'games'
    ),

  tags: Joi.array()
    .items(Joi.string())
    .max(10)
})

Такой объект фактически является декларативным описанием сущности.


Использование схемы между слоями

Валидация HTTP-запросов

app.post('/users', async (req, res) => {
  try {
    const value = await userSchema.validateAsync(req.body)

    const user = await userService.create(value)

    res.json(user)

  } catch (err) {
    res.status(400).json({
      error: err.message
    })
  }
})

Валидация событий

await userCreatedSchema.validateAsync(event)

Валидация конфигурации

const envSchema = Joi.object({
  PORT: Joi.number().required(),
  DB_HOST: Joi.string().required(),
  JWT_SECRET: Joi.string().required()
}).unknown()
const { error, value } = envSchema.validate(process.env)

if (error) {
  throw error
}

Теперь приложение гарантированно стартует только при корректной конфигурации.


Схема как механизм безопасности

Валидация напрямую связана с безопасностью.

Ограничение лишних полей

const schema = Joi.object({
  username: Joi.string()
})
schema.validate(data, {
  allowUnknown: false
})

Попытка передать:

{
  username: 'admin',
  role: 'superuser'
}

вызовет ошибку.

Это предотвращает:

  • mass assignment;
  • случайное расширение модели;
  • внедрение неожиданных данных.

Строгость модели

stripUnknown

const schema = Joi.object({
  username: Joi.string()
})
const result = schema.validate(data, {
  stripUnknown: true
})

Лишние поля автоматически удаляются.


Пример

Вход:

{
  username: 'alex',
  isAdmin: true
}

Результат:

{
  username: 'alex'
}

Контроль версий API

Схемы помогают поддерживать разные версии API.

Версия V1

const userV1 = Joi.object({
  username: Joi.string().required()
})

Версия V2

const userV2 = Joi.object({
  username: Joi.string().required(),
  email: Joi.string().email().required()
})

Маршруты используют нужную схему:

routerV1.post('/users', validate(userV1))
routerV2.post('/users', validate(userV2))

Композиция схем

Большие системы редко используют монолитные схемы.

Joi поддерживает композицию.

Базовая схема

const baseUser = Joi.object({
  username: Joi.string().required()
})

Расширение

const adminUser = baseUser.keys({
  permissions: Joi.array()
    .items(Joi.string())
})

Разделение доменной логики

Схемы создания

const createUserSchema = Joi.object({
  username: Joi.string().required(),
  password: Joi.string().required()
})

Схемы обновления

const updateUserSchema = Joi.object({
  username: Joi.string(),
  password: Joi.string()
})

Схемы публичного ответа

const publicUserSchema = Joi.object({
  id: Joi.number(),
  username: Joi.string()
})

Производные схемы

fork

Позволяет изменить часть схемы.

const schema = Joi.object({
  username: Joi.string(),
  email: Joi.string()
})
const requiredSchema = schema.fork(
  ['username', 'email'],
  field => field.required()
)

Условная логика

Схема может содержать бизнес-правила.

when

const schema = Joi.object({
  role: Joi.string()
    .valid('user', 'admin'),

  permissions: Joi.when('role', {
    is: 'admin',

    then: Joi.array()
      .items(Joi.string())
      .required(),

    otherwise: Joi.forbidden()
  })
})

Взаимосвязь полей

with

const schema = Joi.object({
  password: Joi.string(),

  repeatPassword: Joi.string()
})
.with('password', 'repeatPassword')

xor

const schema = Joi.object({
  email: Joi.string(),
  phone: Joi.string()
}).xor('email', 'phone')

Требуется только одно поле.


Схема как источник тестовых данных

Даже тестирование может опираться на Joi-схемы.

Проверка fixture-данных

expect(() => {
  userSchema.validateAsync(fixture)
}).not.toThrow()

Генерация типов

В экосистеме существуют инструменты:

  • генерация TypeScript-типов из Joi;
  • генерация OpenAPI;
  • генерация JSON Schema;
  • создание Swagger-документации.

Схема становится центральной моделью приложения.


Интеграция с TypeScript

Несмотря на динамическую природу Joi, схема может быть синхронизирована с типами.

Проблема рассинхронизации

type User = {
  username: string
}
const schema = Joi.object({
  username: Joi.string(),
  age: Joi.number()
})

Типы и схема начинают расходиться.


Подходы к решению

Используются:

  • генераторы типов;
  • schema-first подход;
  • автоматическая синхронизация типов;
  • inference utilities.

Подход schema-first

В schema-first архитектуре схема считается первичной сущностью.

Не типы определяют валидацию, а схема определяет:

  • типы;
  • структуру API;
  • ограничения;
  • сериализацию;
  • документацию.

Преимущества подхода

Предсказуемость

Все ограничения находятся в одном месте.


Снижение количества ошибок

Меньше расхождений между:

  • frontend;
  • backend;
  • базой данных;
  • документацией.

Упрощение рефакторинга

Изменение схемы автоматически влияет на всё приложение.


Самодокументируемость

Схема уже описывает контракт данных.


Практическая структура проекта

Вариант организации

src/
├── schemas/
│   ├── user/
│   │   ├── create.js
│   │   ├── update.js
│   │   └── public.js
│   │
│   ├── product/
│   └── auth/
│
├── middleware/
│   └── validate.js
│
├── services/
└── controllers/

Middleware для Express

const validate = schema => {
  return async (req, res, next) => {
    try {
      req.validated = await schema.validateAsync(
        req.body,
        {
          abortEarly: false,
          stripUnknown: true
        }
      )

      next()

    } catch (err) {
      res.status(400).json({
        errors: err.details
      })
    }
  }
}

Поведение abortEarly

abortEarly: true

Остановка на первой ошибке.


abortEarly: false

Сбор всех ошибок:

[
  {
    "message": "\"email\" is required"
  },
  {
    "message": "\"password\" length must be at least 8 characters long"
  }
]

Схема как слой защиты домена

Если сервис получает только валидированные данные, бизнес-логика становится значительно проще.

Вместо:

if (!user.email)

сервис может исходить из гарантии корректности структуры.


Изоляция ответственности

Joi отвечает за:

  • структуру;
  • ограничения;
  • преобразование;
  • базовую консистентность.

Сервис отвечает за:

  • бизнес-процессы;
  • доступ к данным;
  • доменные операции.

Anti-pattern: разрозненные проверки

Плохой подход:

if (!email)
if (typeof email !== 'string')
if (!email.includes('@'))
if (email.length > 255)

Правильный подход:

const schema = Joi.object({
  email: Joi.string()
    .email()
    .max(255)
    .required()
})

Сложные доменные ограничения

custom

const schema = Joi.string().custom((value, helpers) => {
  if (value.startsWith('admin')) {
    return helpers.error('string.invalid')
  }

  return value
})

Асинхронная валидация

Joi поддерживает асинхронные проверки через external.

const schema = Joi.object({
  email: Joi.string().email()
}).external(async value => {
  const exists = await db.users.exists(value.email)

  if (exists) {
    throw new Error('Email already exists')
  }
})

Эволюция схемы

Схема — живая часть системы.

Она развивается вместе с бизнес-логикой:

  • появляются новые поля;
  • меняются ограничения;
  • вводятся зависимости;
  • устаревают старые форматы.

При schema-first архитектуре изменения локализованы.


Схема как основа экосистемы

В зрелых проектах Joi-схема становится фундаментом:

  • API-контрактов;
  • middleware;
  • генерации документации;
  • тестирования;
  • сериализации;
  • трансформации данных;
  • безопасности;
  • конфигурации приложения;
  • интеграции между сервисами.

Концепция единственного источника истины

Single Source of Truth означает:

структура данных определяется ровно в одном месте.

Для Joi этим местом является схема.

Все остальные части системы должны опираться именно на неё:

  • HTTP API;
  • frontend;
  • тесты;
  • документация;
  • микросервисы;
  • event-driven архитектура;
  • очереди;
  • CLI;
  • конфигурация.

Практический результат schema-first подхода

При правильной организации проекта:

  • уменьшается количество багов;
  • снижается связность компонентов;
  • упрощается сопровождение;
  • ускоряется рефакторинг;
  • повышается предсказуемость системы;
  • улучшается читаемость кода;
  • уменьшается количество дублирования;
  • упрощается интеграция между командами и сервисами.