API-дизайн: REST, GraphQL или tRPC - что выбрать.

Сравниваем три подхода к проектированию API: REST, GraphQL и tRPC. Реальный код, плюсы и минусы, и когда какой подход выбрать.

Что такое API и зачем оно нужно

Представьте ресторан. Вы (клиент) сидите за столиком. На кухне - повара (сервер). Между вами - официант. Вы не идёте на кухню сами, а говорите официанту: "Мне, пожалуйста, борщ и чай". Официант передаёт заказ на кухню, кухня готовит, официант приносит вам еду.

API (Application Programming Interface) - это тот самый "официант" в мире программирования. Это набор правил, по которым одна программа общается с другой. Ваше мобильное приложение хочет показать список товаров? Оно отправляет запрос на API сервера, сервер достаёт товары из базы данных и отправляет их обратно.

Но вот вопрос: по каким именно правилам общаться? Как формулировать запросы? В каком формате получать ответы? Для этого придумали несколько подходов. Три самых популярных в 2026 году - это REST, GraphQL и tRPC. Давайте разберём каждый.

REST - "классика жанра"

REST (Representational State Transfer) - это самый распространённый подход к созданию API. Ему уже больше 20 лет, и он до сих пор работает отлично.

Идея REST простая: каждый "ресурс" (пользователь, товар, заказ) имеет свой адрес (URL), а действия с ним выполняются через HTTP-методы:

  • GET - получить данные (как вопрос "покажи мне список товаров")
  • POST - создать новые данные (как "добавь новый товар")
  • PUT - обновить существующие данные ("измени цену товара #5")
  • DELETE - удалить данные ("удали товар #5")
// REST API на Node.js + Express
// Express - это библиотека для создания веб-серверов на JavaScript

// Подключаем Express
import express from 'express'
const app = express()

// Говорим Express понимать JSON в теле запроса
// Без этой строки req.body будет undefined
app.use(express.json())

// Имитация базы данных (в реальном проекте здесь будет PostgreSQL или MySQL)
// В реальности товары хранятся в базе данных, не в переменной
let products = [
  { id: 1, name: 'Ноутбук', price: 79990, category: 'электроника' },
  { id: 2, name: 'Наушники', price: 4990, category: 'электроника' },
  { id: 3, name: 'Книга "JavaScript"', price: 890, category: 'книги' },
]

// --- GET /api/products - получить список всех товаров ---
// Когда кто-то отправляет GET-запрос на /api/products,
// выполняется эта функция
app.get('/api/products', (req, res) => {
  // req.query - параметры из URL (после знака ?)
  // Например: /api/products?category=электроника&sort=price
  const { category, sort, limit } = req.query

  let result = [...products]  // Копируем массив (чтобы не менять оригинал)

  // Фильтрация по категории (если указана)
  if (category) {
    result = result.filter(p => p.category === category)
  }

  // Сортировка (если указана)
  if (sort === 'price') {
    result.sort((a, b) => a.price - b.price)
  }

  // Ограничение количества (если указано)
  if (limit) {
    result = result.slice(0, parseInt(limit))
  }

  // res.json() - отправляем ответ в формате JSON
  res.json({
    data: result,            // Массив товаров
    total: result.length,    // Сколько товаров в ответе
  })
})

// --- GET /api/products/:id - получить один товар по ID ---
// :id - это "параметр пути". Если URL = /api/products/5, то req.params.id = "5"
app.get('/api/products/:id', (req, res) => {
  // parseInt превращает строку "5" в число 5
  const id = parseInt(req.params.id)

  // Ищем товар с нужным ID
  const product = products.find(p => p.id === id)

  if (!product) {
    // 404 = "не найдено"
    // Это стандартный HTTP-код ошибки
    return res.status(404).json({ error: 'Товар не найден' })
  }

  res.json({ data: product })
})

// --- POST /api/products - создать новый товар ---
// POST-запросы используются для создания новых данных
app.post('/api/products', (req, res) => {
  // req.body - данные, которые отправил клиент в теле запроса
  // Например: { "name": "Мышка", "price": 1990, "category": "электроника" }
  const { name, price, category } = req.body

  // Проверяем, что все обязательные поля заполнены
  if (!name || !price) {
    // 400 = "плохой запрос" (клиент отправил неправильные данные)
    return res.status(400).json({ error: 'Укажите name и price' })
  }

  // Создаём новый товар
  const newProduct = {
    id: products.length + 1,  // Генерируем ID (в реальности это делает БД)
    name,
    price: parseFloat(price), // Убеждаемся, что цена - число
    category: category || 'без категории',
  }

  products.push(newProduct)  // Добавляем в "базу данных"

  // 201 = "создано" - стандартный код для успешного создания
  res.status(201).json({ data: newProduct })
})

// --- PUT /api/products/:id - обновить товар ---
app.put('/api/products/:id', (req, res) => {
  const id = parseInt(req.params.id)
  const index = products.findIndex(p => p.id === id)

  if (index === -1) {
    return res.status(404).json({ error: 'Товар не найден' })
  }

  // Обновляем только те поля, которые были отправлены
  // Если клиент отправил только { price: 5990 }, name не изменится
  products[index] = { ...products[index], ...req.body }

  res.json({ data: products[index] })
})

// --- DELETE /api/products/:id - удалить товар ---
app.delete('/api/products/:id', (req, res) => {
  const id = parseInt(req.params.id)
  const index = products.findIndex(p => p.id === id)

  if (index === -1) {
    return res.status(404).json({ error: 'Товар не найден' })
  }

  products.splice(index, 1)  // Удаляем из массива

  // 204 = "нет содержимого" - успешно удалено, нечего возвращать
  res.status(204).send()
})

// Запускаем сервер на порту 3000
app.listen(3000, () => {
  console.log('REST API сервер запущен: http://localhost:3000')
})

Как пользоваться этим API? Вот примеры запросов:

// Примеры запросов к REST API из браузера (JavaScript)

// 1. Получить все товары
const response = await fetch('/api/products')
const data = await response.json()
console.log(data)
// { data: [{id: 1, name: "Ноутбук", ...}, ...], total: 3 }

// 2. Получить товары только из категории "электроника"
const electronics = await fetch('/api/products?category=электроника')

// 3. Получить один товар по ID
const product = await fetch('/api/products/1')

// 4. Создать новый товар
const newProduct = await fetch('/api/products', {
  method: 'POST',                          // Метод - POST (создание)
  headers: { 'Content-Type': 'application/json' }, // Формат данных - JSON
  body: JSON.stringify({                    // Данные нового товара
    name: 'Мышка беспроводная',
    price: 1990,
    category: 'электроника'
  })
})

// 5. Обновить цену товара
await fetch('/api/products/1', {
  method: 'PUT',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ price: 74990 })   // Меняем только цену
})

// 6. Удалить товар
await fetch('/api/products/3', { method: 'DELETE' })

Проблемы REST

REST отлично работает для простых случаев, но у него есть два известных недостатка:

Проблема 1: Избыточные данные (overfetching)

Допустим, вам нужно показать список товаров - только название и цену. Но API возвращает ВСЕ поля: id, name, price, category, description, images, reviews, specifications... Вы получаете в 10 раз больше данных, чем нужно.

Проблема 2: Множественные запросы (underfetching)

Допустим, вы открываете страницу заказа. Вам нужно: данные заказа + данные покупателя + список товаров в заказе. В REST это три отдельных запроса:

// Три запроса для одной страницы - это медленно!
// Каждый запрос - это "путешествие" данных через интернет
const order = await fetch('/api/orders/123')
const customer = await fetch('/api/customers/456')
const items = await fetch('/api/orders/123/items')

Эти проблемы и привели к созданию GraphQL.

GraphQL - запрашиваем только то, что нужно

GraphQL (Graph Query Language) придумали в Facebook в 2012 году. Их мобильное приложение отправляло кучу запросов и получало горы ненужных данных. Они решили: "А что если клиент сам будет указывать, какие данные ему нужны?"

Аналогия: REST - это меню ресторана с фиксированными блюдами. GraphQL - это шведский стол, где вы сами набираете то, что хотите.

// GraphQL - серверная часть
// Устанавливаем: npm install @apollo/server graphql

import { ApolloServer } from '@apollo/server'
import { startStandaloneServer } from '@apollo/server/standalone'

// Схема - описание всех типов данных и операций
// Это как "меню" вашего API: что можно запросить и что получить
const typeDefs = `#graphql
  # Тип "Товар" - описывает структуру данных товара
  # Каждое поле имеет тип: String (текст), Int (целое число), Float (дробное)
  # ! означает "обязательное поле" (не может быть null)
  type Product {
    id: ID!              # Уникальный идентификатор (обязательный)
    name: String!        # Название (обязательное)
    price: Float!        # Цена (обязательная)
    category: String     # Категория (необязательная, может быть null)
    description: String  # Описание
    reviews: [Review!]   # Список отзывов (массив объектов Review)
  }

  # Тип "Отзыв"
  type Review {
    id: ID!
    text: String!
    rating: Int!       # Оценка от 1 до 5
    author: String!
  }

  # Тип "Заказ" - содержит товары и информацию о покупателе
  type Order {
    id: ID!
    customer: Customer!
    items: [OrderItem!]!
    total: Float!
    status: String!
  }

  type Customer {
    id: ID!
    name: String!
    email: String!
  }

  type OrderItem {
    product: Product!
    quantity: Int!
  }

  # Query - запросы на ЧТЕНИЕ данных (аналог GET в REST)
  type Query {
    products(category: String, limit: Int): [Product!]!
    product(id: ID!): Product
    order(id: ID!): Order
  }

  # Mutation - запросы на ИЗМЕНЕНИЕ данных (аналог POST/PUT/DELETE в REST)
  type Mutation {
    createProduct(name: String!, price: Float!, category: String): Product!
    updateProduct(id: ID!, name: String, price: Float): Product
    deleteProduct(id: ID!): Boolean!
  }
`

// Резолверы - функции, которые обрабатывают запросы
// Для каждого поля в схеме нужен резолвер
const resolvers = {
  Query: {
    // Запрос списка товаров
    products: (_, { category, limit }) => {
      let result = [...products]
      if (category) result = result.filter(p => p.category === category)
      if (limit) result = result.slice(0, limit)
      return result
    },

    // Запрос одного товара
    product: (_, { id }) => products.find(p => p.id === id),

    // Запрос заказа (со всеми связанными данными)
    order: async (_, { id }) => {
      const order = await db.query('SELECT * FROM orders WHERE id = ?', [id])
      return order
    },
  },

  // Резолвер для поля "reviews" в типе Product
  // Вызывается ТОЛЬКО если клиент запросил отзывы
  Product: {
    reviews: (product) => {
      return db.query('SELECT * FROM reviews WHERE product_id = ?', [product.id])
    }
  },

  // Резолвер для поля "customer" в типе Order
  Order: {
    customer: (order) => {
      return db.query('SELECT * FROM customers WHERE id = ?', [order.customerId])
    },
    items: (order) => {
      return db.query('SELECT * FROM order_items WHERE order_id = ?', [order.id])
    },
  },

  Mutation: {
    createProduct: (_, { name, price, category }) => {
      const newProduct = { id: Date.now().toString(), name, price, category }
      products.push(newProduct)
      return newProduct
    },
    deleteProduct: (_, { id }) => {
      const index = products.findIndex(p => p.id === id)
      if (index === -1) return false
      products.splice(index, 1)
      return true
    },
  },
}

// Создаём и запускаем сервер
const server = new ApolloServer({ typeDefs, resolvers })
const { url } = await startStandaloneServer(server, { listen: { port: 4000 } })
console.log(`GraphQL API: ${url}`)

А вот как клиент использует GraphQL:

// Клиент GraphQL - запрашиваем ТОЛЬКО нужные поля
// Все запросы отправляются на один URL: /graphql
// И все через метод POST

// Запрос 1: Только названия и цены товаров (без описаний, отзывов и т.д.)
const response = await fetch('/graphql', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    query: `{
      products {
        name
        price
      }
    }`
  })
})
// Ответ: { data: { products: [{ name: "Ноутбук", price: 79990 }, ...] } }
// Только name и price - ничего лишнего!

// Запрос 2: Страница заказа - ВСЕ данные ОДНИМ запросом!
// Помните, в REST нужно было 3 отдельных запроса?
// В GraphQL - один:
const orderPage = await fetch('/graphql', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    query: `{
      order(id: "123") {
        id
        status
        total
        customer {
          name
          email
        }
        items {
          quantity
          product {
            name
            price
          }
        }
      }
    }`
  })
})
// Один запрос - все данные для страницы!
// Сервер сам соберёт информацию из разных таблиц

tRPC - типобезопасность от клиента до сервера

tRPC (TypeScript Remote Procedure Call) - самый молодой из трёх подходов. Он появился в мире TypeScript и решает проблему, которая знакома каждому разработчику: клиент и сервер "не знают" друг о друге.

Аналогия: REST и GraphQL - это как общение по телефону. Вы надеетесь, что собеседник вас правильно понял. tRPC - это как телепатия: вы ТОЧНО знаете, что сервер ожидает и что вернёт, потому что типы данных общие.

// tRPC - серверная часть
// Устанавливаем: npm install @trpc/server @trpc/client zod

import { initTRPC } from '@trpc/server'
import { z } from 'zod'  // Zod - библиотека для проверки данных

// Инициализируем tRPC
const t = initTRPC.create()

// Создаём "роутер" - набор всех функций API
const appRouter = t.router({
  // "products" - группа функций для работы с товарами
  products: t.router({
    // Получить список товаров
    // .input() описывает, какие параметры принимает функция
    // z.object() - это "схема" входных данных
    list: t.procedure
      .input(z.object({
        category: z.string().optional(), // category - необязательный текст
        limit: z.number().min(1).max(100).optional(), // limit - число от 1 до 100
      }).optional())
      .query(async ({ input }) => {
        // .query() - означает "чтение данных" (аналог GET)
        // input - проверенные данные (если не прошли проверку - ошибка автоматически)
        let result = await db.products.findMany()

        if (input?.category) {
          result = result.filter(p => p.category === input.category)
        }
        if (input?.limit) {
          result = result.slice(0, input.limit)
        }

        return result
      }),

    // Получить один товар
    byId: t.procedure
      .input(z.object({
        id: z.string()  // id - обязательная строка
      }))
      .query(async ({ input }) => {
        const product = await db.products.findUnique({ where: { id: input.id } })
        if (!product) throw new Error('Товар не найден')
        return product
      }),

    // Создать товар
    create: t.procedure
      .input(z.object({
        name: z.string().min(1).max(200),   // Название: от 1 до 200 символов
        price: z.number().positive(),        // Цена: положительное число
        category: z.string().optional(),
      }))
      .mutation(async ({ input }) => {
        // .mutation() - означает "изменение данных" (аналог POST/PUT/DELETE)
        return await db.products.create({ data: input })
      }),

    // Удалить товар
    delete: t.procedure
      .input(z.object({ id: z.string() }))
      .mutation(async ({ input }) => {
        await db.products.delete({ where: { id: input.id } })
        return { success: true }
      }),
  }),
})

// Экспортируем ТИП роутера - это ключевая фича tRPC!
// Клиент импортирует этот тип и ЗНАЕТ все функции API, их параметры и ответы
export type AppRouter = typeof appRouter
// tRPC - клиентская часть (React)
// МАГИЯ: клиент знает ВСЕ функции сервера, их параметры и типы ответов!
// Если сервер изменит API - клиент покажет ошибку ещё ДО запуска кода

import { createTRPCReact } from '@trpc/react-query'
import type { AppRouter } from '../server/router'  // Импорт ТИПА с сервера

// Создаём типизированный клиент
const trpc = createTRPCReact()

function ProductList() {
  // trpc.products.list.useQuery() - вызов функции сервера
  // TypeScript знает, что data - это массив товаров с полями id, name, price, ...
  // Автокомплит работает! Если ошибётесь в имени поля - увидите ошибку в редакторе
  const { data, isLoading, error } = trpc.products.list.useQuery({
    category: 'электроника',
    limit: 10
  })

  if (isLoading) return 
Загрузка...
if (error) return
Ошибка: {error.message}
return (
    {data.map(product => ( // TypeScript знает, что у product есть поля name и price // Если напишете product.nmae - увидите ошибку!
  • {product.name} - {product.price} руб.
  • ))}
) } function CreateProductForm() { // useMutation - для создания/изменения данных const createProduct = trpc.products.create.useMutation() const handleSubmit = (formData) => { // TypeScript проверит, что вы передали все нужные поля // Если забудете price - редактор покажет ошибку ещё до запуска createProduct.mutate({ name: formData.name, price: parseFloat(formData.price), category: formData.category, }) } // ... }

Сравнение: что когда использовать

КритерийRESTGraphQLtRPC
Сложность настройкиПростаяСредняяПростая
ТипобезопасностьНет (нужна отдельная генерация)Частичная (codegen)Полная (автоматическая)
Гибкость запросовФиксированная структураКлиент выбирает поляФиксированная структура
Публичный APIОтлично подходитОтлично подходитНе подходит (только TypeScript)
ПроизводительностьХорошаяХорошая (но сложнее оптимизировать)Отличная (минимум overhead)
ЭкосистемаОгромнаяБольшаяРастущая
КэшированиеВстроенное (HTTP-кэш)Сложнее (один URL)Через React Query

Конкретные рекомендации

Выбирайте REST, если:

  • Ваш API будет использоваться внешними разработчиками (публичный API)
  • Команда не знакома с GraphQL или tRPC
  • Простое CRUD-приложение без сложных связей между данными
  • Нужна максимальная совместимость (REST работает с любым языком)
  • Хотите использовать HTTP-кэширование

Выбирайте GraphQL, если:

  • Много связанных данных (пользователи, заказы, товары, отзывы - всё связано)
  • Разные клиенты (мобильное приложение, веб-сайт, десктоп) нуждаются в разных наборах данных
  • Проблема N+1 запросов (одна страница требует 5-10 REST-запросов)
  • Публичный API с богатой структурой данных

Выбирайте tRPC, если:

  • И клиент, и сервер написаны на TypeScript
  • Внутренний API (не публичный)
  • Хотите максимальную типобезопасность и автокомплит
  • Next.js или другой TypeScript-фреймворк
  • Маленькая команда, где все пишут и фронт, и бэк

Версионирование API: как обновлять, не ломая

Когда API используют другие разработчики (или ваше мобильное приложение), нельзя просто взять и изменить формат ответа - всё сломается. Для этого используют версионирование:

// REST: версионирование через URL
// Старая версия API остаётся работать
// Новая живёт по другому адресу

// Версия 1 (старая, для существующих клиентов)
app.get('/api/v1/products', (req, res) => {
  // Возвращает данные в старом формате
  res.json(products.map(p => ({
    id: p.id,
    name: p.name,
    price: p.price,          // Цена в рублях (число)
  })))
})

// Версия 2 (новая, с улучшениями)
app.get('/api/v2/products', (req, res) => {
  // Возвращает данные в новом формате
  res.json(products.map(p => ({
    id: p.id,
    name: p.name,
    price: {
      amount: p.price,         // Цена (число)
      currency: 'RUB',         // Валюта (строка)
      formatted: `${p.price} руб.`  // Отформатированная цена
    },
    // Новое поле, которого не было в v1
    images: p.images || [],
  })))
})

// Клиенты старой версии продолжают работать
// Новые клиенты используют v2 с расширенными данными

Обработка ошибок: как правильно

Хорошее API всегда возвращает понятные ошибки. Не просто "Ошибка", а конкретно: что пошло не так и что делать.

// Правильная обработка ошибок в REST API

// Коды ошибок HTTP (должен знать каждый):
// 200 - Всё хорошо (OK)
// 201 - Создано (Created)
// 204 - Удалено, нечего возвращать (No Content)
// 400 - Плохой запрос (клиент отправил что-то не то)
// 401 - Не авторизован (не залогинен)
// 403 - Запрещено (залогинен, но нет прав)
// 404 - Не найдено
// 429 - Слишком много запросов (rate limit)
// 500 - Ошибка сервера (наша проблема)

// Пример: единообразная обработка ошибок
app.post('/api/orders', async (req, res) => {
  try {
    const { productId, quantity } = req.body

    // Проверяем входные данные
    if (!productId) {
      return res.status(400).json({
        error: 'MISSING_FIELD',           // Код ошибки (для программы)
        message: 'Укажите productId',     // Описание (для человека)
        field: 'productId',               // Какое поле проблемное
      })
    }

    // Проверяем, что товар существует
    const product = await db.products.findById(productId)
    if (!product) {
      return res.status(404).json({
        error: 'NOT_FOUND',
        message: `Товар с ID ${productId} не найден`,
      })
    }

    // Проверяем наличие на складе
    if (product.stock < quantity) {
      return res.status(400).json({
        error: 'OUT_OF_STOCK',
        message: `На складе только ${product.stock} шт., вы запросили ${quantity}`,
      })
    }

    // Всё в порядке - создаём заказ
    const order = await db.orders.create({ productId, quantity })
    res.status(201).json({ data: order })

  } catch (error) {
    // Непредвиденная ошибка - логируем и возвращаем 500
    console.error('Ошибка при создании заказа:', error)
    res.status(500).json({
      error: 'INTERNAL_ERROR',
      message: 'Произошла ошибка на сервере. Попробуйте позже.',
    })
  }
})

Итого

REST, GraphQL и tRPC - это три подхода к одной задаче: как клиенту и серверу обмениваться данными. REST - проверенная классика, которая подходит для большинства случаев. GraphQL - гибкий инструмент для сложных приложений с множеством связей. tRPC - идеальный выбор для TypeScript-проектов, где и фронтенд, и бэкенд пишет одна команда.

Для большинства проектов начните с REST - он прост, понятен и хорошо документирован. Переходите на GraphQL или tRPC, когда REST начнёт мешать (много запросов на одну страницу, проблемы с типами, сложные связи данных). Нужна помощь с проектированием API для вашего проекта? Напишите нам - спроектируем архитектуру, которая будет масштабироваться вместе с вашим бизнесом.

Все статьи