Files
zero-fucks-given/docs/architecture/react-frontend/layers/data.md
T

4.1 KiB

Data Layer

The data layer owns all data access and transformation: API calls, DTO types, DTO → entity mappers, React Query hooks, caching and errors. Higher layers (forms, widgets, pages) never call APIs directly — they consume data-layer hooks.

Organization

Two ways to structure the layer:

Approach Layout Best for Trade-off
By Module data/api/, data/hooks/, data/dto/, data/mappers/ Small apps, simple data layers One feature's files scattered; hard to scale
By Entity data/user/, data/product/ — each with api.ts, hooks.ts, dto.ts, mapper.ts, types.ts Medium/large apps Harder to see all APIs/hooks globally
data/
| --- user/
|   | --- api.ts              # user-related API functions (getUsers, createUser, etc.)
|   | --- hooks.ts            # React Query hooks (useUsersQuery, useCreateUserMutation)
|   | --- dto.ts              # API DTO types for user
|   | --- mapper.ts           # map user DTO → entity
|   | --- types.ts            # API types/interfaces (optional)

Data Flow

1. DTO Types

export type UserDTO = {
  id: number;
  full_name: string;
  email_address: string;
};
export type CreateUserDTO = {
  full_name: string;
  email_address: string;
};

2. API Layer

API functions or class methods perform HTTP requests and always return DTOs.

export class UserApi {
  private http: HttpClient;

  async getDetailed(id: number): Promise<UserDTO> {
    return this.http.get(`/users/${id}`);
  }

  async getList(): Promise<UserDTO[]> {
    return this.http.get(`/users`);
  }

  async create(dto: CreateUserDTO): Promise<UserDTO> {
    return this.http.post(`/users`, dto);
  }
}

3. Mapper

Mappers convert DTO to Entity.

import { User } from "@/entities/user";

export class UserMapper {
  toEntity(dto: UserDTO): User {
    return {
      id: dto.id,
      name: dto.full_name,
      email: dto.email_address,
    };
  }

  toEntities(dtos: UserDTO[]): User[] {
    return dtos.map((dto) => this.toEntity(dto));
  }

  toPaginated(dto: PaginatedDataDTO<UserDTO[]>): PaginatedResult<User[]> => {
    return {
      meta: this.paginationMetaMapper.toMeta(dto.meta),
      data: this.toEntities(dto.data),
    };
  };
}

Mapper Testing

Mappers are part of the data layer contract and need unit tests whenever they rename fields, normalize values (paths, dates, statuses, enums, defaults), map nested DTOs, arrays, paginated data, or optional fields. Cover: toEntity on a representative DTO, toEntities on arrays, defaults for missing fields, nested mapper shapes, enum/status casts. Test only transformation — no React, no rendering, no real API calls.

Keep tests at the app root, mirroring the source path:

apps/admin/
├── src/core/FileManager/data/mappers/entryMapper.ts
└── tests/unit/core/FileManager/data/mappers/entryMapper.test.ts

4. Query Hooks

Hooks combine API + mapper + React Query.

export const useUserDetailed = (id: number) => {
  const api = new UserApi(http);
  const mapper = new UserMapper();

  return useQuery({
    queryKey: ["user", id],
    queryFn: async () => {
      const dto = await api.getDetailed(id);
      return mapper.toEntity(dto);
    },
  });
};

Pagination

Pagination is not entity-specific — keep it in a shared data module.

export type PaginatedMetaDTO = {
  total: number;
  perPage: number;
  currentPage: number;
  lastPage: number;
};

export type PaginatedDTO<T> = {
  data: T;
  meta: PaginatedMetaDTO;
};

export class PaginationMapper {
  toMeta(dto: PaginatedMetaDTO) {
    return {
      total: dto.total,
      perPage: dto.perPage,
      currentPage: dto.currentPage,
      lastPage: dto.lastPage,
    };
  }
}