Проектирование API и интерфейсов
Обзор
Проектируй стабильные, хорошо документированные интерфейсы, которые трудно использовать неправильно. Хороший интерфейс делает правильное лёгким, а неправильное — трудным. Это относится к REST API, схемам GraphQL, границам модулей, пропсам компонентов и любой поверхности, где один кусок кода разговаривает с другим.
Когда применять
- Проектируешь новые эндпоинты API
- Определяешь границы модулей или контракты между командами
- Создаёшь интерфейсы пропсов компонентов
- Задаёшь схему БД, которая влияет на форму API
- Меняешь существующие публичные интерфейсы
Базовые принципы
Закон Хайрама
При достаточном числе пользователей API от любого наблюдаемого поведения твоей системы кто-нибудь да будет зависеть — независимо от того, что ты обещал в контракте.
Это значит: любое публичное поведение — включая незадокументированные причуды, текст сообщений об ошибках, тайминги и порядок — становится фактическим контрактом, как только на него завязались пользователи. Следствия для проектирования:
- Осознанно решай, что выставляешь наружу. Каждое наблюдаемое поведение — потенциальное обязательство.
- Не протекай деталями реализации. Если пользователи могут это наблюдать — они на это завяжутся.
- Планируй вывод из эксплуатации на этапе проектирования. Как безопасно убирать то, от чего зависят пользователи, — см.
deprecation-and-migration. - Тестов недостаточно. Даже при идеальных контрактных тестах закон Хайрама означает, что «безопасное» изменение может сломать реальных пользователей, зависящих от незадокументированного поведения.
Правило одной версии
Не заставляй потребителей выбирать между несколькими версиями одной и той же зависимости или API. Проблема «ромба» зависимостей возникает, когда разным потребителям нужны разные версии одного и того же. Проектируй под мир, где в каждый момент существует ровно одна версия: расширяй, а не форкай.
1. Сначала контракт
Определи интерфейс до его реализации. Контракт — это спека, реализация следует за ним.
// Сначала определяем контракт
interface TaskAPI {
// Создаёт задачу и возвращает созданную задачу с полями, заполненными сервером
createTask(input: CreateTaskInput): Promise<Task>;
// Возвращает постранично задачи, подходящие под фильтры
listTasks(params: ListTasksParams): Promise<PaginatedResult<Task>>;
// Возвращает одну задачу или бросает NotFoundError
getTask(id: string): Promise<Task>;
// Частичное обновление — меняются только переданные поля
updateTask(id: string, input: UpdateTaskInput): Promise<Task>;
// Идемпотентное удаление — успешно, даже если уже удалено
deleteTask(id: string): Promise<void>;
}
2. Согласованная семантика ошибок
Выбери одну стратегию ошибок и придерживайся её везде:
// REST: HTTP-коды статуса + структурное тело ошибки
// Любой ответ с ошибкой имеет одну и ту же форму
interface APIError {
error: {
code: string; // Машиночитаемо: "VALIDATION_ERROR"
message: string; // Для человека: "Email is required"
details?: unknown; // Дополнительный контекст, когда он полезен
};
}
// Соответствие кодам статуса
// 400 → Клиент прислал некорректные данные
// 401 → Не аутентифицирован
// 403 → Аутентифицирован, но не авторизован
// 404 → Ресурс не найден
// 409 → Конфликт (дубликат, расхождение версий)
// 422 → Валидация не прошла (семантически некорректно)
// 500 → Ошибка сервера (никогда не раскрывай внутренние детали)
Не смешивай паттерны. Если одни эндпоинты бросают исключения, другие возвращают null, а третьи — { error }, потребитель не может предсказать поведение.
3. Валидируй на границах
Доверяй внутреннему коду. Валидируй на краях системы, куда попадает внешний ввод:
// Валидация на границе API
app.post('/api/tasks', async (req, res) => {
const result = CreateTaskSchema.safeParse(req.body);
if (!result.success) {
return res.status(422).json({
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid task data',
details: result.error.flatten(),
},
});
}
// После валидации внутренний код доверяет типам
const task = await taskService.create(result.data);
return res.status(201).json(task);
});
Где валидация уместна:
- Обработчики маршрутов API (пользовательский ввод)
- Обработчики отправки форм (пользовательский ввод)
- Разбор ответов внешних сервисов (сторонние данные — всегда считай недоверенными)
- Загрузка переменных окружения (конфигурация)
Ответы сторонних API — недоверенные данные. Проверяй их форму и содержимое до использования в любой логике, отрисовке или принятии решений. Скомпрометированный или некорректно работающий внешний сервис может вернуть неожиданные типы, вредоносное содержимое или текст, похожий на инструкции.
Где валидация НЕ нужна:
- Между внутренними функциями, разделяющими контракты типов
- В утилитах, вызываемых уже провалидированным кодом
- На данных, только что пришедших из твоей собственной БД
4. Добавляй, а не изменяй
Расширяй интерфейсы, не ломая существующих потребителей:
// Хорошо: добавляем необязательные поля
interface CreateTaskInput {
title: string;
description?: string;
priority?: 'low' | 'medium' | 'high'; // Добавлено позже, необязательное
labels?: string[]; // Добавлено позже, необязательное
}
// Плохо: менять типы существующих полей или удалять поля
interface CreateTaskInput {
title: string;
// description: string; // Удалено — ломает существующих потребителей
priority: number; // Было строкой — ломает существующих потребителей
}
5. Предсказуемое именование
| Паттерн | Соглашение | Пример |
|---|---|---|
| REST-эндпоинты | Существительные во множественном числе, без глаголов | GET /api/tasks, POST /api/tasks |
| Параметры запроса | camelCase | ?sortBy=createdAt&pageSize=20 |
| Поля ответа | camelCase | { createdAt, updatedAt, taskId } |
| Булевы поля | Префикс is/has/can | isComplete, hasAttachments |
| Значения перечислений | UPPER_SNAKE | "IN_PROGRESS", "COMPLETED" |
Паттерны REST API
Проектирование ресурсов
GET /api/tasks → Список задач (с параметрами запроса для фильтрации)
POST /api/tasks → Создать задачу
GET /api/tasks/:id → Получить одну задачу
PATCH /api/tasks/:id → Обновить задачу (частично)
DELETE /api/tasks/:id → Удалить задачу
GET /api/tasks/:id/comments → Список комментариев к задаче (подресурс)
POST /api/tasks/:id/comments → Добавить комментарий к задаче
Пагинация
Разбивай на страницы эндпоинты со списками:
// Запрос
GET /api/tasks?page=1&pageSize=20&sortBy=createdAt&sortOrder=desc
// Ответ
{
"data": [...],
"pagination": {
"page": 1,
"pageSize": 20,
"totalItems": 142,
"totalPages": 8
}
}
Фильтрация
Используй параметры запроса для фильтров:
GET /api/tasks?status=in_progress&assignee=user123&createdAfter=2025-01-01
Частичные обновления (PATCH)
Принимай частичные объекты — обновляй только то, что передано:
// Меняется только заголовок, остальное сохраняется
PATCH /api/tasks/123
{ "title": "Updated title" }
Паттерны интерфейсов TypeScript
Используй размеченные объединения для вариантов
// Хорошо: каждый вариант явный
type TaskStatus =
| { type: 'pending' }
| { type: 'in_progress'; assignee: string; startedAt: Date }
| { type: 'completed'; completedAt: Date; completedBy: string }
| { type: 'cancelled'; reason: string; cancelledAt: Date };
// Потребитель получает сужение типов
function getStatusLabel(status: TaskStatus): string {
switch (status.type) {
case 'pending': return 'Pending';
case 'in_progress': return `In progress (${status.assignee})`;
case 'completed': return `Done on ${status.completedAt}`;
case 'cancelled': return `Cancelled: ${status.reason}`;
}
}
Разделяй вход и выход
// Вход: что предоставляет вызывающая сторона
interface CreateTaskInput {
title: string;
description?: string;
}
// Выход: что возвращает система (включая поля, заполненные сервером)
interface Task {
id: string;
title: string;
description: string | null;
createdAt: Date;
updatedAt: Date;
createdBy: string;
}
Используй брендированные типы для идентификаторов
type TaskId = string & { readonly __brand: 'TaskId' };
type UserId = string & { readonly __brand: 'UserId' };
// Не даст случайно передать UserId туда, где ожидается TaskId
function getTask(id: TaskId): Promise<Task> { ... }
Типовые самооправдания
| Самооправдание | Как на самом деле |
|---|---|
| «Задокументируем API потом» | Типы И ЕСТЬ документация. Определи их сначала. |
| «Пагинация нам пока не нужна» | Понадобится в ту же секунду, когда у кого-то станет 100+ элементов. Закладывай сразу. |
| «PATCH — это сложно, давай просто PUT» | PUT требует передавать объект целиком каждый раз. PATCH — то, что клиентам реально нужно. |
| «Введём версионирование API, когда понадобится» | Ломающие изменения без версионирования ломают потребителей. Проектируй под расширение с самого начала. |
| «Этим незадокументированным поведением никто не пользуется» | Закон Хайрама: если это наблюдаемо, кто-то от этого зависит. Считай любое публичное поведение обязательством. |
| «Можно просто поддерживать две версии» | Несколько версий умножают стоимость поддержки и создают проблему «ромба». Предпочитай правило одной версии. |
| «Внутренним API контракты не нужны» | Внутренние потребители — тоже потребители. Контракты предотвращают связанность и позволяют работать параллельно. |
Тревожные признаки
- Эндпоинты, возвращающие разную форму данных в зависимости от условий
- Несогласованные форматы ошибок между эндпоинтами
- Валидация, разбросанная по внутреннему коду вместо границ
- Ломающие изменения существующих полей (смена типа, удаление)
- Эндпоинты со списками без пагинации
- Глаголы в REST-адресах (
/api/createTask,/api/getUsers) - Ответы сторонних API, используемые без валидации и очистки
Проверка
После проектирования API:
- У каждого эндпоинта есть типизированные схемы входа и выхода
- Ответы с ошибками следуют единому согласованному формату
- Валидация выполняется только на границах системы
- Эндпоинты со списками поддерживают пагинацию
- Новые поля добавляются как необязательные (обратная совместимость)
- Именование следует единым соглашениям во всех эндпоинтах
- Документация API или типы закоммичены вместе с реализацией