Skip to content

Data Transformers

Purpose: Separate data transformation logic from API call logic, enabling clean DTO ↔ Entity mapping with type safety.

Location: packages/core-api/src/data-services/base-data.transformer.ts


Why Data Transformers?

In enterprise applications, the shape of data returned by the API (DTOs) often differs from the shape used in the frontend (Domain Entities). Common differences include:

API (DTO)Frontend (Entity)
snake_case field namescamelCase field names
Deeply nested structuresFlattened/normalized shapes
Raw ISO date stringsParsed Date objects
No computed fieldsDerived/computed properties
Backend-specific enumsFrontend-friendly enums

Without transformers, this mapping logic leaks into components, hooks, and services — violating the Single Responsibility Principle and making the codebase harder to test and maintain.

Benefits

  • Separation of Concerns — Transformation logic lives in one place, not scattered across components
  • Testability — Transformers are pure functions, trivially unit-testable
  • Reusability — Same transformer can be used across multiple services or contexts
  • Type Safety — Two generic parameters (TEntity, TDTO) enforce correct mapping at compile time
  • Backward Compatible — Transformers are optional; existing services work unchanged

Architecture

Data flows:

  • API → Frontend: Response DTO → transformToEntity() → Domain Entity
  • Frontend → API: Domain Entity → transformToDTO() → Request DTO

Quick Start

1. Define Your Types

typescript
// Domain Entity (what your UI uses)
interface BookingEntity extends BaseEntity {
  bookingCode: string;
  customerName: string;
  checkInDate: string;
}

// API DTO (what the backend returns)
interface BookingDTO {
  id?: string;
  booking_code: string;
  customer_name: string;
  check_in_date: string;
}

2. Create a Transformer

typescript
import { BaseDataTransformer } from '@repo/core-api/data-services';

class BookingTransformer extends BaseDataTransformer<BookingEntity, BookingDTO> {
  transformToEntity(dto: BookingDTO): BookingEntity {
    return {
      id: dto.id,
      bookingCode: dto.booking_code,
      customerName: dto.customer_name,
      checkInDate: dto.check_in_date,
    };
  }

  transformToDTO(entity: BookingEntity): BookingDTO {
    return {
      id: entity.id,
      booking_code: entity.bookingCode,
      customer_name: entity.customerName,
      check_in_date: entity.checkInDate,
    };
  }
}

3. Inject into Data Services

typescript
import { CommonRemoteDataServices } from '@repo/core-api/data-services';

const bookingServices = new CommonRemoteDataServices<BookingEntity, BookingDTO>(
  apiClient,
  {
    apiUrl: '/bookings',
    moduleKey: 'BOOKING',
    transformer: new BookingTransformer(),
  },
);

// Now all CRUD methods automatically transform:
const { data } = await bookingServices.getOne('42');
// data is BookingEntity (camelCase) ✓

await bookingServices.create({ bookingCode: 'BK001', customerName: 'Alice', ... });
// Payload is sent as { booking_code: 'BK001', customer_name: 'Alice', ... } ✓

Interface Reference

IDataTransformer<TEntity, TDTO>

The minimal contract for bidirectional data transformation.

typescript
interface IDataTransformer<TEntity, TDTO> {
  transformToEntity(dto: TDTO): TEntity;
  transformToDTO(entity: TEntity): TDTO;

  // Optional operation-specific hooks
  transformGetOneResponse?(dto: TDTO): TEntity;
  transformGetManyResponse?(dtos: TDTO[]): TEntity[];
  transformCreatePayload?(entity: Partial<TEntity>): Partial<TDTO>;
  transformEditPayload?(entity: Partial<TEntity>): Partial<TDTO>;
}

BaseDataTransformer<TEntity, TDTO>

Abstract class implementing IDataTransformer with sensible defaults.

MethodDefault BehaviorOverride When
transformToEntityIdentity cast (passthrough)Always — this is the core mapping
transformToDTOIdentity cast (passthrough)Always — this is the core mapping
transformGetOneResponseDelegates to transformToEntitygetOne needs computed/derived fields
transformGetManyResponseMaps each item via transformToEntityList responses need bulk transformations
transformCreatePayloadDelegates to transformToDTOCreate payloads need special handling (e.g., strip IDs)
transformEditPayloadDelegates to transformToDTOEdit payloads differ from create

Integration with BaseRemoteDataServices

When a transformer is injected via DataServicesConfig.transformer, the base service methods automatically apply transformations:

Service MethodTransformer Hook UsedDirection
getOne()transformGetOneResponse()Response → Entity
getMany()transformGetManyResponse()Response → Entity
create()transformCreatePayload()Entity → DTO
edit()transformEditPayload()Entity → DTO
delete()None (no data transformation)
customRequest()None (manual transformation)

Important: If no transformer is injected, all methods behave exactly as before — data passes through unchanged. This ensures 100% backward compatibility.


Advanced: Extending Transformers

For domain-specific features that go beyond standard CRUD, you can extend both the transformer and the data service.

Extended Transformer

typescript
// advanced-booking.transformer.ts
import { BookingTransformer } from './booking.transformer';

interface AvailabilityChartRawData {
  dates: Array<{
    date_iso: string;
    available_rooms: number;
    occupancy_rate: number;
  }>;
}

interface AvailabilityChartData {
  dataPoints: Array<{
    label: string;
    availableRooms: number;
    isHighDemand: boolean;
  }>;
}

class AdvancedBookingTransformer extends BookingTransformer {
  // All standard CRUD mappings are inherited ✓

  // Add custom transformation for non-CRUD data
  transformAvailabilityChart(rawData: AvailabilityChartRawData): AvailabilityChartData {
    return {
      dataPoints: rawData.dates.map((item) => ({
        label: new Date(item.date_iso).toLocaleDateString('en-US', {
          weekday: 'short',
          month: 'short',
          day: 'numeric',
        }),
        availableRooms: item.available_rooms,
        isHighDemand: item.occupancy_rate > 80,
      })),
    };
  }
}

Extended Data Service

typescript
// advanced-booking.data-services.ts
import { BaseRemoteDataServices } from '@repo/core-api/data-services';

class AdvancedBookingDataServices extends BaseRemoteDataServices<BookingEntity, BookingDTO> {
  private readonly advancedTransformer: AdvancedBookingTransformer;

  constructor() {
    const transformer = new AdvancedBookingTransformer();
    super(apiClient, {
      apiUrl: '/bookings',
      moduleKey: 'BOOKING',
      transformer,
    });
    this.advancedTransformer = transformer;
  }

  // Custom method using the extended transformer
  async getAvailabilityChart(params: {
    startDate: string;
    endDate: string;
  }): Promise<ApiResponse<AvailabilityChartData>> {
    const response = await this.customRequest<AvailabilityChartRawData>({
      url: '/bookings/availability-chart',
      method: 'GET',
      params: { start_date: params.startDate, end_date: params.endDate },
    });

    return {
      data: this.advancedTransformer.transformAvailabilityChart(response.data),
      status: response.status,
    };
  }
}

export const advancedBookingServices = new AdvancedBookingDataServices();

Migration Guide

Adding transformers to existing services requires zero breaking changes:

Step 1: Create a Transformer

typescript
class MyTransformer extends BaseDataTransformer<MyEntity, MyDTO> {
  transformToEntity(dto: MyDTO): MyEntity {
    /* ... */
  }
  transformToDTO(entity: MyEntity): MyDTO {
    /* ... */
  }
}

Step 2: Add a Second Generic Parameter

diff
- const services = new CommonRemoteDataServices<MyEntity>(apiClient, {
+ const services = new CommonRemoteDataServices<MyEntity, MyDTO>(apiClient, {
    apiUrl: '/my-endpoint',
+   transformer: new MyTransformer(),
  });

Step 3: (Optional) Override Operation-Specific Hooks

typescript
class MyTransformer extends BaseDataTransformer<MyEntity, MyDTO> {
  transformToEntity(dto: MyDTO): MyEntity {
    /* ... */
  }
  transformToDTO(entity: MyEntity): MyDTO {
    /* ... */
  }

  // Only override if getOne needs special handling
  override transformGetOneResponse(dto: MyDTO): MyEntity {
    const entity = this.transformToEntity(dto);
    return { ...entity, computedField: derive(dto) };
  }
}

Existing services without transformers are completely unaffected. The TDTO generic defaults to TEntity, and the transformer config property defaults to undefined.


Sample Implementation

A full working example is available in the showcase booking feature:

FileDescription
apps/showcase/.../booking/data/booking.transformer.tsBasic transformer with snake_case ↔ camelCase mapping
apps/showcase/.../booking/data/booking.data-services.tsData service with injected transformer
apps/showcase/.../booking/data/advanced-booking.transformer.tsExtended transformer with custom chart method
apps/showcase/.../booking/data/advanced-booking.data-services.tsExtended service with custom getAvailabilityChart()