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:
@@ -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()` |
|
||||
Reference in New Issue
Block a user