Centralized API layer

В крупных приложениях работа с сервером быстро превращается в источник хаоса:

  • HTTP-запросы разбросаны по компонентам;
  • URL и параметры дублируются;
  • обработка ошибок отличается в разных местах;
  • авторизация реализована неодинаково;
  • логика кэширования смешивается с транспортным уровнем;
  • переход на другой backend требует массовых изменений.

TanStack Query решает задачи синхронизации серверного состояния, но не заменяет архитектуру сетевого слоя. Именно поэтому в современных проектах формируется отдельный centralized API layer — централизованный слой работы с backend.

Основная идея заключается в разделении ответственности:

Слой Ответственность
API layer HTTP, headers, auth, retry, serialization
Query layer caching, refetching, invalidation
UI layer rendering
Domain layer бизнес-логика

Такое разделение особенно важно при масштабировании проекта.


Проблемы прямых запросов внутри queryFn

На ранних этапах разработки часто встречается следующий подход:

useQuery({
    queryKey: ['users'],
    queryFn: async () => {
        const response = await fetch('/api/users');

        if (!response.ok) {
            throw new Error('Request failed');
        }

        return response.json();
    }
});

Подобный код кажется простым, но со временем возникают проблемы:

Дублирование

Во множестве запросов повторяется:

  • base URL;
  • headers;
  • credentials;
  • обработка ошибок;
  • преобразование JSON;
  • timeout;
  • retry.

Смешивание уровней абстракции

Компонент начинает знать:

  • структуру API;
  • детали транспорта;
  • способ авторизации;
  • формат ошибок.

UI постепенно превращается в транспортный слой.


Сложность тестирования

Компоненты с прямыми fetch-вызовами тяжелее тестировать:

render(<UsersPage />);

Тесту приходится мокать:

  • fetch;
  • network;
  • headers;
  • tokens.

При централизованном API достаточно замокать сервис.


Проблемы миграции

Переход:

  • с fetch на axios;
  • REST → GraphQL;
  • monolith → microservices;
  • JWT → cookies;

превращается в массовый рефакторинг.


Архитектурная схема centralized API layer

Типичная структура:

src/
├── api/
│   ├── client/
│   │   ├── httpClient.ts
│   │   ├── auth.ts
│   │   └── interceptors.ts
│   │
│   ├── services/
│   │   ├── users.service.ts
│   │   ├── posts.service.ts
│   │   └── comments.service.ts
│   │
│   ├── dto/
│   ├── mappers/
│   └── errors/
│
├── queries/
├── mutations/
├── hooks/
└── components/

Базовый HTTP-клиент

Центральная точка взаимодействия с сетью обычно представлена единым клиентом.

Вариант на fetch

const API_URL = 'https://api.example.com';

export async function http<T>(
    endpoint: string,
    options?: RequestInit
): Promise<T> {
    const response = await fetch(`${API_URL}${endpoint}`, {
        headers: {
            'Content-Type': 'application/json',
            ...options?.headers
        },
        credentials: 'include',
        ...options
    });

    if (!response.ok) {
        throw new Error(`HTTP error: ${response.status}`);
    }

    return response.json();
}

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


Использование generic-типов

Generic-параметры особенно важны в TypeScript-проектах.

interface User {
    id: number;
    name: string;
}

const users = await http<User[]>('/users');

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

  • автокомплит;
  • безопасный рефакторинг;
  • контроль типов;
  • предсказуемость API.

API-сервисы

Следующий уровень абстракции — сервисы.

users.service.ts

import { http } fr om '../client/httpClient';

export const usersService = {
    getAll() {
        return http<User[]>('/users');
    },

    getById(id: number) {
        return http<User>(`/users/${id}`);
    },

    create(data: CreateUserDto) {
        return http<User>('/users', {
            method: 'POST',
            body: JSON.stringify(data)
        });
    }
};

Теперь компоненты ничего не знают о fetch.


Интеграция с TanStack Query

Плохой вариант

useQuery({
    queryKey: ['users'],
    queryFn: async () => {
        const response = await fetch('/api/users');
        return response.json();
    }
});

Хороший вариант

useQuery({
    queryKey: ['users'],
    queryFn: usersService.getAll
});

Компонент становится декларативным.


Разделение query layer и API layer

Очень важно понимать:

TanStack Query не должен заменять API-слой.

Ошибка:

export function useUsers() {
    return useQuery({
        queryKey: ['users'],
        queryFn: async () => {
            const response = await fetch('/users');
            return response.json();
        }
    });
}

В этом случае:

  • fetch зашит внутрь query logic;
  • переиспользование усложняется;
  • транспорт смешан с caching logic.

Правильная схема

export function useUsers() {
    return useQuery({
        queryKey: ['users'],
        queryFn: usersService.getAll
    });
}

Централизация обработки ошибок

Без централизованного слоя:

if (!response.ok) {
    throw new Error('Failed');
}

дублируется десятки раз.


Выделение собственного класса ошибок

export class ApiError extends Error {
    status: number;

    constructor(message: string, status: number) {
        super(message);

        this.status = status;
    }
}

Использование в HTTP-клиенте

if (!response.ok) {
    throw new ApiError(
        'Request failed',
        response.status
    );
}

Глобальная обработка 401

Одна из ключевых причин централизованного API-слоя — единая авторизация.

if (response.status === 401) {
    logout();
    window.location.href = '/login';
}

Теперь logout происходит автоматически для всех запросов.


Автоматическое добавление токена

const token = localStorage.getItem('token');

headers: {
    Authorization: `Bearer ${token}`
}

Компоненты не должны знать о токенах.


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

Многие проекты используют axios из-за interceptors.

Базовый клиент

import axios from 'axios';

export const api = axios.create({
    baseURL: 'https://api.example.com',
    withCredentials: true
});

Request interceptors

api.interceptors.request.use(config => {
    const token = localStorage.getItem('token');

    if (token) {
        config.headers.Authorization = `Bearer ${token}`;
    }

    return config;
});

Response interceptors

api.interceptors.response.use(
    response => response,
    error => {
        if (error.response?.status === 401) {
            logout();
        }

        return Promise.reject(error);
    }
);

DTO-слой

Серверные модели не всегда должны использоваться напрямую в UI.

DTO

interface UserDto {
    user_id: number;
    full_name: string;
}

UI model

interface User {
    id: number;
    name: string;
}

Mapper-функции

function mapUser(dto: UserDto): User {
    return {
        id: dto.user_id,
        name: dto.full_name
    };
}

Преимущества mapper-слоя

Изоляция backend

Backend может менять:

  • snake_case;
  • вложенность;
  • названия полей.

UI останется стабильным.


Чистота доменной модели

UI работает с удобными структурами:

user.name

вместо:

user.full_name

Интеграция mapper с сервисами

export const usersService = {
    async getAll(): Promise<User[]> {
        const dto = await http<UserDto[]>('/users');

        return dto.map(mapUser);
    }
};

Теперь query layer получает уже готовую доменную модель.


Query keys и centralized API

API layer и query keys должны проектироваться совместно.


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

['data']

Непонятно:

  • что кэшируется;
  • какие параметры участвуют;
  • как инвалидировать.

Хороший пример

['users']
['users', userId]
['posts', postId]
['posts', postId, 'comments']

Query key factory

Крупные проекты выносят ключи в отдельный модуль.

export const queryKeys = {
    users: {
        all: ['users'] as const,

        detail: (id: number) =>
            ['users', id] as const
    }
};

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

useQuery({
    queryKey: queryKeys.users.detail(id),
    queryFn: () => usersService.getById(id)
});

Mutation layer

Mutations тоже должны использовать centralized API.


Плохой вариант

useMutation({
    mutationFn: async data => {
        return fetch('/users', {
            method: 'POST',
            body: JSON.stringify(data)
        });
    }
});

Хороший вариант

useMutation({
    mutationFn: usersService.create
});

Инвалидация кэша

const queryClient = useQueryClient();

useMutation({
    mutationFn: usersService.create,

    onSuccess: () => {
        queryClient.invalidateQueries({
            queryKey: queryKeys.users.all
        });
    }
});

Абстракции поверх Query

В больших проектах создаются отдельные query hooks.

useUsers.ts

export function useUsers() {
    return useQuery({
        queryKey: queryKeys.users.all,
        queryFn: usersService.getAll
    });
}

Преимущества custom hooks

Переиспользование

Одинаковая логика не дублируется.


Централизация options

staleTime
gcTime
retry
select
enabled

настраиваются в одном месте.


Упрощение компонентов

Компонент превращается в декларативный UI:

const { data } = useUsers();

Слой query options

В крупных проектах встречается дополнительный слой:

export const usersQueries = {
    all: () => ({
        queryKey: queryKeys.users.all,
        queryFn: usersService.getAll
    })
};

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

useQuery(usersQueries.all());

Преимущества query factories

Совместимость с prefetch

queryClient.prefetchQuery(
    usersQueries.all()
);

Совместимость с hydration

dehydrate(queryClient);

Переиспользование между SSR и CSR

Одинаковая конфигурация используется:

  • на сервере;
  • в браузере;
  • при prefetch;
  • при тестировании.

Сегментация API-клиентов

Крупные приложения часто используют несколько backend-сервисов.


Пример

auth-api
billing-api
content-api
analytics-api

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

export const authApi = axios.create({
    baseURL: AUTH_API
});

export const billingApi = axios.create({
    baseURL: BILLING_API
});

Почему это важно

Разные backend могут иметь:

  • разные timeout;
  • разные токены;
  • разные retry policy;
  • разные rate lim it;
  • разные форматы ошибок.

Retry logic

Retry должен быть централизованным.


На уровне HTTP-клиента

async function requestWithRetry() {

}

Или на уровне TanStack Query

retry: 3

Разделение retry-ответственности

Уровень Задача
HTTP client network retry
TanStack Query stale data retry

AbortController

TanStack Query умеет отменять запросы.


Поддержка signal

async function getUsers({
    signal
}: QueryFunctionContext) {
    return http('/users', {
        signal
    });
}

Почему это важно

Без abort:

  • лишняя нагрузка;
  • race conditions;
  • утечки памяти;
  • ненужные state updates.

Версионирование API

Централизованный слой упрощает миграции.


Пример

baseURL: '/api/v2'

или:

'/v1/users'
'/v2/users'

Feature-based organization

Многие современные проекты переходят от технической структуры к feature-based.


Пример

features/
├── users/
│   ├── api/
│   ├── hooks/
│   ├── queries/
│   ├── types/
│   └── components/

Преимущества feature-архитектуры

Локализация логики

Все относящееся к users находится рядом.


Упрощение масштабирования

Feature можно:

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

SSR и centralized API

При SSR особенно важно отсутствие прямых fetch внутри компонентов.


Prefetch

await queryClient.prefetchQuery(
    usersQueries.all()
);

Dehydration

dehydrate(queryClient);

Общий API layer для сервера и клиента

Одинаковые сервисы работают:

  • в браузере;
  • на Node.js;
  • при SSR;
  • в тестах.

Тестирование API layer

Централизованный слой резко упрощает тесты.


Мокирование сервисов

vi.mock(usersService);

Тестирование query hooks

usersService.getAll.mockResolvedValue([
    {
        id: 1,
        name: 'John'
    }
]);

Контроль контрактов

API layer становится единым местом интеграции с backend.


Runtime validation

TypeScript проверяет только compile-time.

Backend может вернуть:

null
undefined
wrong shape

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

const UserSchema = z.object({
    id: z.number(),
    name: z.string()
});

Проверка ответа

const parsed = UserSchema.parse(data);

Преимущества runtime validation

Безопасность

Frontend не ломается из-за неожиданного ответа.


Контроль backend-контрактов

Ошибки обнаруживаются мгновенно.


Упрощение отладки

Проблемы локализуются в API layer.


Anti-pattern: fat queryFn

Очень распространённая ошибка:

useQuery({
    queryKey: ['users'],
    queryFn: async () => {
        const response = await fetch('/users');

        const data = await response.json();

        return data
            .filter(user => user.active)
            .sort((a, b) => a.name.localeCompare(b.name))
            .map(transformUser);
    }
});

Почему это плохо

queryFn превращается одновременно в:

  • transport layer;
  • business layer;
  • transformation layer;
  • mapping layer.

Правильное разделение

Логика Место
HTTP API client
mapping mappers
filtering selectors
caching TanStack Query
UI components

Selectors

Для преобразований лучше использовать select.

useQuery({
    queryKey: ['users'],
    queryFn: usersService.getAll,

    select: users =>
        users.filter(user => user.active)
});

Преимущества centralized API layer

Масштабируемость

Архитектура выдерживает рост проекта.


Предсказуемость

Все запросы работают одинаково.


Простота поддержки

Изменения локализованы.


Безопасность

Ошибки и auth контролируются централизованно.


Независимость UI

Компоненты не зависят от транспорта.


Улучшение тестируемости

Легче мокать и изолировать зависимости.


Типичная production-схема

Component
    ↓
Custom Hook
    ↓
Query Factory
    ↓
Service Layer
    ↓
HTTP Client
    ↓
Backend API

Production-ready пример структуры

src/
├── shared/
│   ├── api/
│   │   ├── client.ts
│   │   ├── errors.ts
│   │   ├── auth.ts
│   │   └── validators.ts
│   │
│   ├── query/
│   │   ├── queryClient.ts
│   │   └── queryKeys.ts
│
├── features/
│   ├── users/
│   │   ├── api/
│   │   │   ├── users.service.ts
│   │   │   ├── users.queries.ts
│   │   │   └── users.mutations.ts
│   │   │
│   │   ├── hooks/
│   │   ├── types/
│   │   ├── mappers/
│   │   └── components/
│   │
│   └── posts/
│
└── app/