Guide on decoupling API contracts and internal application data models using mappings (DTO -> Domain Model).
Benefits: Converters isolate the UI from backend changes, allow data formatting (dates, enums), and guarantee layer-level type safety.
Located in the converters/ folder. Files are named following the [entity-name].converters.ts pattern. They use types from the sibling folder for data transformation.
Located in the types folder. Must be divided into *-backend.interface.ts (raw DTOs as T*Backend) and *.interface.ts (UI models as I*). All types are exported via index.ts.
Transforming raw backend data (snake_case, strings instead of dates) into camelCase and typed objects for the UI. Use operation-specific names like mapEntityToFrontend / mapEntitiesToFrontend — never a literal mapToFrontend.
1import { formatDate } from "@/shared/lib/utils";
2import type { IPaginationResponse } from "@/shared/types";
3import type { TEntityBackend, IEntity, TEntitiesBackend } from "../types";
4
5export const mapEntityToFrontend = (
6 data: TEntityBackend
7): IEntity => ({
8 id: data.id,
9 fullName: data.full_name,
10 dateCreated: formatDate(data.created_at),
11 status: data.status_code
12});
13
14/** Backend list envelope → unified { data, total } */
15export const mapEntitiesToFrontend = (
16 response: TEntitiesBackend
17): IPaginationResponse<IEntity> => ({
18 data: response.items.map(mapEntityToFrontend),
19 total: response.total_count
20});Preparing data before sending. Used when creating or updating entities to return to the DTO format expected by the API.
1import type { TEntityBackend, IEntity } from "../types";
2
3export const mapEntityToBackend = (
4 data: Partial<IEntity>
5): Partial<TEntityBackend> => ({
6 id: data.id,
7 full_name: data.fullName,
8 created_at: data.dateCreated,
9 status_code: data.status
10});A special case of mapping search filter state into query parameters. Allows convenient array joining, handling empty values, and API-specific formats.
1import type { IPaginationRequest } from "@/shared/types";
2import type { TEntitiesQueryBackend } from "../types";
3
4/** IPaginationRequest → API-specific query fields (names stay here) */
5export const mapEntitiesQueryToBackend = (
6 filters: IPaginationRequest
7): TEntitiesQueryBackend => ({
8 page: filters.page,
9 elements_on_page: filters.limit,
10 search: filters.search || undefined,
11 status_code: filters.status
12});Converters are injected directly into endpoint definitions: transformResponse for data retrieval and query (params/body) for data submission. Importing converters into pages/widgets/features is forbidden — UI already gets frontend models.
1import { baseApi } from "@/shared/api/backend";
2import type { IPaginationRequest, IPaginationResponse } from "@/shared/types";
3import {
4 mapEntitiesQueryToBackend,
5 mapEntitiesToFrontend,
6 mapEntityToBackend
7} from "../converters";
8import type { TEntitiesBackend, IEntity } from "../types";
9
10/** Only place converters are imported — never in pages/widgets/features/ui */
11export const entityApi = baseApi.injectEndpoints({
12 endpoints: (builder) => ({
13 getEntities: builder.query<
14 IPaginationResponse<IEntity>,
15 IPaginationRequest
16 >({
17 query: (filters) => ({
18 url: "/entity/list",
19 params: mapEntitiesQueryToBackend(filters)
20 }),
21 transformResponse: (response: TEntitiesBackend) =>
22 mapEntitiesToFrontend(response)
23 }),
24 updateEntity: builder.mutation<void, IEntity>({
25 query: (body) => ({
26 url: "/entity/update",
27 method: "POST",
28 body: mapEntityToBackend(body)
29 })
30 })
31 })
32});no-restricted-imports (block @/entities/*/converters) and eslint-plugin-boundaries (only same-entity api/ may import converters). Relative ../converters from api/ stays allowed.1import boundaries from "eslint-plugin-boundaries";
2
3export default [
4 {
5 plugins: { boundaries },
6 settings: {
7 "boundaries/elements": [
8 {
9 type: "entity-converters",
10 pattern: "src/entities/*/converters/**",
11 capture: ["entity"]
12 },
13 {
14 type: "entity-api",
15 pattern: "src/entities/*/api/**",
16 capture: ["entity"]
17 },
18 { type: "pages", pattern: "src/pages/**" },
19 { type: "widgets", pattern: "src/widgets/**" },
20 { type: "features", pattern: "src/features/**" },
21 { type: "entities", pattern: "src/entities/**" },
22 { type: "shared", pattern: "src/shared/**" }
23 ],
24 "boundaries/ignore": ["**/*.{test,spec}.{ts,tsx}"]
25 },
26 rules: {
27 // 1) Block public/alias imports of converters (api uses relative ../converters)
28 "no-restricted-imports": [
29 "error",
30 {
31 patterns: [
32 {
33 group: [
34 "**/entities/*/converters",
35 "**/entities/*/converters/**",
36 "@/entities/*/converters",
37 "@/entities/*/converters/**"
38 ],
39 message:
40 "Converters are private to the entity. Import only from entities/<name>/api via relative '../converters'. UI must use frontend models from RTK hooks."
41 }
42 ]
43 }
44 ],
45 // 2) Only same-entity api may depend on converters segment
46 "boundaries/element-types": [
47 "error",
48 {
49 default: "disallow",
50 rules: [
51 {
52 from: { type: "entity-api", capture: ["entity"] },
53 allow: [
54 { type: "entity-converters", capture: ["entity"] },
55 "entities",
56 "shared"
57 ]
58 },
59 {
60 from: "entity-converters",
61 allow: ["entities", "shared"]
62 },
63 {
64 from: "pages",
65 allow: ["widgets", "features", "entities", "shared"]
66 },
67 {
68 from: "widgets",
69 allow: ["features", "entities", "shared"]
70 },
71 {
72 from: "features",
73 allow: ["entities", "shared", "features"]
74 },
75 {
76 from: "entities",
77 allow: ["entities", "shared"]
78 },
79 {
80 from: "shared",
81 allow: ["shared", "entities"]
82 }
83 ]
84 }
85 ],
86 "boundaries/no-private": "error"
87 }
88 }
89];