Сайт использует сookies для хранения данных. Продолжая использовать сайт, вы даёте согласие на работу с этими файлами.

ОК
💻
Технологии
Опубликовано:
14.09.2026
Обновлено:
14.09.2026

Слой адаптеров API в React: как подружить бэкенд и UI с помощью паттерна Data Mapper

Тимофей Ищенко

Коротко: Гайд по внедрению слоя адаптеров API и паттерна Data Mapper в React на TypeScript: изоляция UI от DTO, Anti-Corruption Layer и интеграция с React Query.

Каждый фронтенд-разработчик сталкивался с ситуацией, когда ответ сервера используется в React-компонентах напрямую. Поля в snake_case, даты в виде сырых строк, неожиданные null вместо пустых массивов и числовые флаги 0 или 1 вместо boolean начинают расползаться по JSX-разметке.

Когда бэкенд меняет формат ответа или проект переходит на новую версию API, разработчикам приходится вручную переписывать десятки компонентов и хуков. Чтобы защитить клиентское приложение от изменений на стороне сервера, в архитектуру внедряют слой адаптеров на базе паттерна Data Mapper.


Почему компоненты не должны знать о структуре API

Прямая передача данных из сетевых запросов в UI-компоненты создает жесткую связность (tight coupling) между клиентом и сервером. В результате интерфейс становится заложником формата базы данных или специфики бэкенд-фреймворка.

[ Backend API ] ---> (DTO) ---> [ React Component ]  // Прямая связность: UI зависит от API

Проблема протекающей абстракции (Leaky Abstraction)

Когда компонент получает «сырой» ответ от сервера (Data Transfer Object, DTO), в разметку неизбежно проникает логика форматирования:

  • проверки на существование вложенных объектов через user?.profile?.avatar_url ?? '/default.png';
  • склеивание строк const fullName = ${user.first_name} ${user.last_name}`` прямо в теле компонента;
  • парсинг дат через new Date(item.created_at_utc).toLocaleDateString();
  • маппинг числовых статусов в читаемый текст через условные конструкции status === 1 ? 'Активен' : 'Заблокирован'.

Если эта логика дублируется в разных частях интерфейса, кодовая база обрастает скрытым техдолгом.

Боль при рефакторинге контрактов

Представьте, что бэкенд-команда решила разделить поле name на firstName и lastName или переименовать user_id в id. Если объект из API проброшен сквозь пропсы на 4 уровня вниз в 15 компонентов, разработчику приходится:

  1. Найти все места использования старого поля в кодовой базе.
  2. Изменить типы во всех интерфейсах пропсов.
  3. Проверить каждую ветку логики, где это поле могло неявно участвовать в условиях.
  4. Провести регрессионное тестирование десятков экранов.

Слой адаптеров устраняет эту проблему: изменение структуры сетевого ответа изолируется в одном файле-маппере, а компоненты продолжают работать со стабильной внутренней моделью.


Что такое Data Mapper и Anti-Corruption Layer во фронтенде

Паттерн Data Mapper, описанный Мартином Фаулером, исторически создавался для разделения объектов предметной области (Domain Entities) и схемы реляционной базы данных. Маппер выступает изолированной прослойкой, которая переносит данные между хранилищем и памятью приложения, сохраняя их независимость друг от друга.

[ Backend API ] ---> (DTO) ---> [ Data Mapper ] ---> (Domain Entity) ---> [ React UI ]

Во фронтенд-архитектуре роль базы данных играет внешний REST- или GraphQL-сервис, а роль домена — структуры данных, с которыми работают хуки, стейт-менеджеры и компоненты.

Anti-Corruption Layer (Предохранительный слой)

В парадигме Domain-Driven Design (DDD) адаптеры API выполняют функцию Anti-Corruption Layer (ACL). Это барьер, который «очищает» входящие данные от чужеродных концепций:

  • преобразует сетевые структуры данных в понятные клиентские сущности;
  • заменяет специфичные для бэкенда структуры (например, битовые маски или строковые идентификаторы типов) на удобные для UI типы;
  • исключает проникновение внешних контрактов в бизнес-логику фронтенда.

Разделение понятий: DTO, Domain Entity и View Model

Для построения устойчивой архитектуры важно разграничивать три типа моделей:

  1. DTO (Data Transfer Object) — чистый контракт сетевого уровня. Отражает точные типы полей, приходящих от API (user_first_name: string, is_deleted: 0 | 1, created_at: string).
  2. Domain Entity (Сущность предметной области) — независимая внутренняя модель данных (User, Product, Order). Содержит нормализованные типы (Date, boolean, camelCase).
  3. View Model (Модель представления) — данные, подготовленные под конкретный UI-виджет (например, флаги отображения тултипов, цвет плашки статуса).

Проектирование слоя адаптеров на TypeScript

В клиентском коде маппер чаще всего реализуется как чистая функция (pure function) без побочных эффектов. Она принимает DTO и возвращает строго типизированную доменную модель.

Анатомия маппера

Разберем структуру на примере сущности пользователя.

1. Описание DTO (user.dto.ts)

export interface UserDto {
  id: string;
  first_name: string;
  last_name: string;
  email: string;
  is_verified: boolean;
  registered_at_utc: string;
  role_flags: number;
  contact_info: {
    phone_number: string | null;
    telegram_handle: string | null;
  } | null;
}

2. Описание доменной модели (user.entity.ts)

export type UserRole = 'admin' | 'moderator' | 'user';

export interface User {
  id: string;
  firstName: string;
  lastName: string;
  fullName: string;
  email: string;
  isVerified: boolean;
  registeredAt: Date;
  role: UserRole;
  phone: string;
  telegram?: string;
}

3. Функция трансформации (user.mapper.ts)

import { UserDto } from './user.dto';
import { User, UserRole } from './user.entity';

const parseRoleFlag = (flag: number): UserRole => {
  if (flag === 1) return 'admin';
  if (flag === 2) return 'moderator';
  return 'user';
};

export const mapUserDtoToEntity = (dto: UserDto): User => {
  return {
    id: dto.id,
    firstName: dto.first_name,
    lastName: dto.last_name,
    fullName: `${dto.first_name} ${dto.last_name}`.trim(),
    email: dto.email,
    isVerified: Boolean(dto.is_verified),
    registeredAt: new Date(dto.registered_at_utc),
    role: parseRoleFlag(dto.role_flags),
    phone: dto.contact_info?.phone_number ?? 'Не указан',
    telegram: dto.contact_info?.telegram_handle ?? undefined,
  };
};

Двусторонний маппинг (Мутации)

Для отправки данных на сервер (POST, PUT, PATCH) используется обратная функция-адаптер:

export interface UpdateUserPayload {
  firstName: string;
  lastName: string;
  phone: string;
}

export interface UpdateUserDto {
  first_name: string;
  last_name: string;
  phone_number: string;
}

export const mapUserEntityToDto = (payload: UpdateUserPayload): UpdateUserDto => {
  return {
    first_name: payload.firstName,
    last_name: payload.lastName,
    phone_number: payload.phone,
  };
};

Благодаря этому формы в React оперируют чистыми объектами, а подготовка пейлоада под специфику бэкенда происходит централизованно перед отправкой HTTP-запроса.


Где размещать слой мапперов в архитектуре React

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

Вариант 1: Внутри TanStack Query (React Query)

Лучшая практика при работе с серверным состоянием — применять маппинг непосредственно в функции запроса (queryFn) или через свойство select.

// api/users.ts
import { UserDto } from './user.dto';
import { mapUserDtoToEntity } from './user.mapper';
import { User } from './user.entity';

export const fetchUser = async (id: string): Promise<User> => {
  const response = await fetch(`/api/v1/users/${id}`);
  if (!response.ok) {
    throw new Error('Ошибка загрузки пользователя');
  }
  const dto: UserDto = await response.json();
  return mapUserDtoToEntity(dto);
};

// hooks/useUser.ts
import { useQuery } from '@tanstack/react-query';
import { fetchUser } from '../api/users';

export const useUser = (userId: string) => {
  return useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
  });
};

При таком подходе данные попадают в кэш TanStack Query уже в нормализованном виде.

Вариант 2: Внутри RTK Query (Redux Toolkit)

В RTK Query трансформация настраивается декларативно через хук жизненного цикла transformResponse:

import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
import { UserDto } from './user.dto';
import { User } from './user.entity';
import { mapUserDtoToEntity } from './user.mapper';

export const usersApi = createApi({
  reducerPath: 'usersApi',
  baseQuery: fetchBaseQuery({ baseUrl: '/api/' }),
  endpoints: (builder) => ({
    getUserById: builder.query<User, string>({
      query: (id) => `users/${id}`,
      transformResponse: (response: UserDto) => mapUserDtoToEntity(response),
    }),
  }),
});

Вариант 3: Связка с валидацией схем (Zod)

Если контракт API нестабилен, маппер можно объединить со схемой валидации в рантайме:

import { z } from 'zod';
import { User } from './user.entity';
import { mapUserDtoToEntity } from './user.mapper';

export const UserDtoSchema = z.object({
  id: z.string(),
  first_name: z.string(),
  last_name: z.string(),
  email: z.string().email(),
  is_verified: z.boolean(),
  registered_at_utc: z.string().datetime(),
  role_flags: z.number(),
  contact_info: z.object({
    phone_number: z.string().nullable(),
    telegram_handle: z.string().nullable(),
  }).nullable(),
});

export const parseAndMapUser = (rawData: unknown): User => {
  const validDto = UserDtoSchema.parse(rawData);
  return mapUserDtoToEntity(validDto);
};

Практический пример: рефакторинг спагетти-кода

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

До рефакторинга: связанный и хрупкий код

Компонент перегружен логикой парсинга, ручной сборкой строк и условными ветвлениями:

// UserCard.tsx - БЕЗ МАППЕРА
export const UserCard = ({ userData }: { userData: any }) => {
  return (
    <div className="card">
      <h3>
        {userData.first_name} {userData.last_name}
      </h3>
      <p>Email: {userData.email}</p>
      <p>Статус: {userData.is_verified ? 'Подтвержден' : 'Не подтвержден'}</p>
      <p>
        Дата регистрации:{' '}
        {userData.registered_at_utc
          ? new Date(userData.registered_at_utc).toLocaleDateString('ru-RU')
          : '—'}
      </p>
      <p>Телефон: {userData.contact_info?.phone_number ?? 'Не указан'}</p>
    </div>
  );
};

После рефакторинга: чистый UI-компонент

Компонент принимает строгую доменную сущность User, не содержит вычислений и занимается исключительно рендерингом:

// UserCard.tsx - С ИСПОЛЬЗОВАНИЕМ ДОМЕННОЙ СУЩНОСТИ
import React from 'react';
import { User } from '../entities/user.entity';

interface UserCardProps {
  user: User;
}

export const UserCard: React.FC<UserCardProps> = ({ user }) => {
  return (
    <div className="card">
      <h3>{user.fullName}</h3>
      <p>Email: {user.email}</p>
      <p>Статус: {user.isVerified ? 'Подтвержден' : 'Не подтвержден'}</p>
      <p>Дата регистрации: {user.registeredAt.toLocaleDateString('ru-RU')}</p>
      <p>Телефон: {user.phone}</p>
      {user.telegram && <p>Telegram: @{user.telegram}</p>}
    </div>
  );
};

Что изменилось:

  1. Код компонента сократился и стал строго декларативным.
  2. Исключены ошибки чтения неопределенных свойств (TypeError: Cannot read properties of undefined) во время рендера.
  3. Компонент легко тестировать и изолированно отображать в Storybook, передавая готовый объект User.

Когда Data Mapper избыточен, а когда жизненно необходим

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

Критерий Data Mapper необходим Data Mapper избыточен
Тип проекта Enterprise-системы, крупные SaaS, масштабируемые SPA/SSR сервисы Простые лендинги, прототипы, маленькие пет-проекты
Команда Раздельные команды фронтенда и бэкенда Fullstack-разработчик на Next.js / Remix
Контракты API snake_case, легаси-эндпоинты, частые изменения структуры Строго типизированный tRPC или единый GraphQL-слой
Требования к UI Сложные вычисляемые поля, агрегация данных из разных API Прямое отображение табличных данных «1-в-1»

Часто задаваемые вопросы (FAQ)

1. Не замедляет ли маппинг рендеринг React-приложения?

Нет. Маппер — это линейный проход по полям объекта. Накладные расходы на создание простых JavaScript-объектов несоизмеримо малы по сравнению с временем парсинга JSON браузером и перерисовкой DOM-дерева. При работе с большими массивами (десятки тысяч элементов) производительность определяется виртуализацией списков, а не маппингом.

2. Где лучше вызывать маппер: в API-сервисе или в компоненте?

Маппер вызывается на границе сетевого слоя — внутри функций API-клиента (fetch/axios), либо в обработчиках queryFn (TanStack Query) и transformResponse (RTK Query). Внутрь компонентов «сырые» DTO попадать не должны.

3. Заменяет ли библиотека Zod паттерн Data Mapper?

Они дополняют друг друга. Zod выполняет runtime-валидацию схемы и гарантирует, что ответ сервера действительно соответствует ожидаемому контракту (UserDto). Data Mapper отвечает за дальнейшую трансформацию типов и структуры данных в формат клиентского домена (User).

4. Нужен ли Data Mapper при работе с tRPC или Next.js Server Actions?

В tRPC и архитектурах с единым стеком TypeScript клиент и сервер делят общие типы. Если структура ответа бэкенда полностью совпадает с потребностями интерфейса, отдельный слой мапперов может быть избыточным. Однако при наличии вычисляемых свойств (fullName, агрегированные статусы) маппер по-прежнему полезен как способ централизации клиентской логики.

5. Как тестировать слой мапперов?

Поскольку мапперы пишутся как чистые функции, они не требуют моков сети или рендеринга виртуального DOM. Достаточно написать стандартный Unit-тест на Jest или Vitest: передать фиктивный объект Dto и проверить соответствие результирующего Entity.


Заключение

Слой адаптеров API (Data Mapper) — это инвестиция в предсказуемость и надежность кодовой базы. Изоляция React-компонентов от сетевых контрактов позволяет:

  • локализовать изменения при рефакторинге бэкенда в единой точке;
  • очистить JSX-разметку от защитных проверок и форматирования;
  • обеспечить строгую типизацию во всем клиентском приложении;
  • упростить написание Unit-тестов и разработку компонентов в изоляции.

Разделение типов DTO и доменных сущностей с первых этапов разработки защищает проект от неконтролируемого накопления техдолга по мере масштабирования сервиса.

Источники

Это авторская статья, основанная на личном опыте и субъективном взгляде автора. Заметили ошибку или битую ссылку? Сообщите нам: info@codesrc.ru - мы оперативно исправим. Спасибо, что помогаете делать блог лучше.
Следите за нами в соцсетях:

Читайте также