Обработка breaking changes

Breaking changes — это изменения в API, нарушающие совместимость между клиентом и сервером. После внедрения таких изменений старый клиент перестаёт корректно работать без дополнительной адаптации.

Наиболее распространённые примеры:

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

Пример несовместимого изменения:

Старая версия API:

{
  "id": 1,
  "name": "Alex"
}

Новая версия API:

{
  "id": 1,
  "fullName": "Alex"
}

Если RTK Query использует поле name, приложение начнёт работать некорректно.


Основные проблемы breaking changes в RTK Query

RTK Query тесно связан со структурой API, поэтому несовместимые изменения затрагивают:

  • endpoint definitions;
  • selectors;
  • hooks;
  • кеширование;
  • optimistic updates;
  • invalidation;
  • трансформацию данных;
  • типизацию;
  • middleware;
  • re-fetching;
  • polling.

Особенно опасны изменения в:

  • transformResponse
  • providesTags
  • invalidatesTags
  • updateQueryData
  • onQueryStarted
  • serializeQueryArgs

Стратегии обработки breaking changes

Существует несколько подходов:

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

Наиболее надёжный подход.

Пример:

/api/v1/users
/api/v2/users

RTK Query позволяет поддерживать несколько версий одновременно.


Разделение API Slice по версиям

v1

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

export const apiV1 = createApi({
    reducerPath: 'apiV1',

    baseQuery: fetchBaseQuery({
        baseUrl: '/api/v1'
    }),

    endpoints: (builder) => ({
        getUsers: builder.query({
            query: () => '/users'
        })
    })
})

v2

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

export const apiV2 = createApi({
    reducerPath: 'apiV2',

    baseQuery: fetchBaseQuery({
        baseUrl: '/api/v2'
    }),

    endpoints: (builder) => ({
        getUsers: builder.query({
            query: () => '/users'
        })
    })
})

Параллельная поддержка нескольких версий

RTK Query позволяет использовать обе версии одновременно.

const { data: oldUsers } = apiV1.useGetUsersQuery()

const { data: newUsers } = apiV2.useGetUsersQuery()

Такой подход полезен при:

  • постепенной миграции;
  • A/B тестировании;
  • частичном обновлении фронтенда;
  • поддержке legacy-компонентов.

Нормализация структуры ответа

Breaking changes часто касаются структуры данных.

Сервер v1:

{
  "data": [
    {
      "id": 1,
      "name": "John"
    }
  ]
}

Сервер v2:

{
  "result": [
    {
      "id": 1,
      "full_name": "John"
    }
  ]
}

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


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

getUsers: builder.query({
    query: () => '/users',

    transformResponse: (response) => {
        return response.result.map(user => ({
            id: user.id,
            name: user.full_name
        }))
    }
})

Теперь приложение продолжает работать со старой моделью:

user.name

несмотря на изменения API.


Поддержка нескольких форматов ответа

Иногда сервер возвращает разные структуры во время миграции.

transformResponse: (response) => {
    const users = response.data || response.result || []

    return users.map(user => ({
        id: user.id,
        name: user.name || user.full_name
    }))
}

Это снижает риск массовых ошибок при поэтапном обновлении backend.


Адаптеры данных

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

function mapUser(user) {
    return {
        id: user.id,
        name: user.name || user.full_name,
        email: user.email
    }
}

RTK Query:

transformResponse: (response) => {
    return response.users.map(mapUser)
}

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

  • централизованная логика;
  • повторное использование;
  • упрощение миграций;
  • единая структура данных;
  • уменьшение количества breaking changes во фронтенде.

Обработка удаления полей

Сервер:

{
  "id": 1
}

Фронтенд ожидает:

user.avatar

Без обработки:

Cannot read properties of undefined

Значения по умолчанию

transformResponse: (response) => {
    return {
        ...response,
        avatar: response.avatar || null
    }
}

Безопасный доступ к данным

const avatar = user?.avatar ?? '/default-avatar.png'

Обработка изменения типов данных

Старая версия:

{
  "id": 1
}

Новая версия:

{
  "id": "1"
}

Проблемы:

  • invalidation;
  • entity adapters;
  • selectors;
  • memoization;
  • comparison;
  • cache keys.

Приведение типов

transformResponse: (response) => {
    return {
        ...response,
        id: Number(response.id)
    }
}

Изменение структуры пагинации

Старая версия:

{
  "items": [],
  "total": 100
}

Новая версия:

{
  "data": [],
  "meta": {
    "count": 100
  }
}

Унификация пагинации

transformResponse: (response) => {
    return {
        items: response.items || response.data,
        total: response.total || response.meta?.count || 0
    }
}

Изменение HTTP-методов

Старая версия:

GET /users/delete/1

Новая версия:

DELETE /users/1

RTK Query:

deleteUser: builder.mutation({
    query: (id) => ({
        url: `/users/${id}`,
        method: 'DELETE'
    })
})

Временная поддержка legacy endpoint

deleteUserLegacy: builder.mutation({
    query: (id) => ({
        url: `/users/delete/${id}`,
        method: 'GET'
    })
})

Feature flags для миграции

const USE_NEW_API = true

Динамический выбор endpoint

getUsers: builder.query({
    query: () => {
        return USE_NEW_API
            ? '/v2/users'
            : '/v1/users'
    }
})

Runtime migration

Иногда сервер меняется раньше клиента.

RTK Query можно адаптировать динамически.

transformResponse: (response) => {
    if (response.result) {
        return response.result
    }

    return response.data
}

Обработка несовместимых ошибок

Старый формат:

{
  "message": "Validation failed"
}

Новый формат:

{
  "error": {
    "text": "Validation failed"
  }
}

Унификация ошибок

transformErrorResponse: (response) => {
    return {
        message:
            response.data?.message ||
            response.data?.error?.text ||
            'Unknown error'
    }
}

Изменение авторизации

Старая схема:

Authorization: Token abc123

Новая схема:

Authorization: Bearer abc123

Централизация авторизации

baseQuery: fetchBaseQuery({
    baseUrl: '/api',

    prepareHeaders: (headers, { getState }) => {
        const token = getState().auth.token

        headers.set('Authorization', `Bearer ${token}`)

        return headers
    }
})

Миграция baseQuery

Иногда breaking changes касаются транспортного уровня.

Пример:

  • REST → GraphQL
  • fetch → axios
  • HTTP → gRPC gateway

Создание собственного baseQuery

const customBaseQuery = async (args, api, extraOptions) => {
    const response = await fetch(args.url)

    const data = await response.json()

    return {
        data
    }
}

Адаптация GraphQL

const graphqlBaseQuery = async ({ body }) => {
    const response = await fetch('/graphql', {
        method: 'POST',

        headers: {
            'Content-Type': 'application/json'
        },

        body: JSON.stringify(body)
    })

    const result = await response.json()

    return {
        data: result.data
    }
}

Breaking changes и кеш RTK Query

Изменение структуры данных может ломать кеш.

Проблемный пример:

providesTags: (result) =>
    result.map(user => ({
        type: 'Users',
        id: user.id
    }))

Если user.id изменил тип:

1 !== "1"

invalidatesTags перестанет работать корректно.


Нормализация идентификаторов

providesTags: (result) =>
    result.map(user => ({
        type: 'Users',
        id: String(user.id)
    }))

Breaking changes в serializeQueryArgs

Изменение query parameters способно сломать кеширование.

Старая версия:

/users?page=1

Новая версия:

/users?page[number]=1

Кастомная сериализация

serializeQueryArgs: ({ endpointName, queryArgs }) => {
    return `${endpointName}-${queryArgs.page}`
}

Миграция optimistic updates

Breaking changes часто затрагивают mutations.


Проблемный optimistic update

updateQueryData('getUsers', undefined, draft => {
    draft.push(newUser)
})

Если сервер изменил структуру:

{
    items: []
}

код перестанет работать.


Универсальная структура состояния

transformResponse: (response) => {
    return {
        items: response.items || response.data || []
    }
}

Обновление optimistic cache

updateQueryData('getUsers', undefined, draft => {
    draft.items.push(newUser)
})

Совместимость с entity adapters

Breaking changes особенно опасны при использовании createEntityAdapter.


Проблема

Старая версия:

{
    id: 1
}

Новая версия:

{
    uuid: 'abc'
}

Adapter:

selectId: (user) => user.id

Адаптация selectId

const usersAdapter = createEntityAdapter({
    selectId: (user) => user.id || user.uuid
})

Защита от partial rollout

Иногда backend обновляется постепенно.

Один сервер:

{
  "name": "Alex"
}

Другой:

{
  "full_name": "Alex"
}

Fail-safe трансформация

function normalizeUser(user) {
    return {
        id: user.id,
        name: user.name || user.full_name || 'Unknown'
    }
}

Обработка deprecated API

RTK Query может поддерживать deprecated endpoints.


Изоляция legacy API

export const legacyApi = createApi({
    reducerPath: 'legacyApi',

    baseQuery: fetchBaseQuery({
        baseUrl: '/legacy-api'
    }),

    endpoints: (builder) => ({
        getUsers: builder.query({
            query: () => '/users'
        })
    })
})

Proxy endpoints

Иногда удобно скрыть breaking changes внутри frontend.

getUsers: builder.query({
    async queryFn(arg, api, extraOptions, baseQuery) {
        const result = await baseQuery('/v2/users')

        if (result.error) {
            return result
        }

        return {
            data: result.data.map(user => ({
                id: user.id,
                name: user.full_name
            }))
        }
    }
})

Обработка version negotiation

Некоторые API используют заголовки версий.

Accept-Version: 2

Добавление version headers

prepareHeaders: (headers) => {
    headers.set('Accept-Version', '2')

    return headers
}

Автоматическое определение версии API

const detectVersion = (response) => {
    if (response.result) {
        return 2
    }

    return 1
}

Graceful degradation

При breaking changes важно избегать полного падения интерфейса.


Fallback rendering

if (!user.name) {
    return <span>User unavailable</span>
}

Обработка несовместимых enum

Старая версия:

{
  "status": "active"
}

Новая версия:

{
  "status": "enabled"
}

Маппинг enum

const statusMap = {
    active: 'active',
    enabled: 'active'
}

Применение маппинга

transformResponse: (response) => {
    return {
        ...response,
        status: statusMap[response.status]
    }
}

Тестирование breaking changes

Критически важно тестировать:

  • transformResponse;
  • transformErrorResponse;
  • adapters;
  • optimistic updates;
  • invalidation;
  • selectors;
  • polling;
  • re-fetching;
  • migration logic.

Snapshot testing

expect(normalizeUser(apiResponse)).toMatchSnapshot()

Контрактные тесты

expect(user).toHaveProperty('id')
expect(user).toHaveProperty('name')

Интеграционные тесты RTK Query

const result = await store.dispatch(
    api.endpoints.getUsers.initiate()
)

expect(result.data.length).toBeGreaterThan(0)

Defensive programming

Breaking changes невозможно полностью исключить, поэтому RTK Query требует defensive-подхода:

  • нормализация ответов;
  • fallback values;
  • tolerant parsing;
  • runtime adaptation;
  • централизация преобразований;
  • versioning;
  • backward compatibility;
  • изоляция transport layer;
  • адаптеры моделей;
  • fail-safe rendering;
  • постепенная миграция API.

Архитектурные рекомендации

Наиболее устойчивая архитектура RTK Query обычно содержит:

API layer

Изолирует HTTP и transport logic.

Transformation layer

Нормализует данные.

Entity layer

Содержит adapters и selectors.

UI layer

Работает только со стабильной моделью данных.


Пример полной схемы адаптации

function normalizeUser(rawUser) {
    return {
        id: String(rawUser.id || rawUser.uuid),

        name:
            rawUser.name ||
            rawUser.full_name ||
            'Unknown',

        avatar:
            rawUser.avatar ||
            rawUser.photo ||
            null
    }
}

RTK Query endpoint:

getUsers: builder.query({
    query: () => '/users',

    transformResponse: (response) => {
        const users =
            response.data ||
            response.result ||
            response.users ||
            []

        return users.map(normalizeUser)
    },

    providesTags: (result) =>
        result.map(user => ({
            type: 'Users',
            id: user.id
        }))
})

Такая схема значительно снижает влияние breaking changes на frontend-приложение и позволяет мигрировать API постепенно без массового переписывания компонентов.