diff --git a/apps/docs-dev/src/packages/core-api/transformers.md b/apps/docs-dev/src/packages/core-api/transformers.md
new file mode 100644
index 0000000..614eb39
--- /dev/null
+++ b/apps/docs-dev/src/packages/core-api/transformers.md
@@ -0,0 +1,323 @@
+# 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 names | `camelCase` field names |
+| Deeply nested structures | Flattened/normalized shapes |
+| Raw ISO date strings | Parsed `Date` objects |
+| No computed fields | Derived/computed properties |
+| Backend-specific enums | Frontend-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
+
+```mermaid
+graph LR
+ classDef api fill:#f59e0b,stroke:#b45309,color:#fff
+ classDef transformer fill:#6366f1,stroke:#4338ca,color:#fff
+ classDef entity fill:#10b981,stroke:#047857,color:#fff
+ classDef service fill:#3b82f6,stroke:#2563eb,color:#fff
+
+ API["🌐 REST API
(snake_case DTOs)"]:::api
+ SVC["BaseRemoteDataServices
(execute, getOne, getMany, ...)"]:::service
+ TFM["Data Transformer
(transformToEntity / transformToDTO)"]:::transformer
+ ENT["Domain Entity
(camelCase, computed fields)"]:::entity
+
+ API -->|"Response (DTO)"| SVC
+ SVC -->|"dto"| TFM
+ TFM -->|"entity"| ENT
+
+ ENT -->|"entity"| TFM
+ TFM -->|"dto"| SVC
+ SVC -->|"Request (DTO)"| API
+```
+
+**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 {
+ 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(
+ 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`
+
+The minimal contract for bidirectional data transformation.
+
+```typescript
+interface IDataTransformer {
+ transformToEntity(dto: TDTO): TEntity;
+ transformToDTO(entity: TEntity): TDTO;
+
+ // Optional operation-specific hooks
+ transformGetOneResponse?(dto: TDTO): TEntity;
+ transformGetManyResponse?(dtos: TDTO[]): TEntity[];
+ transformCreatePayload?(entity: Partial): Partial;
+ transformEditPayload?(entity: Partial): Partial;
+}
+```
+
+### `BaseDataTransformer`
+
+Abstract class implementing `IDataTransformer` with sensible defaults.
+
+| Method | Default Behavior | Override When |
+| ------------------------- | ---------------------------------------- | ------------------------------------------ |
+| `transformToEntity` | Identity cast (passthrough) | Always — this is the core mapping |
+| `transformToDTO` | Identity cast (passthrough) | Always — this is the core mapping |
+| `transformGetOneResponse` | Delegates to `transformToEntity` | `getOne` needs computed/derived fields |
+| `transformGetManyResponse`| Maps each item via `transformToEntity` | List responses need bulk transformations |
+| `transformCreatePayload` | Delegates to `transformToDTO` | Create payloads need special handling (e.g., strip IDs) |
+| `transformEditPayload` | Delegates to `transformToDTO` | Edit payloads differ from create |
+
+---
+
+## Integration with `BaseRemoteDataServices`
+
+When a transformer is injected via `DataServicesConfig.transformer`, the base service methods automatically apply transformations:
+
+| Service Method | Transformer Hook Used | Direction |
+| -------------- | -------------------------------- | --------------- |
+| `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 {
+ 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> {
+ const response = await this.customRequest({
+ 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 {
+ transformToEntity(dto: MyDTO): MyEntity { /* ... */ }
+ transformToDTO(entity: MyEntity): MyDTO { /* ... */ }
+}
+```
+
+### Step 2: Add a Second Generic Parameter
+
+```diff
+- const services = new CommonRemoteDataServices(apiClient, {
++ const services = new CommonRemoteDataServices(apiClient, {
+ apiUrl: '/my-endpoint',
++ transformer: new MyTransformer(),
+ });
+```
+
+### Step 3: (Optional) Override Operation-Specific Hooks
+
+```typescript
+class MyTransformer extends BaseDataTransformer {
+ 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:
+
+| File | Description |
+| ---- | ----------- |
+| `apps/web/.../booking/data/booking.transformer.ts` | Basic transformer with snake_case ↔ camelCase mapping |
+| `apps/web/.../booking/data/booking.data-services.ts` | Data service with injected transformer |
+| `apps/web/.../booking/data/advanced-booking.transformer.ts` | Extended transformer with custom chart method |
+| `apps/web/.../booking/data/advanced-booking.data-services.ts` | Extended service with custom `getAvailabilityChart()` |
diff --git a/apps/web/src/apps/showcase/example/features/booking/data/advanced-booking.data-services.ts b/apps/web/src/apps/showcase/example/features/booking/data/advanced-booking.data-services.ts
new file mode 100644
index 0000000..929c940
--- /dev/null
+++ b/apps/web/src/apps/showcase/example/features/booking/data/advanced-booking.data-services.ts
@@ -0,0 +1,118 @@
+import { BaseRemoteDataServices } from '@repo/core-api/data-services';
+import type { ApiResponse } from '@repo/core-api/http-client';
+import { apiClient } from '../../../../../../core/lib/api-client';
+import type { BookingEntity } from './booking.data-services';
+import type { BookingDTO } from './booking.transformer';
+import {
+ AdvancedBookingTransformer,
+ type AvailabilityChartRawData,
+ type AvailabilityChartData,
+} from './advanced-booking.transformer';
+
+// ─── Advanced Booking Data Services ─────────────────────────────
+
+/**
+ * Extended booking data services with custom methods for
+ * advanced booking features beyond standard CRUD.
+ *
+ * Extends {@link BaseRemoteDataServices} directly (instead of using
+ * `CommonRemoteDataServices`) to add domain-specific methods like
+ * `getAvailabilityChart()`.
+ *
+ * Uses {@link AdvancedBookingTransformer} which provides:
+ * - All standard DTO ↔ Entity mappings (inherited from BookingTransformer)
+ * - Custom `transformAvailabilityChart()` for chart data
+ * - Enhanced `transformGetManyResponse()` with status normalization
+ *
+ * @example
+ * ```ts
+ * // Standard CRUD (inherited, with transformer)
+ * const { data: bookings } = await advancedBookingServices.getMany();
+ * const { data: booking } = await advancedBookingServices.getOne('42');
+ *
+ * // Custom method for chart data
+ * const { data: chartData } = await advancedBookingServices.getAvailabilityChart({
+ * startDate: '2026-07-01',
+ * endDate: '2026-07-31',
+ * });
+ * ```
+ */
+class AdvancedBookingDataServices extends BaseRemoteDataServices {
+ /**
+ * The concrete advanced transformer instance.
+ *
+ * Stored separately from the base `transformer` property
+ * to access custom methods (like `transformAvailabilityChart`)
+ * that aren't part of the `IDataTransformer` interface.
+ */
+ private readonly advancedTransformer: AdvancedBookingTransformer;
+
+ constructor() {
+ const advancedTransformer = new AdvancedBookingTransformer();
+
+ super(apiClient, {
+ apiUrl: '/bookings',
+ moduleKey: 'BOOKING',
+ transformer: advancedTransformer,
+ });
+
+ this.advancedTransformer = advancedTransformer;
+ }
+
+ // ─── Custom Methods ─────────────────────────────────────────
+
+ /**
+ * Fetch the availability chart data for a given date range.
+ *
+ * Calls the `/bookings/availability-chart` endpoint and transforms
+ * the raw API response into a UI-friendly chart format using
+ * {@link AdvancedBookingTransformer.transformAvailabilityChart}.
+ *
+ * @param params - Date range parameters for the chart query
+ * @param params.startDate - Start date (ISO format, e.g., '2026-07-01')
+ * @param params.endDate - End date (ISO format, e.g., '2026-07-31')
+ * @returns Transformed chart data ready for UI rendering
+ *
+ * @example
+ * ```ts
+ * const { data } = await advancedBookingServices.getAvailabilityChart({
+ * startDate: '2026-07-01',
+ * endDate: '2026-07-31',
+ * });
+ *
+ * // data.dataPoints → Array of chart-ready data points
+ * // data.summary → Aggregated metrics for the period
+ * ```
+ */
+ async getAvailabilityChart(params: {
+ startDate: string;
+ endDate: string;
+ }): Promise> {
+ const response = await this.customRequest({
+ 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,
+ };
+ }
+}
+
+// ─── Singleton Export ────────────────────────────────────────────
+
+/**
+ * Pre-configured advanced booking data services instance.
+ *
+ * Use this when you need both standard CRUD operations and
+ * custom methods like `getAvailabilityChart()`.
+ *
+ * For standard CRUD-only usage, prefer `bookingServices` from
+ * `booking.data-services.ts` instead.
+ */
+export const advancedBookingServices = new AdvancedBookingDataServices();
diff --git a/apps/web/src/apps/showcase/example/features/booking/data/advanced-booking.transformer.ts b/apps/web/src/apps/showcase/example/features/booking/data/advanced-booking.transformer.ts
new file mode 100644
index 0000000..657d803
--- /dev/null
+++ b/apps/web/src/apps/showcase/example/features/booking/data/advanced-booking.transformer.ts
@@ -0,0 +1,159 @@
+import { BookingTransformer } from './booking.transformer';
+import type { BookingDTO } from './booking.transformer';
+import type { BookingEntity } from './booking.data-services';
+
+// ─── Advanced Types ─────────────────────────────────────────────
+
+/**
+ * Raw availability chart data as returned by the API.
+ *
+ * The backend returns a flat structure with snake_case keys
+ * and ISO date strings. This needs to be transformed into
+ * a more UI-friendly shape for chart rendering.
+ */
+export interface AvailabilityChartRawData {
+ dates: Array<{
+ date_iso: string;
+ available_rooms: number;
+ total_rooms: number;
+ occupancy_rate: number;
+ revenue_per_room: number;
+ }>;
+ summary: {
+ avg_occupancy_rate: number;
+ total_revenue: number;
+ period_start: string;
+ period_end: string;
+ };
+}
+
+/**
+ * UI-friendly availability chart data.
+ *
+ * Pre-computed for direct rendering in chart components
+ * with camelCase fields, formatted labels, and derived metrics.
+ */
+export interface AvailabilityChartData {
+ /** Data points ready for chart rendering. */
+ dataPoints: Array<{
+ /** Formatted date label (e.g., 'Mon, Jul 1'). */
+ label: string;
+ /** ISO date string for programmatic use. */
+ dateISO: string;
+ /** Number of rooms available. */
+ availableRooms: number;
+ /** Total room capacity. */
+ totalRooms: number;
+ /** Occupancy rate as a percentage (0-100). */
+ occupancyRate: number;
+ /** Revenue per available room. */
+ revenuePerRoom: number;
+ /** Whether the day is a high-demand day (>80% occupancy). */
+ isHighDemand: boolean;
+ }>;
+ /** Aggregated summary metrics for the period. */
+ summary: {
+ averageOccupancy: number;
+ totalRevenue: number;
+ periodStart: string;
+ periodEnd: string;
+ /** Number of high-demand days in the period. */
+ highDemandDays: number;
+ };
+}
+
+// ─── Advanced Booking Transformer ───────────────────────────────
+
+/**
+ * Extended booking transformer with additional custom methods
+ * for non-CRUD data transformations.
+ *
+ * Inherits all standard DTO ↔ Entity mapping from
+ * {@link BookingTransformer} and adds domain-specific
+ * transformations for advanced features like availability charts.
+ *
+ * **When to extend vs. create new:**
+ * - Extend when the new transformer shares the same entity/DTO pair
+ * and you need additional transformation methods
+ * - Create a new transformer when the entity/DTO types are different
+ *
+ * @example
+ * ```ts
+ * const transformer = new AdvancedBookingTransformer();
+ *
+ * // Standard CRUD mapping (inherited)
+ * const entity = transformer.transformToEntity(bookingDTO);
+ *
+ * // Custom chart transformation (new)
+ * const chartData = transformer.transformAvailabilityChart(rawChartData);
+ * ```
+ */
+export class AdvancedBookingTransformer extends BookingTransformer {
+ /**
+ * Transform raw availability chart data from the API into a
+ * UI-friendly format for chart rendering.
+ *
+ * Performs the following transformations:
+ * 1. Maps snake_case fields to camelCase
+ * 2. Formats date strings into human-readable labels
+ * 3. Computes derived `isHighDemand` flag (>80% occupancy)
+ * 4. Aggregates `highDemandDays` count in the summary
+ *
+ * @param rawData - Raw chart data from the `/bookings/availability-chart` endpoint
+ * @returns Transformed chart data ready for UI rendering
+ */
+ transformAvailabilityChart(rawData: AvailabilityChartRawData): AvailabilityChartData {
+ const HIGH_DEMAND_THRESHOLD = 80;
+
+ const dataPoints = rawData.dates.map((item) => {
+ const date = new Date(item.date_iso);
+ const isHighDemand = item.occupancy_rate > HIGH_DEMAND_THRESHOLD;
+
+ return {
+ label: date.toLocaleDateString('en-US', {
+ weekday: 'short',
+ month: 'short',
+ day: 'numeric',
+ }),
+ dateISO: item.date_iso,
+ availableRooms: item.available_rooms,
+ totalRooms: item.total_rooms,
+ occupancyRate: item.occupancy_rate,
+ revenuePerRoom: item.revenue_per_room,
+ isHighDemand,
+ };
+ });
+
+ const highDemandDays = dataPoints.filter((dp) => dp.isHighDemand).length;
+
+ return {
+ dataPoints,
+ summary: {
+ averageOccupancy: rawData.summary.avg_occupancy_rate,
+ totalRevenue: rawData.summary.total_revenue,
+ periodStart: rawData.summary.period_start,
+ periodEnd: rawData.summary.period_end,
+ highDemandDays,
+ },
+ };
+ }
+
+ /**
+ * Enhanced getMany response that also normalizes status values.
+ *
+ * Demonstrates overriding an inherited hook to add
+ * additional processing on top of the base transformation.
+ *
+ * @param dtos - Array of booking DTOs from the API
+ * @returns Transformed entities with normalized status
+ */
+ override transformGetManyResponse(dtos: BookingDTO[]): BookingEntity[] {
+ return super.transformGetManyResponse(dtos).map((entity) => ({
+ ...entity,
+ // Normalize 'cancelled' vs 'canceled' from different API versions
+ status: entity.status === ('canceled' as BookingEntity['status'])
+ ? 'cancelled'
+ : entity.status,
+ }));
+ }
+}
diff --git a/apps/web/src/apps/showcase/example/features/booking/data/booking.data-services.ts b/apps/web/src/apps/showcase/example/features/booking/data/booking.data-services.ts
index 2c2d8a0..b65ce28 100644
--- a/apps/web/src/apps/showcase/example/features/booking/data/booking.data-services.ts
+++ b/apps/web/src/apps/showcase/example/features/booking/data/booking.data-services.ts
@@ -1,6 +1,8 @@
import { CommonRemoteDataServices } from '@repo/core-api/data-services';
import type { BaseEntity } from '@repo/core-api/data-services';
import { apiClient } from '../../../../../../core/lib/api-client';
+import { BookingTransformer } from './booking.transformer';
+import type { BookingDTO } from './booking.transformer';
// ─── Domain Entity ──────────────────────────────────────────────
@@ -22,19 +24,35 @@ export interface BookingEntity extends BaseEntity {
// ─── Data Services Instance ─────────────────────────────────────
/**
- * Booking data services — wired to the enterprise `apiClient`.
+ * Booking data services — wired to the enterprise `apiClient`
+ * with automatic DTO ↔ Entity transformation.
*
* All requests flow through the full interceptor chain:
* Faro tracing → Bearer token injection → ApiError normalization.
*
+ * The injected {@link BookingTransformer} automatically:
+ * - Maps snake_case API responses to camelCase entities on `getOne`/`getMany`
+ * - Maps camelCase entity payloads to snake_case DTOs on `create`/`edit`
+ * - Strips `id` from create payloads
+ * - Computes `durationNights` on `getOne` responses
+ *
* @example
* ```ts
* const { data } = await bookingServices.getMany({ params: { page: 1 } });
+ * // data is BookingEntity[] with camelCase fields
+ *
* const { data: booking } = await bookingServices.getOne('42');
+ * // booking is BookingEntity with computed durationNights
+ *
+ * await bookingServices.create({ bookingCode: 'BK001', customerName: 'Alice', ... });
+ * // Payload is automatically transformed to { booking_code: 'BK001', customer_name: 'Alice', ... }
+ *
* await bookingServices.confirmProcessTransaction('42');
* ```
*/
-export const bookingServices = new CommonRemoteDataServices(apiClient, {
+export const bookingServices = new CommonRemoteDataServices(apiClient, {
apiUrl: '/bookings',
moduleKey: 'BOOKING',
+ transformer: new BookingTransformer(),
});
+
diff --git a/apps/web/src/apps/showcase/example/features/booking/data/booking.transformer.ts b/apps/web/src/apps/showcase/example/features/booking/data/booking.transformer.ts
new file mode 100644
index 0000000..b37bc63
--- /dev/null
+++ b/apps/web/src/apps/showcase/example/features/booking/data/booking.transformer.ts
@@ -0,0 +1,123 @@
+import { BaseDataTransformer } from '@repo/core-api/data-services';
+import type { BookingEntity } from './booking.data-services';
+
+// ─── Booking DTO (API Response Shape) ───────────────────────────
+
+/**
+ * Raw booking data as returned by the API.
+ *
+ * Uses snake_case field names matching the backend's JSON serialization.
+ * This DTO is never used directly in UI components — it is transformed
+ * into a {@link BookingEntity} by the {@link BookingTransformer}.
+ */
+export interface BookingDTO {
+ id?: string;
+ booking_code: string;
+ customer_name: string;
+ check_in_date: string;
+ check_out_date: string;
+ status: 'pending' | 'confirmed' | 'cancelled';
+ total_amount: number;
+}
+
+// ─── Booking Transformer ────────────────────────────────────────
+
+/**
+ * Transforms between the API's `BookingDTO` (snake_case) and
+ * the frontend's `BookingEntity` (camelCase).
+ *
+ * Handles:
+ * - Field name mapping (snake_case ↔ camelCase)
+ * - Computed field derivation (e.g., `durationNights` on `getOne`)
+ * - Payload sanitization (e.g., stripping `id` on create)
+ *
+ * @example
+ * ```ts
+ * const transformer = new BookingTransformer();
+ *
+ * // API response → Domain entity
+ * const entity = transformer.transformToEntity({
+ * id: '42',
+ * booking_code: 'BK042',
+ * customer_name: 'Alice',
+ * check_in_date: '2026-07-01',
+ * check_out_date: '2026-07-03',
+ * status: 'confirmed',
+ * total_amount: 500000,
+ * });
+ * // → { id: '42', bookingCode: 'BK042', customerName: 'Alice', ... }
+ * ```
+ */
+export class BookingTransformer extends BaseDataTransformer {
+ /**
+ * Map an API booking DTO to a frontend booking entity.
+ *
+ * @param dto - Raw booking data from the API
+ * @returns Mapped booking entity with camelCase fields
+ */
+ override transformToEntity(dto: BookingDTO): BookingEntity {
+ return {
+ id: dto.id,
+ bookingCode: dto.booking_code,
+ customerName: dto.customer_name,
+ checkInDate: dto.check_in_date,
+ checkOutDate: dto.check_out_date,
+ status: dto.status,
+ totalAmount: dto.total_amount,
+ };
+ }
+
+ /**
+ * Map a frontend booking entity to an API booking DTO.
+ *
+ * @param entity - Booking entity from the frontend
+ * @returns Mapped booking DTO with snake_case fields
+ */
+ override transformToDTO(entity: BookingEntity): BookingDTO {
+ return {
+ id: entity.id,
+ booking_code: entity.bookingCode,
+ customer_name: entity.customerName,
+ check_in_date: entity.checkInDate,
+ check_out_date: entity.checkOutDate,
+ status: entity.status,
+ total_amount: entity.totalAmount,
+ };
+ }
+
+ /**
+ * Transform a single booking response with computed fields.
+ *
+ * Adds `durationNights` as a derived convenience field
+ * that is only relevant when viewing a single booking detail.
+ *
+ * @param dto - Raw booking DTO from the API
+ * @returns Booking entity with computed fields
+ */
+ override transformGetOneResponse(dto: BookingDTO): BookingEntity {
+ const entity = this.transformToEntity(dto);
+ const checkIn = new Date(dto.check_in_date);
+ const checkOut = new Date(dto.check_out_date);
+ const durationMs = checkOut.getTime() - checkIn.getTime();
+ const durationNights = Math.max(0, Math.ceil(durationMs / (1000 * 60 * 60 * 24)));
+
+ return {
+ ...entity,
+ // Attach computed field via type assertion since
+ // durationNights is a view-layer convenience
+ ...(durationNights > 0 ? { durationNights } : {}),
+ };
+ }
+
+ /**
+ * Strip `id` from create payloads since the backend generates IDs.
+ *
+ * @param entity - Partial booking entity from the create form
+ * @returns Sanitized DTO payload without `id`
+ */
+ override transformCreatePayload(entity: Partial): Partial {
+ const dto = this.transformToDTO(entity as BookingEntity);
+ const { id: _, ...rest } = dto;
+ return rest;
+ }
+}
diff --git a/packages/core-api/src/data-services/base-data.transformer.ts b/packages/core-api/src/data-services/base-data.transformer.ts
new file mode 100644
index 0000000..58dfc53
--- /dev/null
+++ b/packages/core-api/src/data-services/base-data.transformer.ts
@@ -0,0 +1,221 @@
+import type { BaseEntity } from './types';
+
+// ─── Core Transformer Interface ─────────────────────────────────
+
+/**
+ * Minimal contract for bidirectional data transformation
+ * between API Data Transfer Objects (DTOs) and domain entities.
+ *
+ * Implement this interface when you only need the two core
+ * mapping methods without operation-specific hooks.
+ *
+ * @typeParam TEntity - The frontend domain entity type
+ * @typeParam TDTO - The API response/request DTO shape
+ *
+ * @example
+ * ```ts
+ * class UserTransformer implements IDataTransformer {
+ * transformToEntity(dto: UserDTO): UserEntity {
+ * return { id: dto.id, fullName: `${dto.first_name} ${dto.last_name}` };
+ * }
+ * transformToDTO(entity: UserEntity): UserDTO {
+ * const [first, ...rest] = entity.fullName.split(' ');
+ * return { id: entity.id, first_name: first, last_name: rest.join(' ') };
+ * }
+ * }
+ * ```
+ */
+export interface IDataTransformer<
+ TEntity extends BaseEntity = BaseEntity,
+ TDTO = TEntity,
+> {
+ /**
+ * Transform an API DTO into a domain entity.
+ *
+ * Called after receiving data from the API. Use this to map
+ * snake_case fields to camelCase, flatten nested structures,
+ * compute derived fields, or apply any normalization.
+ *
+ * @param dto - Raw data from the API response
+ * @returns The mapped domain entity
+ */
+ transformToEntity(dto: TDTO): TEntity;
+
+ /**
+ * Transform a domain entity into an API DTO.
+ *
+ * Called before sending data to the API. Use this to map
+ * camelCase fields to snake_case, restructure nested objects,
+ * or strip frontend-only computed fields.
+ *
+ * @param entity - Domain entity from the frontend
+ * @returns The mapped DTO for the API request
+ */
+ transformToDTO(entity: TEntity): TDTO;
+
+ // ─── Optional Operation-Specific Hooks ──────────────────────
+
+ /**
+ * Transform the response of a `getOne()` call.
+ * If not provided, falls back to `transformToEntity`.
+ */
+ transformGetOneResponse?(dto: TDTO): TEntity;
+
+ /**
+ * Transform the response of a `getMany()` call.
+ * If not provided, falls back to mapping each item via `transformToEntity`.
+ */
+ transformGetManyResponse?(dtos: TDTO[]): TEntity[];
+
+ /**
+ * Transform the payload before a `create()` call.
+ * If not provided, falls back to `transformToDTO`.
+ */
+ transformCreatePayload?(entity: Partial): Partial;
+
+ /**
+ * Transform the payload before an `edit()` call.
+ * If not provided, falls back to `transformToDTO`.
+ */
+ transformEditPayload?(entity: Partial): Partial;
+}
+
+// ─── Abstract Base Transformer ──────────────────────────────────
+
+/**
+ * Abstract base class providing operation-specific transformation
+ * hooks with sensible passthrough defaults.
+ *
+ * Extends the core `IDataTransformer` contract with granular hooks
+ * for each CRUD operation. Override only the hooks you need —
+ * unoverridden hooks delegate to the core `transformToEntity` /
+ * `transformToDTO` methods.
+ *
+ * **Design rationale (from legacy analysis):**
+ * The legacy `BaseTransformer` used operation-specific methods
+ * (`transformerGetOne`, `transformerCreate`, etc.) because different
+ * operations often need different transformations. For example,
+ * `getOne` might need to compute derived fields, while `create`
+ * might need to strip IDs. This pattern is preserved here with
+ * proper typing.
+ *
+ * @typeParam TEntity - The frontend domain entity type
+ * @typeParam TDTO - The API response/request DTO shape
+ *
+ * @example
+ * ```ts
+ * class BookingTransformer extends BaseDataTransformer {
+ * transformToEntity(dto: BookingDTO): BookingEntity {
+ * return {
+ * id: dto.id,
+ * bookingCode: dto.booking_code,
+ * customerName: dto.customer_name,
+ * };
+ * }
+ *
+ * transformToDTO(entity: BookingEntity): BookingDTO {
+ * return {
+ * id: entity.id,
+ * booking_code: entity.bookingCode,
+ * customer_name: entity.customerName,
+ * };
+ * }
+ *
+ * // Override only when getOne needs extra computed fields
+ * transformGetOneResponse(dto: BookingDTO): BookingEntity {
+ * const entity = this.transformToEntity(dto);
+ * return { ...entity, durationNights: computeNights(dto) };
+ * }
+ * }
+ * ```
+ */
+export abstract class BaseDataTransformer<
+ TEntity extends BaseEntity = BaseEntity,
+ TDTO = TEntity,
+> implements IDataTransformer
+{
+ /**
+ * Core DTO → Entity transformation.
+ *
+ * Default implementation performs an identity cast.
+ * Override this in concrete transformers to provide
+ * the actual mapping logic.
+ *
+ * @param dto - Raw data from the API response
+ * @returns The mapped domain entity
+ */
+ transformToEntity(dto: TDTO): TEntity {
+ return dto as unknown as TEntity;
+ }
+
+ /**
+ * Core Entity → DTO transformation.
+ *
+ * Default implementation performs an identity cast.
+ * Override this in concrete transformers to provide
+ * the actual mapping logic.
+ *
+ * @param entity - Domain entity from the frontend
+ * @returns The mapped DTO for the API request
+ */
+ transformToDTO(entity: TEntity): TDTO {
+ return entity as unknown as TDTO;
+ }
+
+ // ─── Operation-Specific Hooks ───────────────────────────────
+
+ /**
+ * Transform the response of a `getOne()` call.
+ *
+ * Override this when `getOne` needs additional computed fields
+ * or different mapping than the default `transformToEntity`.
+ *
+ * @param dto - Single DTO from the API response
+ * @returns The mapped domain entity
+ */
+ transformGetOneResponse(dto: TDTO): TEntity {
+ return this.transformToEntity(dto);
+ }
+
+ /**
+ * Transform the response of a `getMany()` call.
+ *
+ * Override this when list responses need bulk transformations
+ * (e.g., sorting, filtering, or status normalization) beyond
+ * per-item mapping.
+ *
+ * @param dtos - Array of DTOs from the API response
+ * @returns Array of mapped domain entities
+ */
+ transformGetManyResponse(dtos: TDTO[]): TEntity[] {
+ return dtos.map((dto) => this.transformToEntity(dto));
+ }
+
+ /**
+ * Transform the payload before a `create()` call.
+ *
+ * Override this when create payloads need special handling
+ * (e.g., stripping IDs, formatting dates, converting nested
+ * structures).
+ *
+ * @param entity - Partial entity data from the frontend
+ * @returns The mapped partial DTO for the API request
+ */
+ transformCreatePayload(entity: Partial): Partial {
+ return this.transformToDTO(entity as TEntity) as Partial;
+ }
+
+ /**
+ * Transform the payload before an `edit()` call.
+ *
+ * Override this when edit payloads need special handling
+ * that differs from create (e.g., preserving certain
+ * read-only fields, handling delta updates).
+ *
+ * @param entity - Partial entity data from the frontend
+ * @returns The mapped partial DTO for the API request
+ */
+ transformEditPayload(entity: Partial): Partial {
+ return this.transformToDTO(entity as TEntity) as Partial;
+ }
+}
diff --git a/packages/core-api/src/data-services/base-remote.data-services.test.ts b/packages/core-api/src/data-services/base-remote.data-services.test.ts
index b19104b..abed529 100644
--- a/packages/core-api/src/data-services/base-remote.data-services.test.ts
+++ b/packages/core-api/src/data-services/base-remote.data-services.test.ts
@@ -1,7 +1,9 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { AxiosInstance, AxiosRequestConfig } from 'axios';
import type { BaseEntity } from './types';
+import type { IDataTransformer } from './base-data.transformer';
import { CommonRemoteDataServices } from './common-remote.data-services';
+import { BaseDataTransformer } from './base-data.transformer';
// ─── Mock AxiosInstance ─────────────────────────────────────────
@@ -261,3 +263,398 @@ describe('BaseRemoteDataServices (via CommonRemoteDataServices)', () => {
});
});
});
+
+// ═══════════════════════════════════════════════════════════════════
+// Data Transformer Integration Tests
+// ═══════════════════════════════════════════════════════════════════
+
+// ─── Test DTO (snake_case API shape) ────────────────────────────
+
+interface TestDTO {
+ id?: string;
+ booking_code: string;
+ customer_name: string;
+}
+
+interface TestEntity2 extends BaseEntity {
+ bookingCode: string;
+ customerName: string;
+}
+
+// ─── Concrete Transformer for Testing ───────────────────────────
+
+class TestTransformer extends BaseDataTransformer {
+ transformToEntity(dto: TestDTO): TestEntity2 {
+ return {
+ id: dto.id,
+ bookingCode: dto.booking_code,
+ customerName: dto.customer_name,
+ };
+ }
+
+ transformToDTO(entity: TestEntity2): TestDTO {
+ return {
+ id: entity.id,
+ booking_code: entity.bookingCode,
+ customer_name: entity.customerName,
+ };
+ }
+}
+
+// ─── Transformer with Custom Hooks ──────────────────────────────
+
+class CustomHookTransformer extends TestTransformer {
+ override transformGetOneResponse(dto: TestDTO): TestEntity2 {
+ const entity = this.transformToEntity(dto);
+ return { ...entity, customerName: entity.customerName.toUpperCase() };
+ }
+
+ override transformGetManyResponse(dtos: TestDTO[]): TestEntity2[] {
+ return dtos
+ .map((dto) => this.transformToEntity(dto))
+ .map((entity) => ({ ...entity, bookingCode: `LIST-${entity.bookingCode}` }));
+ }
+
+ override transformCreatePayload(entity: Partial): Partial {
+ const dto = super.transformCreatePayload(entity);
+ return { ...dto, id: undefined };
+ }
+
+ override transformEditPayload(entity: Partial): Partial {
+ const dto = super.transformEditPayload(entity);
+ return { ...dto, booking_code: `EDIT-${dto.booking_code}` };
+ }
+}
+
+// ─── Transformer Integration Tests ──────────────────────────────
+
+describe('BaseRemoteDataServices — Data Transformer Integration', () => {
+ let mockClient: AxiosInstance;
+
+ beforeEach(() => {
+ mockClient = createMockHttpClient();
+ });
+
+ // ── Without Transformer (backward compatibility) ──────────────
+
+ describe('without transformer (backward compatibility)', () => {
+ it('getOne() returns raw API response unchanged', async () => {
+ const rawDTO = { id: '42', booking_code: 'BK042', customer_name: 'Alice' };
+ (mockClient.request as ReturnType).mockResolvedValueOnce({
+ data: rawDTO,
+ status: 200,
+ });
+
+ const services = new CommonRemoteDataServices(mockClient, {
+ apiUrl: '/bookings',
+ });
+
+ const result = await services.getOne('42');
+ expect(result.data).toEqual(rawDTO);
+ });
+
+ it('getMany() returns raw API response unchanged', async () => {
+ const rawDTOs = [
+ { id: '1', booking_code: 'BK001', customer_name: 'Alice' },
+ { id: '2', booking_code: 'BK002', customer_name: 'Bob' },
+ ];
+ (mockClient.request as ReturnType).mockResolvedValueOnce({
+ data: rawDTOs,
+ status: 200,
+ });
+
+ const services = new CommonRemoteDataServices(mockClient, {
+ apiUrl: '/bookings',
+ });
+
+ const result = await services.getMany();
+ expect(result.data).toEqual(rawDTOs);
+ });
+
+ it('create() sends entity data as-is without transformation', async () => {
+ const services = new CommonRemoteDataServices(mockClient, {
+ apiUrl: '/bookings',
+ });
+
+ const entityData = { id: 'temp-1' };
+ await services.create(entityData);
+
+ const requestArg = (mockClient.request as ReturnType).mock.calls[0][0];
+ expect(requestArg.data).toEqual(entityData);
+ });
+ });
+
+ // ── With Base Transformer (core methods) ──────────────────────
+
+ describe('with transformer (core transformToEntity / transformToDTO)', () => {
+ let services: CommonRemoteDataServices;
+ const transformer = new TestTransformer();
+
+ beforeEach(() => {
+ services = new CommonRemoteDataServices(mockClient, {
+ apiUrl: '/bookings',
+ moduleKey: 'BOOKING',
+ transformer,
+ });
+ });
+
+ it('getOne() transforms API DTO to domain entity', async () => {
+ (mockClient.request as ReturnType).mockResolvedValueOnce({
+ data: { id: '42', booking_code: 'BK042', customer_name: 'Alice' },
+ status: 200,
+ });
+
+ const result = await services.getOne('42');
+
+ expect(result.data).toEqual({
+ id: '42',
+ bookingCode: 'BK042',
+ customerName: 'Alice',
+ });
+ expect(result.status).toBe(200);
+ });
+
+ it('getMany() transforms each DTO in the array to entities', async () => {
+ (mockClient.request as ReturnType).mockResolvedValueOnce({
+ data: [
+ { id: '1', booking_code: 'BK001', customer_name: 'Alice' },
+ { id: '2', booking_code: 'BK002', customer_name: 'Bob' },
+ ],
+ status: 200,
+ });
+
+ const result = await services.getMany();
+
+ expect(result.data).toEqual([
+ { id: '1', bookingCode: 'BK001', customerName: 'Alice' },
+ { id: '2', bookingCode: 'BK002', customerName: 'Bob' },
+ ]);
+ });
+
+ it('getMany() handles empty array response', async () => {
+ (mockClient.request as ReturnType).mockResolvedValueOnce({
+ data: [],
+ status: 200,
+ });
+
+ const result = await services.getMany();
+
+ expect(result.data).toEqual([]);
+ });
+
+ it('create() transforms entity payload to DTO before sending', async () => {
+ await services.create({
+ bookingCode: 'BK001',
+ customerName: 'Alice',
+ });
+
+ const requestArg = (mockClient.request as ReturnType).mock.calls[0][0];
+ expect(requestArg.data).toEqual({
+ id: undefined,
+ booking_code: 'BK001',
+ customer_name: 'Alice',
+ });
+ });
+
+ it('edit() transforms entity payload to DTO before sending', async () => {
+ await services.edit('42', {
+ bookingCode: 'BK042-UPDATED',
+ customerName: 'Bob',
+ });
+
+ const requestArg = (mockClient.request as ReturnType).mock.calls[0][0];
+ expect(requestArg.data).toEqual({
+ id: undefined,
+ booking_code: 'BK042-UPDATED',
+ customer_name: 'Bob',
+ });
+ expect(requestArg.url).toBe('/bookings/42');
+ });
+
+ it('delete() is unaffected by transformer (no data transformation needed)', async () => {
+ await services.delete('42');
+
+ expect(mockClient.request).toHaveBeenCalledWith(
+ expect.objectContaining({
+ url: '/bookings/42',
+ method: 'DELETE',
+ }),
+ );
+ });
+
+ it('customRequest() is unaffected by transformer', async () => {
+ await services.customRequest({
+ url: '/bookings/42/calculate-tax',
+ method: 'POST',
+ data: { items: [] },
+ });
+
+ const requestArg = (mockClient.request as ReturnType).mock.calls[0][0];
+ expect(requestArg.data).toEqual({ items: [] });
+ });
+ });
+
+ // ── With Custom Hook Transformer ──────────────────────────────
+
+ describe('with custom operation-specific hooks', () => {
+ let services: CommonRemoteDataServices;
+ const transformer = new CustomHookTransformer();
+
+ beforeEach(() => {
+ services = new CommonRemoteDataServices(mockClient, {
+ apiUrl: '/bookings',
+ moduleKey: 'BOOKING',
+ transformer,
+ });
+ });
+
+ it('getOne() uses transformGetOneResponse hook (uppercases customer name)', async () => {
+ (mockClient.request as ReturnType).mockResolvedValueOnce({
+ data: { id: '42', booking_code: 'BK042', customer_name: 'alice' },
+ status: 200,
+ });
+
+ const result = await services.getOne('42');
+
+ expect(result.data).toEqual({
+ id: '42',
+ bookingCode: 'BK042',
+ customerName: 'ALICE', // uppercased by custom hook
+ });
+ });
+
+ it('getMany() uses transformGetManyResponse hook (prefixes booking code)', async () => {
+ (mockClient.request as ReturnType).mockResolvedValueOnce({
+ data: [
+ { id: '1', booking_code: 'BK001', customer_name: 'Alice' },
+ ],
+ status: 200,
+ });
+
+ const result = await services.getMany();
+
+ expect(result.data).toEqual([
+ { id: '1', bookingCode: 'LIST-BK001', customerName: 'Alice' },
+ ]);
+ });
+
+ it('create() uses transformCreatePayload hook (strips id)', async () => {
+ await services.create({
+ id: 'should-be-removed',
+ bookingCode: 'BK001',
+ customerName: 'Alice',
+ });
+
+ const requestArg = (mockClient.request as ReturnType).mock.calls[0][0];
+ expect(requestArg.data.id).toBeUndefined();
+ expect(requestArg.data.booking_code).toBe('BK001');
+ });
+
+ it('edit() uses transformEditPayload hook (prefixes booking code)', async () => {
+ await services.edit('42', {
+ bookingCode: 'BK042',
+ customerName: 'Alice',
+ });
+
+ const requestArg = (mockClient.request as ReturnType).mock.calls[0][0];
+ expect(requestArg.data.booking_code).toBe('EDIT-BK042');
+ });
+ });
+
+ // ── Identity Transformer ──────────────────────────────────────
+
+ describe('with identity transformer (default passthrough)', () => {
+ it('produces same results as no transformer', async () => {
+ const rawData = { id: '1', name: 'Test' };
+ (mockClient.request as ReturnType).mockResolvedValue({
+ data: rawData,
+ status: 200,
+ });
+
+ // Service without transformer
+ const servicesNoTransformer = new CommonRemoteDataServices(mockClient, {
+ apiUrl: '/items',
+ });
+
+ // Service with identity transformer (no method overrides)
+ class IdentityTransformer extends BaseDataTransformer {}
+ const servicesWithIdentity = new CommonRemoteDataServices(mockClient, {
+ apiUrl: '/items',
+ transformer: new IdentityTransformer(),
+ });
+
+ const resultWithout = await servicesNoTransformer.getOne('1');
+ const resultWith = await servicesWithIdentity.getOne('1');
+
+ expect(resultWithout.data).toEqual(resultWith.data);
+ });
+ });
+
+ // ── Transformer receives correct arguments ────────────────────
+
+ describe('transformer method invocation', () => {
+ it('transformGetOneResponse receives the raw DTO from API', async () => {
+ const rawDTO = { id: '42', booking_code: 'BK042', customer_name: 'Alice' };
+ (mockClient.request as ReturnType).mockResolvedValueOnce({
+ data: rawDTO,
+ status: 200,
+ });
+
+ const mockTransformer: IDataTransformer = {
+ transformToEntity: vi.fn((dto: TestDTO) => ({
+ id: dto.id,
+ bookingCode: dto.booking_code,
+ customerName: dto.customer_name,
+ })),
+ transformToDTO: vi.fn(),
+ transformGetOneResponse: vi.fn((dto: TestDTO) => ({
+ id: dto.id,
+ bookingCode: dto.booking_code,
+ customerName: dto.customer_name,
+ })),
+ transformGetManyResponse: vi.fn(),
+ transformCreatePayload: vi.fn(),
+ transformEditPayload: vi.fn(),
+ };
+
+ const services = new CommonRemoteDataServices(mockClient, {
+ apiUrl: '/bookings',
+ transformer: mockTransformer,
+ });
+
+ await services.getOne('42');
+
+ expect(mockTransformer.transformGetOneResponse).toHaveBeenCalledWith(rawDTO);
+ expect(mockTransformer.transformToEntity).not.toHaveBeenCalled();
+ });
+
+ it('transformCreatePayload receives the entity data from caller', async () => {
+ const entityData: Partial = {
+ bookingCode: 'BK001',
+ customerName: 'Alice',
+ };
+
+ const mockTransformer: IDataTransformer = {
+ transformToEntity: vi.fn(),
+ transformToDTO: vi.fn(),
+ transformGetOneResponse: vi.fn(),
+ transformGetManyResponse: vi.fn(),
+ transformCreatePayload: vi.fn((entity) => ({
+ booking_code: entity.bookingCode!,
+ customer_name: entity.customerName!,
+ })),
+ transformEditPayload: vi.fn(),
+ };
+
+ const services = new CommonRemoteDataServices(mockClient, {
+ apiUrl: '/bookings',
+ transformer: mockTransformer,
+ });
+
+ await services.create(entityData);
+
+ expect(mockTransformer.transformCreatePayload).toHaveBeenCalledWith(entityData);
+ expect(mockTransformer.transformToDTO).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/packages/core-api/src/data-services/base-remote.data-services.ts b/packages/core-api/src/data-services/base-remote.data-services.ts
index 51e875f..1efac40 100644
--- a/packages/core-api/src/data-services/base-remote.data-services.ts
+++ b/packages/core-api/src/data-services/base-remote.data-services.ts
@@ -7,6 +7,7 @@ import type {
ExecuteOptions,
DataServicesConfig,
} from './types';
+import type { IDataTransformer } from './base-data.transformer';
import type { ApiResponse } from '../http-client/types';
import { interpolateUrl } from './url-builder';
import { DEFAULT_METHODS, DESCRIPTORS, makeDefaultURLs } from './constants';
@@ -25,10 +26,12 @@ import { DEFAULT_METHODS, DESCRIPTORS, makeDefaultURLs } from './constants';
* - All operations are fully typed end-to-end
* - Includes `customRequest()` as an escape hatch for non-standard endpoints
*
- * @typeParam E - The domain entity type (must extend BaseEntity)
+ * @typeParam E - The domain entity type (must extend BaseEntity)
+ * @typeParam TDTO - The API DTO shape (defaults to E for backward compatibility)
*
* @example
* ```ts
+ * // Without transformer (backward compatible)
* class BookingDataServices extends BaseRemoteDataServices {}
*
* const services = new BookingDataServices(apiClient, {
@@ -36,10 +39,20 @@ import { DEFAULT_METHODS, DESCRIPTORS, makeDefaultURLs } from './constants';
* moduleKey: 'BOOKING',
* });
*
+ * // With transformer (DTO ↔ Entity mapping)
+ * const services = new BookingDataServices(apiClient, {
+ * apiUrl: '/bookings',
+ * moduleKey: 'BOOKING',
+ * transformer: new BookingTransformer(),
+ * });
+ *
* const { data, status } = await services.getOne('42');
* ```
*/
-export abstract class BaseRemoteDataServices {
+export abstract class BaseRemoteDataServices<
+ E extends BaseEntity = BaseEntity,
+ TDTO = E,
+> {
/** The injected, isolated HTTP client instance. */
protected readonly httpClient: AxiosInstance;
@@ -52,9 +65,18 @@ export abstract class BaseRemoteDataServices
/** Module key for the 'ex-module-key' audit header. */
protected readonly moduleKey: string | undefined;
- constructor(httpClient: AxiosInstance, config: DataServicesConfig) {
+ /**
+ * Optional data transformer for DTO ↔ Entity mapping.
+ *
+ * When present, CRUD methods automatically transform responses
+ * and payloads. When absent, data passes through unchanged.
+ */
+ protected readonly transformer: IDataTransformer | undefined;
+
+ constructor(httpClient: AxiosInstance, config: DataServicesConfig) {
this.httpClient = httpClient;
this.moduleKey = config.moduleKey;
+ this.transformer = config.transformer;
this.urls = {
...makeDefaultURLs(config.apiUrl ?? ''),
@@ -143,31 +165,85 @@ export abstract class BaseRemoteDataServices
// ─── CRUD Operations ───────────────────────────────────────────
- /** Fetch a paginated list of entities. */
- getMany(config?: AxiosRequestConfig): Promise> {
- return this.execute(DESCRIPTORS.getMany, { config });
+ /**
+ * Fetch a paginated list of entities.
+ *
+ * When a transformer is injected, the raw API response is passed
+ * through `transformGetManyResponse()` before being returned.
+ */
+ async getMany(config?: AxiosRequestConfig): Promise> {
+ const result = await this.execute(DESCRIPTORS.getMany, { config });
+
+ if (this.transformer && Array.isArray(result.data)) {
+ return {
+ ...result,
+ data: this.transformer.transformGetManyResponse
+ ? this.transformer.transformGetManyResponse(result.data as unknown as TDTO[])
+ : result.data.map((item: unknown) => this.transformer!.transformToEntity(item as TDTO)),
+ } as unknown as ApiResponse;
+ }
+
+ return result;
}
- /** Fetch a single entity by ID. */
- getOne(id: string, config?: AxiosRequestConfig): Promise> {
- return this.execute(DESCRIPTORS.getOne, {
+ /**
+ * Fetch a single entity by ID.
+ *
+ * When a transformer is injected, the raw API response is passed
+ * through `transformGetOneResponse()` before being returned.
+ */
+ async getOne(id: string, config?: AxiosRequestConfig): Promise> {
+ const result = await this.execute(DESCRIPTORS.getOne, {
variableURL: { id },
config,
});
+
+ if (this.transformer && result.data != null) {
+ return {
+ ...result,
+ data: this.transformer.transformGetOneResponse
+ ? this.transformer.transformGetOneResponse(result.data as unknown as TDTO)
+ : this.transformer.transformToEntity(result.data as unknown as TDTO),
+ } as unknown as ApiResponse;
+ }
+
+ return result;
}
- /** Create a new entity. */
+ /**
+ * Create a new entity.
+ *
+ * When a transformer is injected, the entity payload is passed
+ * through `transformCreatePayload()` before being sent to the API.
+ */
create(data: Partial, config?: AxiosRequestConfig): Promise> {
+ const transformedData = this.transformer?.transformCreatePayload
+ ? this.transformer.transformCreatePayload(data)
+ : this.transformer
+ ? this.transformer.transformToDTO(data as E)
+ : data;
+
return this.execute(DESCRIPTORS.create, {
- config: { ...config, data },
+ config: { ...config, data: transformedData },
});
}
- /** Update an existing entity by ID. */
+ /**
+ * Update an existing entity by ID.
+ *
+ * When a transformer is injected, the entity payload is passed
+ * through `transformEditPayload()` before being sent to the API.
+ */
edit(id: string, data: Partial, config?: AxiosRequestConfig): Promise> {
+ const transformedData = this.transformer?.transformEditPayload
+ ? this.transformer.transformEditPayload(data)
+ : this.transformer
+ ? this.transformer.transformToDTO(data as E)
+ : data;
+
return this.execute(DESCRIPTORS.edit, {
variableURL: { id },
- config: { ...config, data },
+ config: { ...config, data: transformedData },
});
}
diff --git a/packages/core-api/src/data-services/common-remote.data-services.ts b/packages/core-api/src/data-services/common-remote.data-services.ts
index bdcae39..a9d6d43 100644
--- a/packages/core-api/src/data-services/common-remote.data-services.ts
+++ b/packages/core-api/src/data-services/common-remote.data-services.ts
@@ -12,11 +12,12 @@ import { BaseRemoteDataServices } from './base-remote.data-services';
* CRUD + lifecycle, extend BaseRemoteDataServices instead and add
* custom methods using `this.execute()` or `this.customRequest()`.
*
- * @typeParam E - The domain entity type
+ * @typeParam E - The domain entity type
+ * @typeParam TDTO - The API DTO shape (defaults to E for backward compatibility)
*
* @example
* ```ts
- * // Direct instantiation for standard modules
+ * // Direct instantiation for standard modules (no transformer)
* const bookingServices = new CommonRemoteDataServices(
* apiClient,
* { apiUrl: '/bookings', moduleKey: 'BOOKING' },
@@ -27,6 +28,19 @@ import { BaseRemoteDataServices } from './base-remote.data-services';
*
* @example
* ```ts
+ * // With transformer for DTO ↔ Entity mapping
+ * const bookingServices = new CommonRemoteDataServices(
+ * apiClient,
+ * {
+ * apiUrl: '/bookings',
+ * moduleKey: 'BOOKING',
+ * transformer: new BookingTransformer(),
+ * },
+ * );
+ * ```
+ *
+ * @example
+ * ```ts
* // For modules needing custom operations, extend the base:
* class InvoiceDataServices extends BaseRemoteDataServices {
* async calculateTax(invoiceId: string) {
@@ -40,4 +54,6 @@ import { BaseRemoteDataServices } from './base-remote.data-services';
*/
export class CommonRemoteDataServices<
E extends BaseEntity = BaseEntity,
-> extends BaseRemoteDataServices {}
+ TDTO = E,
+> extends BaseRemoteDataServices {}
+
diff --git a/packages/core-api/src/data-services/index.ts b/packages/core-api/src/data-services/index.ts
index edaf7ef..ff7ca20 100644
--- a/packages/core-api/src/data-services/index.ts
+++ b/packages/core-api/src/data-services/index.ts
@@ -1,6 +1,7 @@
// ─── Classes ────────────────────────────────────────────────────
export { BaseRemoteDataServices } from './base-remote.data-services';
export { CommonRemoteDataServices } from './common-remote.data-services';
+export { BaseDataTransformer } from './base-data.transformer';
// ─── Utilities ──────────────────────────────────────────────────
export { interpolateUrl } from './url-builder';
@@ -17,3 +18,5 @@ export type {
ExecuteOptions,
DataServicesConfig,
} from './types';
+
+export type { IDataTransformer } from './base-data.transformer';
diff --git a/packages/core-api/src/data-services/types.ts b/packages/core-api/src/data-services/types.ts
index 76ec63c..5c45e82 100644
--- a/packages/core-api/src/data-services/types.ts
+++ b/packages/core-api/src/data-services/types.ts
@@ -1,5 +1,6 @@
import type { AxiosRequestConfig } from 'axios';
import type { TelemetryContext } from '../http-client/types';
+import type { IDataTransformer } from './base-data.transformer';
// ─── Base Entity ────────────────────────────────────────────────
@@ -112,8 +113,14 @@ export interface ExecuteOptions {
/**
* Configuration for constructing a BaseRemoteDataServices instance.
+ *
+ * @typeParam TEntity - The frontend domain entity type (defaults to BaseEntity)
+ * @typeParam TDTO - The API DTO shape (defaults to TEntity for backward compatibility)
*/
-export interface DataServicesConfig {
+export interface DataServicesConfig<
+ TEntity extends BaseEntity = BaseEntity,
+ TDTO = TEntity,
+> {
/** Base API path (e.g., '/bookings'). Used to generate all URL templates. */
apiUrl?: string;
/** Module key for the 'ex-module-key' header (e.g., 'BOOKING'). */
@@ -122,4 +129,18 @@ export interface DataServicesConfig {
urls?: Partial;
/** Override specific HTTP methods. */
methods?: Partial;
+
+ /**
+ * Optional data transformer for DTO ↔ Entity mapping.
+ *
+ * When provided, CRUD methods automatically transform:
+ * - **Responses** (`getOne`, `getMany`): DTO → Entity via transformer
+ * - **Payloads** (`create`, `edit`): Entity → DTO via transformer
+ *
+ * When omitted, data passes through unchanged (backward compatible).
+ *
+ * @see {@link IDataTransformer} for the transformer contract
+ * @see {@link BaseDataTransformer} for the abstract base class
+ */
+ transformer?: IDataTransformer;
}