Привет, Хабр! Сегодня хочу поговорить о проблеме, с которой сталкивается каждый фулстек-разработчик, и о том, как её можно элегантно решить.
Проблема, которая всех бесит
Представьте: пишете форму регистрации. На фронте описываете типы для полей формы. На бэке — те же самые типы для валидации. Меняете одно поле — нужно помнить, что надо поменять в двух местах. Забыли обновить на бэке? Получите баг в продакшене.
А теперь добавьте сюда:
Константы (статусы заказов, роли пользователей)
Схемы валидации (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. А как вы решаете проблему переиспользования кода между фронтендом и бэкендом? Поделитесь в комментариях!
