Привет, Хабр! Сегодня хочу поговорить о проблеме, с которой сталкивается каждый фулстек-разработчик, и о том, как её можно элегантно решить.

Проблема, которая всех бесит

Представьте: пишете форму регистрации. На фронте описываете типы для полей формы. На бэке — те же самые типы для валидации. Меняете одно поле — нужно помнить, что надо поменять в двух местах. Забыли обновить на бэке? Получите баг в продакшене.

А теперь добавьте сюда:

  • Константы (статусы заказов, роли пользователей)

  • Схемы валидации (Zod, Yup)

  • Утилитные функции (форматирование дат, работа со строками)

  • Бизнес-логику, которая должна работать одинаково на клиенте и сервере

Всё это обычно дублируется между фронтендом и бэкендом. И это не просто неудобно — это источник багов и постоянной головной боли.

Варианты решения

Вариант 1: Копипаста

Самый простой и самый плохой. Держите всё в одном репозитории, но фронт и бэк — отдельные проекты. Общий код копируете руками. Понятно, что это путь в никуда.

Вариант 2: Отдельный npm-пакет

Делаете отдельный пакет для shared-кода. Звучит неплохо, но:

  • Каждое изменение = публикация пакета

  • Нужно ждать, пока пакет обновится в проектах

  • Версионирование превращается в боль

  • Локальная разработка через npm link работает через раз

Вариант 3: Monorepo

А вот это уже интересно.

Что такое monorepo

Monorepo — это когда несколько связанных проектов живут в одном репозитории и могут легко переиспользовать код друг друга.

Структура выглядит примерно так:

my-app/
├── packages/
│   ├── frontend/     # React + Vite
│   ├── backend/      # Node.js + Express
│   └── shared/       # Общий код
└── package.json

И вот что меняется к лучшему:

Единственный источник истины

Описываете тип пользователя один раз в shared:

// shared/src/types/user.ts
export interface User {
  id: string;
  email: string;
  role: 'admin' | 'user';
  createdAt: Date;
}

Используете везде:

// frontend/src/App.tsx
import { User } from 'shared';

// backend/src/controllers/user.ts
import { User } from 'shared';

Изменили тип — изменения сразу видны и на фронте, и на бэке. TypeScript не даст собрать проект, если что-то несовместимо.

Zod-схемы для валидации

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

// shared/src/schemas/auth.ts
import { z } from 'zod';

export const registerSchema = z.object({
  email: z.string().email('Некорректный email'),
  password: z.string().min(8, 'Минимум 8 символов'),
  name: z.string().min(2, 'Минимум 2 символа'),
});

export type RegisterDto = z.infer<typeof registerSchema>;

На фронте используете для валидации формы (react-hook-form + zod):

import { registerSchema } from 'shared';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';

const form = useForm({
  resolver: zodResolver(registerSchema)
});

На бэке — для валидации входящих данных:

import { registerSchema } from 'shared';

app.post('/register', (req, res) => {
  const result = registerSchema.safeParse(req.body);
  if (!result.success) {
    return res.status(400).json(result.error);
  }
  // ...
});

Одна схема, одни правила валидации, одни сообщения об ошибках. Везде.

Константы и энамы

// shared/src/constants/orders.ts
export enum OrderStatus {
  PENDING = 'pending',
  PROCESSING = 'processing',
  SHIPPED = 'shipped',
  DELIVERED = 'delivered',
  CANCELLED = 'cancelled',
}

export const ORDER_STATUS_LABELS: Record<OrderStatus, string> = {
  [OrderStatus.PENDING]: 'Ожидает обработки',
  [OrderStatus.PROCESSING]: 'В обработке',
  [OrderStatus.SHIPPED]: 'Отправлен',
  [OrderStatus.DELIVERED]: 'Доставлен',
  [OrderStatus.CANCELLED]: 'Отменён',
};

И на фронте, и на бэке используются одни и те же значения. Невозможно случайно написать 'pending' на фронте и 'Pending' на бэке.

Как это работает на практике

Существуют готовые шаблоны, которые уже настроены под этот подход. Обычно они используют pnpm workspaces для управления монорепо. Почему pnpm? Он быстрый, эффективно использует дисковое пространство, и у него отличная поддержка workspaces.

Например, можно быстро развернуть проект командой:

npm create fullstack-monorepo my-app
cd my-app
pnpm install

Как устроен shared-пакет

Это сердце всей системы. Обычно там есть автоматическая генерация индексного файла — скрипт обходит все файлы в src/ и автоматически создаёт экспорты в index.ts.

То есть вы просто создаёте файл в shared/src/types/product.ts, и он автоматически становится доступен через import { ... } from 'shared'. Не нужно помнить про ручные экспорты.

Для разработки запускается watch-режим:

"watch:index": "chokidar \"src/**/*.ts\" -c \"pnpm generate:index\""

Создали новый файл — индекс пересобрался. Всё работает с горячей перезагрузкой.

Запуск в разработке

Обычно есть удобные команды:

# Фронтенд с автообновлением shared
pnpm run dev:frontend

# Бэкенд с автообновлением shared  
pnpm run dev:backend

Обе команды используют concurrently, чтобы одновременно запустить watch для shared и dev-сервер:

"dev:frontend": "concurrently -n shared,frontend \"pnpm --filter shared watch:index\" \"pnpm --filter frontend dev\""

Меняете код в shared — изменения мгновенно подхватываются и фронтом, и бэком.

Реальные примеры использования

API-контракты

// shared/src/api/user.ts
export interface GetUsersResponse {
  users: User[];
  total: number;
  page: number;
}

export interface CreateUserRequest {
  email: string;
  name: string;
  role: UserRole;
}

Теперь и фронтенд, и бэкенд знают точный формат запросов и ответов. IDE подсказывает поля, TypeScript ругается на несоответствия.

Утилиты для работы с данными

// shared/src/utils/date.ts
export function formatDate(date: Date): string {
  return new Intl.DateTimeFormat('ru-RU').format(date);
}

export function isDateInPast(date: Date): boolean {
  return date < new Date();
}

Одна реализация, одинаковое поведение на клиенте и сервере.

Бизнес-логика

// shared/src/utils/pricing.ts
export function calculateDiscount(
  price: number, 
  userRole: UserRole
): number {
  const discounts = {
    admin: 0,
    premium: 0.15,
    user: 0.05,
  };
  
  return price * (1 - discounts[userRole]);
}

Цена считается одинаково и когда показываете её пользователю, и когда создаёте заказ на сервере.

Что можно добавить

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

  • ESLint/Prettier — общие настройки для всего монорепо

  • Тесты — Jest или Vitest для shared-пакета

  • CI/CD — GitHub Actions для автоматической сборки

  • Docker — контейнеризация для деплоя

  • Turborepo или Nx — для умной пересборки только изменённых пакетов

Но для старта базового набора вполне достаточно.

Альтернативы

Есть и другие решения для организации монорепо:

  • Nx — мощный, но сложный для начала

  • Turborepo — отличный инструмент, но требует настройки

  • Lerna — старичок, сейчас менее актуален

  • Rush — для очень больших монорепо

Простые шаблоны без лишних зависимостей — это решение на базе чистых pnpm workspaces и пары скриптов. Легко понять, легко настроить под себя.

Заключение

Monorepo — это не серебряная пуля, но для fullstack-разработки с общим кодом это очень удобно. Экономите время, уменьшаете количество багов и делаете код более поддерживаемым.

Если устали синхронизировать типы, валидацию и утилиты между фронтом и бэком — попробуйте подход с monorepo. Возможно, ваша жизнь станет чуточку проще.


P.S. А как вы решаете проблему переиспользования кода между фронтендом и бэкендом? Поделитесь в комментариях!

Комментарии (5)


  1. Rive
    29.10.2025 11:53

    Почему pnpm? 

    Я не думаю, что после 2022 года это безопасно при работе с ru-локалью. Один из мэйнтейнеров выразил вполне недвусмысленную позицию посредством геоблока на сайте менеджера установки и я вполне допускаю, что он может подсунуть троян.


    1. artem23112 Автор
      29.10.2025 11:53

      Понимаю, откуда взялось это опасение, но фактически оно не подтверждается.
      pnpm — полностью open-source, код открыт и виден всем, а любые изменения проходят публичный аудит. Если бы кто-то действительно добавил туда что-то вредоносное, это мгновенно стало бы заметно.

      Геоблокировка сайта pnpm.io действительно была, но она никак не влияет на сам инструмент — установка и обновления идут через npm-registry, где пакеты подписаны и проверяются по хэшам.

      Короче, технических оснований считать pnpm «троянским» нет. Это скорее вопрос отношения к поступку мейнтейнера, а не вопрос безопасности самого менеджера пакетов.


  1. savostin
    29.10.2025 11:53

    Не дай бог у вас бэк на Go или прости господи на Python. Что тогда?

    Кстати забыли еще на бэке синхронизацию со схемой базы. Тоже боль.


  1. Bone
    29.10.2025 11:53

    Я как-то раз попробовал, но у меня не завелось. У фронта и бека были разные tsconfig и из-за этого у меня толком ничего не работало.


    1. artem23112 Автор
      29.10.2025 11:53

      Там новая версия вышла, должна заводиться