Организация кода в больших приложениях

В крупных приложениях RTK Query перестаёт быть просто «слоем для запросов» и превращается в центральную часть инфраструктуры работы с серверным состоянием. Ошибки в организации кода здесь быстро приводят к дублированию эндпоинтов, размытию ответственности, сложной поддержке и конфликтам между модулями.

Ключевая цель архитектуры RTK Query в больших проектах — обеспечить:

  • изоляцию доменных областей
  • предсказуемую структуру API-слоя
  • возможность расширения без рефакторинга ядра
  • повторное использование эндпоинтов
  • контроль за зависимостями между API-модулями

Базовый слой API как точка композиции

В масштабируемой архитектуре всегда выделяется один базовый API-инстанс, который становится фундаментом для всех последующих расширений.

import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';

export const baseApi = createApi({
  reducerPath: 'api',
  baseQuery: fetchBaseQuery({
    baseUrl: '/api',
  }),
  endpoints: () => ({}),
});

Этот слой принципиально не должен содержать бизнес-эндпоинтов. Его задача:

  • определить транспорт (fetchBaseQuery, headers, baseUrl)
  • задать глобальные настройки
  • предоставить точку расширения через injectEndpoints

Такой подход предотвращает монолитный API-файл и позволяет строить модульную систему.


Разделение API по доменам

В крупных системах домены становятся основным способом структурирования RTK Query.

Типичные домены:

  • users
  • auth
  • products
  • orders
  • payments

Каждый домен оформляется как отдельный модуль, который расширяет базовый API.

Пример структуры проекта

src/
  app/
    store.js
    baseApi.js
  features/
    users/
      usersApi.js
      usersTypes.js
    products/
      productsApi.js
    orders/
      ordersApi.js

Такое разделение исключает смешивание логики и упрощает навигацию по проекту.


injectEndpoints как основа модульности

Основной механизм расширения RTK Query — injectEndpoints. Он позволяет добавлять эндпоинты без изменения базового API.

import { baseApi } from '../. ./app/baseApi';

export const usersApi = baseApi.injectEndpoints({
  endpoints: (builder) => ({
    getUsers: builder.query({
      query: () => '/users',
    }),
  }),
});

export const { useGetUsersQuery } = usersApi;

Особенность подхода заключается в том, что:

  • базовый API остаётся неизменным
  • каждый модуль независим
  • эндпоинты могут добавляться динамически

Изоляция доменной логики

Каждый модуль RTK Query должен содержать только:

  • описание запросов
  • типы данных (если используются TypeScript)
  • специфичные теги кеша

Не допускается:

  • вызов других API-модулей напрямую
  • хранение UI-логики
  • использование глобальных утилит вне инфраструктурного слоя

Пример доменной изоляции

export const productsApi = baseApi.injectEndpoints({
  endpoints: (builder) => ({
    getProducts: builder.query({
      query: () => '/products',
      providesTags: ['Products'],
    }),
    getProductById: builder.query({
      query: (id) => `/products/${id}`,
      providesTags: (result, error, id) => [{ type: 'Products', id }],
    }),
  }),
});

Стандартизация тегов кеша

В больших приложениях кеширование становится критическим фактором стабильности. Без строгой системы тегов возникает неконтролируемая инвалидация.

Рекомендуется вводить:

  • глобальные типы тегов по доменам
  • единый стиль именования
  • избегание строковых «магических значений»

Пример централизованных тегов

export const TAGS = {
  USERS: 'Users',
  PRODUCTS: 'Products',
  ORDERS: 'Orders',
};

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

providesTags: [TAGS.PRODUCTS]

Такой подход снижает вероятность конфликтов и облегчает рефакторинг.


Разделение query и mutation слоёв

В больших системах важно чётко отделять:

  • query (чтение данных)
  • mutation (изменение данных)

Это не только логическая, но и архитектурная граница.

endpoints: (builder) => ({
  getOrders: builder.query({
    query: () => '/orders',
    providesTags: ['Orders'],
  }),

  createOrder: builder.mutation({
    query: (body) => ({
      url: '/orders',
      method: 'POST',
      body,
    }),
    invalidatesTags: ['Orders'],
  }),
});

Разделение обеспечивает:

  • предсказуемую инвалидацию
  • прозрачность побочных эффектов
  • упрощение отладки кеша

Избежание дублирования API-логики

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

Решение:

1. Базовые утилиты запросов

const buildUrl = (path) => `/api/v1/${path}`;

2. Общие baseQuery конфигурации

const baseQuery = fetchBaseQuery({
  baseUrl: '/api',
  prepareHeaders: (headers) => {
    headers.set('authorization', `Bearer token`);
    return headers;
  },
});

3. Переиспользуемые аргументы

const listQuery = (resource) => ({
  query: () => `/${resource}`,
});

Работа с несколькими API-инстансами

В сложных системах иногда возникает необходимость разделения API на несколько инстансов:

  • публичный API
  • приватный API
  • микросервисные источники
export const publicApi = createApi({
  reducerPath: 'publicApi',
  baseQuery: fetchBaseQuery({ baseUrl: '/public' }),
  endpoints: () => ({}),
});

export const privateApi = createApi({
  reducerPath: 'privateApi',
  baseQuery: fetchBaseQuery({ baseUrl: '/private' }),
  endpoints: () => ({}),
});

Однако злоупотребление этим подходом приводит к:

  • дублированию кеша
  • усложнению store-конфигурации
  • потере единого источника данных

Поэтому разделение оправдано только при чёткой архитектурной необходимости.


Централизованное подключение API в store

В больших приложениях подключение API должно быть строго структурировано.

import { configureStore } from '@reduxjs/toolkit';
import { baseApi } from './baseApi';

export const store = configureStore({
  reducer: {
    [baseApi.reducerPath]: baseApi.reducer,
  },
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware().concat(baseApi.middleware),
});

При использовании injectEndpoints дополнительных reducer’ов не требуется — это критически важный момент, который часто нарушается в плохо организованных проектах.


Организация файлов внутри домена

Каждый доменный модуль должен иметь предсказуемую структуру.

users/
  api/
    usersApi.js
  model/
    selectors.js
    types.js
  ui/
    UsersList.jsx
    UserCard.jsx

Такое разделение обеспечивает:

  • независимость API от UI
  • масштабируемость домена
  • простоту тестирования

Lazy injection и code splitting

RTK Query поддерживает ленивую регистрацию эндпоинтов, что особенно важно для больших приложений с code splitting.

const usersApi = baseApi.injectEndpoints({
  endpoints: (builder) => ({
    getUsers: builder.query({
      query: () => '/users',
    }),
  }),
  overrideExisting: false,
});

При использовании динамических импортов:

const loadUsersApi = async () => {
  const module = await import('./usersApi');
  return module;
};

Это позволяет:

  • уменьшить initial bundle size
  • загружать API по мере необходимости
  • улучшить производительность

Контроль связей между модулями

В больших системах критично избегать циклических зависимостей между API-модулями.

Типичная ошибка:

  • usersApi вызывает ordersApi
  • ordersApi вызывает usersApi

RTK Query не предназначен для междоменных вызовов внутри endpoints. Решение:

  • перенос агрегации на уровень селекторов
  • использование backend aggregation endpoints
  • создание отдельного orchestration слоя

Использование селекторов как слоя агрегации

RTK Query предоставляет селекторы кеша, которые можно использовать для построения сложных вычисляемых данных без дополнительных запросов.

export const selectUsersResult = usersApi.endpoints.getUsers.select();

export const selectActiveUsers = (state) => {
  const result = selectUsersResult(state);
  return result?.data?.filter(user => user.active);
};

Такой подход:

  • снижает количество запросов
  • переносит логику в predictable слой
  • улучшает тестируемость

Масштабируемая стратегия именования

При росте проекта критически важно избегать хаоса в именах.

Рекомендуемая схема:

  • getXxx — query
  • createXxx — mutation
  • updateXxx — mutation
  • deleteXxx — mutation

Дополнительно:

  • единый префикс домена внутри API файла
  • отсутствие сокращений без необходимости

Инвалидация кеша в распределённых системах

В крупных приложениях инвалидация становится сложной задачей, особенно при перекрёстных зависимостях.

Рекомендуемая стратегия:

  • минимальная гранулярность тегов
  • инвалидация по домену, а не по сущности
  • избегание каскадных invalidatesTags
invalidatesTags: ['Orders']

А не:

invalidatesTags: [{ type: 'Orders', id: '123' }]

если нет строгой необходимости точечной инвалидации.


Разграничение инфраструктурного и бизнес-слоя

RTK Query должен оставаться инфраструктурным слоем, а не местом хранения бизнес-логики.

Недопустимо:

  • вычисления сложных бизнес-правил внутри query
  • преобразование данных под UI
  • агрегация нескольких сущностей в endpoint

Допустимо:

  • транспорт данных
  • кеширование
  • инвалидация
  • базовые трансформации ответа при необходимости transformResponse

Использование transformResponse как контролируемого адаптера

getUsers: builder.query({
  query: () => '/users',
  transformResponse: (response) => response.data,
});

Этот механизм должен использоваться умеренно:

  • только для выравнивания API-ответа
  • без сложной бизнес-логики
  • без побочных эффектов

Поддержка долгоживущих приложений

В системах, работающих годами, RTK Query становится частью архитектурного ядра. Поэтому критичны:

  • стабильность API-интерфейсов
  • предсказуемость кеша
  • отсутствие скрытых зависимостей
  • строгая модульность через injectEndpoints
  • контроль за инвалидацией

Структурированный подход к организации кода напрямую влияет на стоимость дальнейшей поддержки и расширения системы.