Практические паттерны композиции

Библиотека Ajv активно использует механизмы композиции JSON Schema для построения сложных систем валидации. Композиция позволяет собирать большие схемы из независимых модулей, переиспользовать общие части, избегать дублирования и описывать сложные зависимости между структурами данных.

Ключевые инструменты композиции:

  • allOf
  • anyOf
  • oneOf
  • not
  • $ref
  • $defs
  • условные конструкции if/then/else
  • дискриминирующие схемы
  • паттерны наследования
  • объединение базовых схем

Объединение схем через allOf

Конструкция allOf требует, чтобы объект одновременно удовлетворял всем вложенным схемам.

Базовый пример

const Ajv = require("ajv")

const ajv = new Ajv()

const schema = {
  allOf: [
    {
      type: "object",
      properties: {
        id: {
          type: "integer"
        }
      },
      required: ["id"]
    },
    {
      type: "object",
      properties: {
        name: {
          type: "string"
        }
      },
      required: ["name"]
    }
  ]
}

const validate = ajv.compile(schema)

console.log(validate({
  id: 1,
  name: "Alice"
})) // true

Фактически allOf работает как логическое И.

Объект обязан пройти каждую схему.


Практический паттерн: базовая схема + специализация

Один из наиболее распространённых подходов — выделение общей структуры в отдельную схему.

Базовая сущность

const baseEntity = {
  type: "object",
  properties: {
    id: {
      type: "integer"
    },
    createdAt: {
      type: "string",
      format: "date-time"
    }
  },
  required: ["id", "createdAt"]
}

Специализированная схема

const userSchema = {
  allOf: [
    baseEntity,
    {
      type: "object",
      properties: {
        email: {
          type: "string",
          format: "email"
        }
      },
      required: ["email"]
    }
  ]
}

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

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

Ограничения allOf

Важно понимать, что allOf не объединяет свойства автоматически на уровне JavaScript-объектов. Каждая схема валидируется отдельно.

Например:

{
  allOf: [
    {
      additionalProperties: false,
      properties: {
        a: { type: "string" }
      }
    },
    {
      properties: {
        b: { type: "string" }
      }
    }
  ]
}

Такой код может привести к неожиданным ошибкам, потому что первая схема запрещает свойства, которых она не знает.


Безопасный паттерн для allOf

Лучше выносить additionalProperties в финальную объединённую схему.

const schema = {
  type: "object",

  properties: {
    a: { type: "string" },
    b: { type: "string" }
  },

  required: ["a", "b"],

  additionalProperties: false
}

anyOf — частичное совпадение

anyOf требует соответствия хотя бы одной схеме.


Пример anyOf

const schema = {
  anyOf: [
    {
      type: "string"
    },
    {
      type: "number"
    }
  ]
}

Допустимые значения:

"hello"
42

Недопустимое значение:

true

Практический паттерн: несколько форматов API

Часто API поддерживает несколько вариантов запроса.

const schema = {
  anyOf: [
    {
      type: "object",
      properties: {
        email: {
          type: "string",
          format: "email"
        }
      },
      required: ["email"]
    },
    {
      type: "object",
      properties: {
        phone: {
          type: "string"
        }
      },
      required: ["phone"]
    }
  ]
}

Валидны оба варианта:

{ email: "user@mail.com" }
{ phone: "+123456789" }

oneOf — строго одно совпадение

oneOf требует, чтобы объект соответствовал только одной схеме.


Разница между anyOf и oneOf

anyOf

{
  anyOf: [
    { type: "integer" },
    { minimum: 0 }
  ]
}

Число 10 проходит обе схемы — это допустимо.


oneOf

{
  oneOf: [
    { type: "integer" },
    { minimum: 0 }
  ]
}

Число 10 невалидно, потому что совпали обе схемы одновременно.


Практический паттерн: разные типы сущностей

const schema = {
  oneOf: [
    {
      type: "object",
      properties: {
        type: { const: "user" },
        email: { type: "string" }
      },
      required: ["type", "email"]
    },
    {
      type: "object",
      properties: {
        type: { const: "admin" },
        permissions: {
          type: "array"
        }
      },
      required: ["type", "permissions"]
    }
  ]
}

Дискриминирующие схемы

При большом количестве вариантов oneOf может стать медленным. Для оптимизации используется discriminator-подход.


Паттерн discriminator

const schema = {
  oneOf: [
    {
      properties: {
        kind: { const: "circle" },
        radius: { type: "number" }
      },
      required: ["kind", "radius"]
    },
    {
      properties: {
        kind: { const: "square" },
        size: { type: "number" }
      },
      required: ["kind", "size"]
    }
  ]
}

Поле kind выступает дискриминатором типа.


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

  • ускорение валидации;
  • более понятные ошибки;
  • простая сериализация;
  • удобство работы с TypeScript union types.

not — отрицание схем

not инвертирует результат проверки.


Пример

const schema = {
  not: {
    type: "null"
  }
}

Любое значение, кроме null, будет валидным.


Практический паттерн: запрет конфликтующих полей

const schema = {
  type: "object",

  properties: {
    password: { type: "string" },
    token: { type: "string" }
  },

  not: {
    required: ["password", "token"]
  }
}

Нельзя одновременно передавать:

{
  password: "123",
  token: "abc"
}

Переиспользование через $defs

$defs позволяет хранить локальные переиспользуемые схемы.


Пример

const schema = {
  $defs: {
    address: {
      type: "object",
      properties: {
        city: { type: "string" },
        zip: { type: "string" }
      },
      required: ["city", "zip"]
    }
  },

  type: "object",

  properties: {
    home: {
      $ref: "#/$defs/address"
    },

    work: {
      $ref: "#/$defs/address"
    }
  }
}

$ref как основной механизм композиции

$ref — фундаментальная часть архитектуры крупных схем.


Вынос схем в отдельные файлы

user.schema.json

{
  "$id": "user.schema.json",

  "type": "object",

  "properties": {
    "id": {
      "type": "integer"
    },

    "name": {
      "type": "string"
    }
  },

  "required": ["id", "name"]
}

post.schema.json

{
  "type": "object",

  "properties": {
    "title": {
      "type": "string"
    },

    "author": {
      "$ref": "user.schema.json"
    }
  }
}

Регистрация схем

const Ajv = require("ajv")

const ajv = new Ajv()

ajv.addSchema(userSchema)

const validate = ajv.compile(postSchema)

Циклические ссылки схем

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


Пример дерева

const nodeSchema = {
  $id: "node",

  type: "object",

  properties: {
    value: {
      type: "string"
    },

    children: {
      type: "array",

      items: {
        $ref: "node"
      }
    }
  }
}

Условная композиция через if/then/else

Условные конструкции позволяют строить динамическую валидацию.


Пример

const schema = {
  type: "object",

  properties: {
    role: {
      type: "string"
    }
  },

  if: {
    properties: {
      role: {
        const: "admin"
      }
    }
  },

  then: {
    required: ["permissions"]
  },

  else: {
    not: {
      required: ["permissions"]
    }
  }
}

Паттерн «режим работы»

Часто структура зависит от режима конфигурации.


Конфигурация приложения

const schema = {
  type: "object",

  properties: {
    mode: {
      enum: ["development", "production"]
    }
  },

  if: {
    properties: {
      mode: {
        const: "production"
      }
    }
  },

  then: {
    required: ["sslCertificate"]
  }
}

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

Ajv позволяет комбинировать сложные структуры массивов.


Пример tuple-схемы

const schema = {
  type: "array",

  prefixItems: [
    { type: "string" },
    { type: "number" }
  ],

  items: false
}

Допустимо:

["age", 25]

Недопустимо:

["age", 25, true]

Паттерн «словарь объектов»


additionalProperties как композиционный механизм

const schema = {
  type: "object",

  additionalProperties: {
    type: "object",

    properties: {
      enabled: {
        type: "boolean"
      }
    },

    required: ["enabled"]
  }
}

Пример данных:

{
  featureA: {
    enabled: true
  },

  featureB: {
    enabled: false
  }
}

dependentSchemas

dependentSchemas добавляет схему при наличии определённого поля.


Пример

const schema = {
  type: "object",

  properties: {
    creditCard: {
      type: "string"
    }
  },

  dependentSchemas: {
    creditCard: {
      required: ["billingAddress"]
    }
  }
}

dependentRequired

Более лёгкий вариант зависимости.

const schema = {
  type: "object",

  dependentRequired: {
    password: ["confirmPassword"]
  }
}

Паттерн «наследование DTO»


Базовый DTO

const baseDto = {
  type: "object",

  properties: {
    id: {
      type: "integer"
    }
  },

  required: ["id"]
}

DTO создания

const createUserDto = {
  allOf: [
    baseDto,

    {
      properties: {
        email: {
          type: "string"
        }
      },

      required: ["email"]
    }
  ]
}

DTO обновления

const updateUserDto = {
  allOf: [
    baseDto,

    {
      properties: {
        email: {
          type: "string"
        }
      }
    }
  ]
}

Паттерн «расширяемая конфигурация»

Многие плагинообразные системы используют композицию схем.


Базовая схема плагина

const pluginSchema = {
  type: "object",

  properties: {
    name: {
      type: "string"
    },

    options: {
      type: "object"
    }
  },

  required: ["name"]
}

Специализированный плагин

const loggerPluginSchema = {
  allOf: [
    pluginSchema,

    {
      properties: {
        name: {
          const: "logger"
        },

        options: {
          type: "object",

          properties: {
            level: {
              enum: ["info", "warn", "error"]
            }
          }
        }
      }
    }
  ]
}

Оптимизация композиции

Слишком глубокая композиция может ухудшать производительность.


Проблемные признаки

  • десятки вложенных allOf;
  • сложные oneOf;
  • рекурсивные ссылки;
  • большие деревья $ref;
  • пересекающиеся условия.

Практические рекомендации

Использование discriminator-полей

type
kind
category
schemaType

Такие поля резко ускоряют проверку вариантов.


Уменьшение глубины allOf

Плохо:

A -> B -> C -> D

Лучше:

A + B + C

Изоляция переиспользуемых компонентов

Хорошая практика:

$defs:
  pagination
  user
  address
  metadata

Ошибки при композиции схем


Конфликт required

allOf: [
  {
    required: ["a"]
  },
  {
    required: ["b"]
  }
]

Результат:

required: ["a", "b"]

Это не альтернатива, а накопление требований.


Конфликт типов

allOf: [
  { type: "string" },
  { type: "number" }
]

Схема никогда не будет валидной.


Проблемы oneOf

oneOf: [
  { type: "number" },
  { minimum: 0 }
]

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


Архитектура больших схем

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


Пример структуры

schemas/
├── common/
│   ├── pagination.json
│   ├── address.json
│   └── error.json
│
├── user/
│   ├── user.json
│   ├── create-user.json
│   └── update-user.json
│
├── post/
│   ├── post.json
│   └── create-post.json
│
└── index.js

Композиция и TypeScript

Ajv часто используется вместе с TypeScript.


Union types

type Shape =
  | Circle
  | Square

Обычно соответствует:

oneOf: [...]

Базовые интерфейсы

interface Entity {
  id: number
}

Соответствует:

allOf: [...]

Паттерн «частичная схема»

Иногда требуется валидировать только часть объекта.


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

const userSchema = {
  type: "object",

  properties: {
    name: { type: "string" },
    age: { type: "number" }
  },

  required: ["name", "age"]
}

PATCH-схема

const patchSchema = {
  allOf: [
    userSchema,
    {
      required: []
    }
  ]
}

На практике чаще создают отдельную схему без обязательных полей.


Комбинация allOf и if/then


Пример сложной композиции

const schema = {
  allOf: [
    {
      type: "object",

      properties: {
        type: {
          type: "string"
        }
      }
    },

    {
      if: {
        properties: {
          type: {
            const: "email"
          }
        }
      },

      then: {
        required: ["email"]
      }
    },

    {
      if: {
        properties: {
          type: {
            const: "sms"
          }
        }
      },

      then: {
        required: ["phone"]
      }
    }
  ]
}

Стратегия проектирования схем

Эффективная композиция обычно строится по следующим принципам:

  1. Минимальные независимые схемы.
  2. Повторное использование через $ref.
  3. Изоляция общих частей.
  4. Явные discriminator-поля.
  5. Ограниченное использование oneOf.
  6. Минимальная глубина вложенности.
  7. Разделение базовой структуры и бизнес-правил.
  8. Использование $defs для локальных компонентов.
  9. Модульная файловая организация.
  10. Избегание конфликтующих ограничений.