feat(core-api): implement abstract data transformer architecture

- **Core API**: Introduced `IDataTransformer` interface and `BaseDataTransformer` abstract class.
- **Data Services**: Updated `BaseRemoteDataServices` and `DataServicesConfig` to support optional transformer injection. CRUD methods now auto-transform data mapping (DTO <-> Entity) when a transformer is provided.
- **Generics**: Updated `CommonRemoteDataServices` to forward the `TDTO` generic for strict type safety. Fixed `ApiResponse<T>` casting and excess property type checks.
- **Showcase/Samples**: Created `BookingTransformer` (snake_case to camelCase mapping) and injected it into `booking.data-services.ts`.
- **Advanced Showcase**: Added `AdvancedBookingTransformer` and `AdvancedBookingService` to demonstrate extending base transformers with custom methods (e.g., `getAvailabilityChart`).
- **Documentation**: Added comprehensive developer guide at `apps/docs-dev/src/packages/core-api/transformers.md`.
- **Testing**: Added 16 new unit tests for transformer integrations (identity and mock scenarios).

Note: The implementation is 100% backward compatible. All 49/49 tests pass and the TypeScript typecheck is entirely clean.
This commit is contained in:
Firman Ramdhani
2026-07-01 11:13:09 +07:00
parent 7130eb3fe3
commit 1c2090f4fb
11 changed files with 1494 additions and 19 deletions
@@ -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<br/>(snake_case DTOs)"]:::api
SVC["BaseRemoteDataServices<br/>(execute, getOne, getMany, ...)"]:::service
TFM["Data Transformer<br/>(transformToEntity / transformToDTO)"]:::transformer
ENT["Domain Entity<br/>(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<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.
| 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<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:
| 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()` |