Sharing схем между frontend и backend

Архитектура разделения схем валидации между клиентской и серверной частями приложения становится критически важной при росте сложности продукта. При использовании Superstruct единый источник правды для структур данных позволяет устранить дублирование логики, уменьшить количество ошибок несоответствия контрактов и упростить сопровождение.

В классической архитектуре фронтенд и бэкенд часто развиваются независимо. Это приводит к расхождению в ожиданиях относительно формата данных. Например, сервер может начать возвращать дополнительное поле, либо изменить тип существующего, а клиент при этом продолжит работать с устаревшей моделью.

Superstruct решает эту проблему через декларативное описание структур:

import { object, string, number, boolean } from 'superstruct'

export const User = object({
  id: number(),
  name: string(),
  email: string(),
  isActive: boolean(),
})

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

Организация общего слоя схем

На практике схемы выносятся в отдельный пакет или модуль, который подключается как зависимость и в frontend, и в backend. Наиболее распространённая структура выглядит следующим образом:

/packages
  /schemas
    user.js
    auth.js
    product.js
/frontend
/backend

Слой schemas не содержит бизнес-логики, HTTP-клиентов или UI-кода. Его задача — описывать только структуры данных.

Пример схемы в общем пакете:

// packages/schemas/user.js
import { object, string, number, optional } from 'superstruct'

export const CreateUser = object({
  name: string(),
  email: string(),
  age: optional(number()),
})

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

На backend Superstruct используется для валидации входящих запросов. Это позволяет централизованно контролировать корректность данных до их попадания в бизнес-логику.

import { assert } from 'superstruct'
import { CreateUser } from '@app/schemas/user'

app.post('/users', (req, res) => {
  try {
    assert(req.body, CreateUser)

    // бизнес-логика
    const user = createUserInDatabase(req.body)

    res.json(user)
  } catch (e) {
    res.status(400).json({ error: 'Invalid payload' })
  }
})

Использование assert делает валидацию строгой: при несоответствии структуры выбрасывается исключение, что позволяет централизованно обрабатывать ошибки.

Использование схем на клиентской стороне

На frontend те же схемы применяются для проверки ответов API. Это снижает вероятность runtime-ошибок при изменении backend-контракта.

import { create } from 'superstruct'
import { User } from '@app/schemas/user'

async function fetchUser(id) {
  const res = await fetch(`/api/users/${id}`)
  const data = await res.json()

  return create(data, User)
}

Функция create не только проверяет структуру, но и возвращает типизированный объект, что упрощает дальнейшую работу с данными.

Централизация типов и устранение дублирования

При использовании TypeScript схемы Superstruct могут служить источником типов. Это устраняет необходимость ручного дублирования интерфейсов.

import { Infer } from 'superstruct'
import { User } from '@app/schemas/user'

export type UserType = Infer<typeof User>

Такой подход обеспечивает синхронность между runtime-валидацией и compile-time типизацией.

Версионирование схем и обратная совместимость

При развитии системы схемы неизбежно изменяются. В распределённой архитектуре важно избегать ломающих изменений.

Одним из решений является создание версионных схем:

export const UserV1 = object({
  id: number(),
  name: string(),
})

export const UserV2 = object({
  id: number(),
  name: string(),
  email: string(),
})

Backend может поддерживать несколько версий одновременно, а клиент выбирать нужную в зависимости от контекста.

Композиция схем для повторного использования

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

import { object, string, number, array } from 'superstruct'

const Address = object({
  city: string(),
  street: string(),
})

export const UserWithAddresses = object({
  id: number(),
  name: string(),
  addresses: array(Address),
})

Такой подход уменьшает дублирование и упрощает сопровождение крупных моделей данных.

Интеграция с API-слоем

При использовании REST или GraphQL схемы могут применяться как промежуточный слой между транспортом и доменной логикой.

Для REST это обычно middleware-валидация. Для GraphQL — проверка входных аргументов резолверов.

import { assert } from 'superstruct'
import { LoginPayload } from '@app/schemas/auth'

const resolvers = {
  Mutation: {
    login: (_, args) => {
      assert(args.input, LoginPayload)
      return authService.login(args.input)
    },
  },
}

Общие схемы для событий и сообщений

В системах с очередями сообщений (например, event-driven архитектура) схемы Superstruct используются для описания событий.

export const UserCreatedEvent = object({
  type: string(),
  payload: object({
    userId: number(),
    createdAt: string(),
  }),
})

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

Ошибки валидации и их унификация

При разделении схем между слоями важно обеспечить единый формат ошибок. Superstruct возвращает структурированные ошибки, которые можно нормализовать:

import { validate } from 'superstruct'

const [error, value] = validate(input, User)

if (error) {
  console.log(error.failures())
}

Это позволяет формировать единый формат ответа API вне зависимости от точки возникновения ошибки.

Общий пакет схем как часть инфраструктуры

В зрелых системах пакет схем становится инфраструктурным компонентом. Он версионируется отдельно и публикуется как внутренний npm-пакет.

Такой подход обеспечивает:

  • независимое обновление frontend и backend
  • контроль версий контрактов
  • повторное использование логики валидации
  • снижение когнитивной нагрузки при разработке

Синхронизация через CI/CD

Для предотвращения рассинхронизации схем часто вводятся проверки в CI:

  • сборка общего пакета схем
  • прогон тестов совместимости
  • проверка обратной совместимости изменений

Это позволяет обнаруживать нарушения контрактов до попадания в production.

Ограничения подхода

При всей эффективности разделения схем существуют ограничения. Жёсткая связка frontend и backend через общий пакет может усложнить независимое развёртывание сервисов. В распределённых системах иногда требуется дублирование схем с явной версификацией контрактов API.

Также Superstruct работает только на уровне runtime-валидации, не заменяя полноценные IDL-решения в сложных инфраструктурах.

Расширение схем через кастомные структуры

Superstruct позволяет создавать собственные валидаторы, что особенно полезно при централизованном использовании схем:

import { define } from 'superstruct'

const PositiveNumber = define('PositiveNumber', (value) => {
  return typeof value === 'number' && value > 0
})

Такие структуры также выносятся в общий пакет и используются синхронно на всех слоях системы.

Взаимодействие с внешними API

При интеграции со сторонними сервисами общий слой схем позволяет нормализовать внешние данные:

const ExternalUser = object({
  user_id: number(),
  full_name: string(),
})

Далее данные приводятся к внутреннему формату через маппинг:

const normalizeUser = (data) => ({
  id: data.user_id,
  name: data.full_name,
})

Схемы при этом служат защитным слоем между внешними и внутренними контрактами.

Итоговая модель использования

Совместное использование Superstruct на frontend и backend формирует архитектуру, в которой:

  • структура данных описана один раз
  • валидация выполняется на всех границах системы
  • типизация и runtime-проверки синхронизированы
  • изменения контрактов контролируются централизованно

Такой подход особенно эффективен в приложениях с интенсивным обменом данными и частыми изменениями API.