Организация схем в проекте

При работе с Zod схема становится центральной точкой описания данных. Она одновременно:

  • валидирует входящие значения;
  • документирует структуру данных;
  • формирует типы TypeScript;
  • служит контрактом между слоями приложения.

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

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

Грамотная организация схем позволяет превратить Zod в полноценный слой описания данных.


Базовые подходы к организации схем

Локальное хранение схем

Минимальный вариант — размещение схем рядом с бизнес-логикой.

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

src/
├── users/
│   ├── user.service.ts
│   ├── user.controller.ts
│   └── user.schema.ts

Пример схемы:

// users/user.schema.ts

import { z } fr om "zod";

export const UserSchema = z.object({
  id: z.string().uuid(),
  email: z.string().email(),
  name: z.string().min(2),
});

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

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

Недостатки:

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

Централизованный каталог схем

Во многих проектах используется единая директория:

src/
├── schemas/
│   ├── user.schema.ts
│   ├── post.schema.ts
│   └── auth.schema.ts

Подход удобен для:

  • API-контрактов;
  • shared-схем;
  • monorepo;
  • SDK;
  • общих типов.

Однако слишком крупный каталог схем превращается в «свалку» моделей. Поэтому обычно применяется модульная организация.


Модульная структура схем

Организация по доменам

Наиболее масштабируемый подход — группировка по бизнес-доменам.

src/
├── modules/
│   ├── auth/
│   │   ├── schemas/
│   │   │   ├── login.schema.ts
│   │   │   ├── register.schema.ts
│   │   │   └── token.schema.ts
│   │   ├── auth.service.ts
│   │   └── auth.controller.ts
│   │
│   ├── users/
│   │   ├── schemas/
│   │   │   ├── user.schema.ts
│   │   │   ├── update-user.schema.ts
│   │   │   └── profile.schema.ts

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

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

Разделение схем по назначению

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

Например:

const UserSchema = z.object({
  id: z.string(),
  email: z.string().email(),
  password: z.string(),
  createdAt: z.date(),
});

Такая схема редко подходит одновременно для:

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

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


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

// create-user.schema.ts

import { z } from "zod";

export const CreateUserSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
  name: z.string().min(2),
});

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

// update-user.schema.ts

import { z } from "zod";
import { CreateUserSchema } from "./create-user.schema";

export const UpdateUserSchema =
  CreateUserSchema.partial();

Схемы ответа API

// user-response.schema.ts

import { z } from "zod";

export const UserResponseSchema = z.object({
  id: z.string().uuid(),
  email: z.string().email(),
  name: z.string(),
  createdAt: z.string(),
});

Схемы базы данных

// user-entity.schema.ts

import { z } from "zod";

export const UserEntitySchema = z.object({
  id: z.string(),
  email: z.string(),
  passwordHash: z.string(),
  createdAt: z.date(),
});

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

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

Плохой пример:

const UserSchema = z.object({
  id: z.string(),
  name: z.string(),
  email: z.string(),
  city: z.string(),
  street: z.string(),
  zip: z.string(),
  avatar: z.string(),
  role: z.string(),
  permissions: z.array(z.string()),
});

Подход приводит к:

  • плохой читаемости;
  • сложному переиспользованию;
  • высокой связанности.

Выделение под-схем

import { z } from "zod";

export const AddressSchema = z.object({
  city: z.string(),
  street: z.string(),
  zip: z.string(),
});

export const AvatarSchema = z.object({
  url: z.string().url(),
});

export const UserSchema = z.object({
  id: z.string(),
  name: z.string(),
  email: z.string(),
  address: AddressSchema,
  avatar: AvatarSchema,
});

Расширение схем

Использование extend

const BaseUserSchema = z.object({
  id: z.string(),
  email: z.string(),
});

const AdminSchema = BaseUserSchema.extend({
  permissions: z.array(z.string()),
});

Подход особенно полезен для:

  • ролей;
  • наследования DTO;
  • API-моделей;
  • административных сущностей.

Использование merge

const TimestampSchema = z.object({
  createdAt: z.date(),
  updatedAt: z.date(),
});

const UserSchema = BaseUserSchema.merge(
  TimestampSchema
);

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

Часто повторяющиеся поля выносятся в отдельные схемы.

Общие идентификаторы

export const IdSchema = z.string().uuid();

Временные метки

export const TimestampsSchema = z.object({
  createdAt: z.date(),
  updatedAt: z.date(),
});

Пагинация

export const PaginationSchema = z.object({
  page: z.number().int().positive(),
  lim it: z.number().int().positive(),
});

Индексные файлы

При большом количестве схем полезно использовать barrel-файлы.

Структура:

schemas/
├── user/
│   ├── create-user.schema.ts
│   ├── update-user.schema.ts
│   ├── user-response.schema.ts
│   └── index.ts
// index.ts

export * from "./create-user.schema";
export * from "./update-user.schema";
export * from "./user-response.schema";

Использование:

import {
  CreateUserSchema,
  UpdateUserSchema,
} from "@/schemas/user";

Разделение input и output схем

Zod позволяет преобразовывать данные через transform.

Это означает, что входной и выходной типы могут отличаться.

Пример

const UserSchema = z
  .string()
  .transform((value) => value.trim());

type Input = z.input<typeof UserSchema>;
type Output = z.output<typeof UserSchema>;

Для сложных систем важно явно разделять:

  • входные DTO;
  • внутренние модели;
  • выход API.

Организация схем в backend-приложении

Структура для Express/NestJS/Fastify

src/
├── modules/
│   ├── users/
│   │   ├── dto/
│   │   ├── schemas/
│   │   ├── services/
│   │   ├── repositories/
│   │   └── controllers/

Разделение DTO и схем

Некоторые команды разделяют:

  • DTO;
  • Zod-схемы;
  • ORM-модели.

Например:

users/
├── dto/
│   └── create-user.dto.ts
│
├── schemas/
│   └── create-user.schema.ts

Однако в TypeScript-проектах DTO часто генерируются напрямую из Zod.


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

Одно из главных преимуществ Zod — отсутствие дублирования типов.

export const UserSchema = z.object({
  id: z.string(),
  email: z.string().email(),
});

export type User = z.infer<typeof UserSchema>;

Важно хранить тип рядом со схемой.


Разделение client/server схем

В fullstack-приложениях часть схем используется:

  • только на сервере;
  • только на клиенте;
  • совместно.

Shared-схемы

packages/
├── shared/
│   ├── schemas/
│   │   ├── user.schema.ts
│   │   └── auth.schema.ts

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

  • frontend;
  • backend;
  • mobile;
  • tests.

Организация схем в monorepo

Популярная структура:

packages/
├── api/
├── web/
├── mobile/
├── shared/
│   ├── schemas/
│   ├── types/
│   └── constants/

Избежание циклических зависимостей

Схемы часто начинают импортировать друг друга.

Например:

// user.schema.ts
import { PostSchema } from "./post.schema";

// post.schema.ts
import { UserSchema } from "./user.schema";

Результат:

  • циклические импорты;
  • undefined;
  • ошибки инициализации.

Использование lazy

import { z } from "zod";

export const UserSchema = z.object({
  posts: z.array(
    z.lazy(() => PostSchema)
  ),
});

export const PostSchema = z.object({
  author: z.lazy(() => UserSchema),
});

Организация enum и констант

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

const UserSchema = z.object({
  role: z.enum([
    "admin",
    "user",
    "moderator",
  ]),
});

Лучше:

export const USER_ROLES = [
  "admin",
  "user",
  "moderator",
] as const;

export const UserRoleSchema =
  z.enum(USER_ROLES);

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

  • переиспользование;
  • единый источник правды;
  • удобство UI.

Слои схем

В крупных проектах удобно выделять уровни.

Domain layer

Бизнес-сущности:

UserDomainSchema

Transport layer

REST/GraphQL DTO:

CreateUserRequestSchema
UserResponseSchema

Persistence layer

Модели хранения:

UserEntitySchema

Нормализация именования

Единый стиль именования критически важен.

Рекомендуемые правила:

Тип Пример
Базовая схема UserSchema
DTO создания CreateUserSchema
DTO обновления UpdateUserSchema
Ответ API UserResponseSchema
Entity UserEntitySchema
Input UserInputSchema
Query UserQuerySchema

Организация схем форм

Во frontend-приложениях схемы часто связаны с формами.

Структура

features/
├── auth/
│   ├── forms/
│   │   ├── login.form.ts
│   │   ├── register.form.ts
│   │   └── schemas/

Пример схемы формы

export const LoginFormSchema = z.object({
  email: z.string().email(),
  password: z.string().min(6),
});

Разделение валидаторов

Иногда сложная бизнес-логика засоряет схему.

Плохой пример:

const schema = z.object({
  password: z
    .string()
    .refine(hasUppercase)
    .refine(hasNumber)
    .refine(hasSpecialChar)
    .refine(isNotCompromisedPassword)
});

Лучше выносить проверки:

export const passwordValidators = {
  hasUppercase,
  hasNumber,
  hasSpecialChar,
};

Каталог reusable-схем

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

Пример

shared/
├── schemas/
│   ├── primitives/
│   ├── common/
│   ├── pagination/
│   ├── auth/
│   └── validation/

Примитивные схемы

export const EmailSchema =
  z.string().email();

export const UrlSchema =
  z.string().url();

export const UUIDSchema =
  z.string().uuid();

Разделение strict/passthrough режимов

В разных слоях нужны разные правила обработки неизвестных полей.


strict

const schema = z
  .object({
    name: z.string(),
  })
  .strict();

Неизвестные поля вызовут ошибку.


passthrough

const schema = z
  .object({
    name: z.string(),
  })
  .passthrough();

Лишние поля сохраняются.


strip

const schema = z
  .object({
    name: z.string(),
  })
  .strip();

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


Подход schema-first

В schema-first архитектуре схема становится основой всего приложения.

Из неё могут генерироваться:

  • типы;
  • OpenAPI;
  • формы;
  • документация;
  • контракты API;
  • runtime-валидация.

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

При грамотной организации схем возможно автоматическое создание документации.

Например:

schemas/
├── requests/
├── responses/
└── entities/

Такое разделение особенно удобно для Swagger/OpenAPI-генерации.


Организация тестирования схем

Схемы должны тестироваться отдельно.

Структура

schemas/
├── user.schema.ts
└── __tests__/
    └── user.schema.test.ts

Пример теста

describe("UserSchema", () => {
  it("валидирует пользователя", () => {
    const result = UserSchema.safeParse({
      id: "123",
      email: "test@test.com",
    });

    expect(result.success).toBe(true);
  });
});

Разделение синхронной и асинхронной валидации

Не стоит смешивать:

  • структуру данных;
  • запросы к БД;
  • внешние API.

Плохой пример:

z.string().refine(async (email) => {
  return !(await userExists(email));
});

Лучше:

  • структура — в Zod;
  • бизнес-проверки — в сервисах.

Паттерн schema factory

Иногда схема зависит от параметров.

Пример

export const createPasswordSchema = (
  minLength: number
) =>
  z.string().min(minLength);

Динамические схемы

export const createUserSchema = (
  isAdmin: boolean
) =>
  z.object({
    name: z.string(),
    permissions: isAdmin
      ? z.array(z.string())
      : z.undefined(),
  });

Организация ошибок валидации

Полезно хранить сообщения централизованно.

export const validationMessages = {
  required: "Поле обязательно",
  invalidEmail: "Некорректный email",
};

Использование:

z.string({
  required_error:
    validationMessages.required,
});

Масштабирование схем

При росте проекта рекомендуется:

  • избегать универсальных схем;
  • строить композицию;
  • выделять shared-схемы;
  • разделять слои;
  • минимизировать связанность;
  • изолировать домены;
  • не смешивать transport/domain/persistence модели;
  • хранить схемы рядом с бизнес-доменом;
  • переиспользовать базовые примитивы;
  • избегать циклических импортов;
  • строить единую стратегию именования.