245 lines
6.9 KiB
Markdown
245 lines
6.9 KiB
Markdown
# BFF Database Mappers
|
|
|
|
## Overview
|
|
|
|
Database mappers in the BFF layer are responsible for transforming **Prisma entities** (database schema) into **domain types** (business entities). These are **infrastructure concerns** and belong in the BFF layer, not the domain layer.
|
|
|
|
## Key Principle
|
|
|
|
**Domain layer should NOT know about Prisma or any ORM**. Database schema is an implementation detail of the BFF layer.
|
|
|
|
```
|
|
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
|
│ Prisma │ ────> │ DB Mapper │ ────> │ Domain │
|
|
│ (DB Type) │ │ (BFF) │ │ Type │
|
|
└─────────────┘ └─────────────┘ └─────────────┘
|
|
```
|
|
|
|
## Location
|
|
|
|
All DB mappers are centralized in:
|
|
```
|
|
apps/bff/src/infra/mappers/
|
|
```
|
|
|
|
## Naming Convention
|
|
|
|
All DB mapper functions follow this pattern:
|
|
```typescript
|
|
mapPrisma{EntityName}ToDomain(entity: Prisma{Entity}): Domain{Type}
|
|
```
|
|
|
|
### Examples:
|
|
- `mapPrismaUserToDomain()` - User entity → AuthenticatedUser
|
|
- `mapPrismaMappingToDomain()` - IdMapping entity → UserIdMapping
|
|
- `mapPrismaOrderToDomain()` - Order entity → OrderDetails (if we had one)
|
|
|
|
## When to Create a DB Mapper
|
|
|
|
Create a DB mapper when you need to:
|
|
|
|
1. ✅ Convert Prisma query results to domain types
|
|
2. ✅ Transform database timestamps to ISO strings
|
|
3. ✅ Handle nullable database fields → domain optional fields
|
|
4. ✅ Map database column names to domain property names (if different)
|
|
|
|
Do NOT create a DB mapper when:
|
|
|
|
1. ❌ Transforming external API responses (use domain provider mappers)
|
|
2. ❌ Transforming between domain types (handle in domain layer)
|
|
3. ❌ Applying business logic (belongs in domain)
|
|
|
|
## Example: User Mapper
|
|
|
|
**File**: `apps/bff/src/infra/mappers/user.mapper.ts`
|
|
|
|
```typescript
|
|
import type { User as PrismaUser } from "@prisma/client";
|
|
import type { AuthenticatedUser } from "@customer-portal/domain/auth";
|
|
|
|
/**
|
|
* Maps Prisma User entity to Domain AuthenticatedUser type
|
|
*
|
|
* NOTE: Profile fields must be fetched from WHMCS - this only maps auth state
|
|
*/
|
|
export function mapPrismaUserToDomain(user: PrismaUser): AuthenticatedUser {
|
|
return {
|
|
id: user.id,
|
|
email: user.email,
|
|
role: user.role,
|
|
|
|
// Transform database booleans
|
|
mfaEnabled: !!user.mfaSecret,
|
|
emailVerified: user.emailVerified,
|
|
|
|
// Transform Date objects to ISO strings
|
|
lastLoginAt: user.lastLoginAt?.toISOString(),
|
|
createdAt: user.createdAt.toISOString(),
|
|
updatedAt: user.updatedAt.toISOString(),
|
|
|
|
// Profile fields are null - must be fetched from WHMCS
|
|
firstname: null,
|
|
lastname: null,
|
|
fullname: null,
|
|
companyname: null,
|
|
phonenumber: null,
|
|
language: null,
|
|
currencyCode: null,
|
|
address: undefined,
|
|
};
|
|
}
|
|
```
|
|
|
|
## Example: ID Mapping Mapper
|
|
|
|
**File**: `apps/bff/src/infra/mappers/mapping.mapper.ts`
|
|
|
|
```typescript
|
|
import type { IdMapping as PrismaIdMapping } from "@prisma/client";
|
|
import type { UserIdMapping } from "@customer-portal/domain/mappings";
|
|
|
|
/**
|
|
* Maps Prisma IdMapping entity to Domain UserIdMapping type
|
|
*/
|
|
export function mapPrismaMappingToDomain(mapping: PrismaIdMapping): UserIdMapping {
|
|
return {
|
|
id: mapping.userId, // Use userId as id since it's the primary key
|
|
userId: mapping.userId,
|
|
whmcsClientId: mapping.whmcsClientId,
|
|
sfAccountId: mapping.sfAccountId, // Keep null as-is (don't convert to undefined)
|
|
createdAt: mapping.createdAt,
|
|
updatedAt: mapping.updatedAt,
|
|
};
|
|
}
|
|
```
|
|
|
|
## Usage Pattern
|
|
|
|
### In Services
|
|
|
|
```typescript
|
|
import { mapPrismaMappingToDomain } from "@bff/infra/mappers";
|
|
|
|
@Injectable()
|
|
export class MappingsService {
|
|
async findByUserId(userId: string): Promise<UserIdMapping | null> {
|
|
const dbMapping = await this.prisma.idMapping.findUnique({
|
|
where: { userId }
|
|
});
|
|
|
|
if (!dbMapping) return null;
|
|
|
|
// Use centralized DB mapper
|
|
return mapPrismaMappingToDomain(dbMapping);
|
|
}
|
|
}
|
|
```
|
|
|
|
### Batch Mapping
|
|
|
|
```typescript
|
|
const dbMappings = await this.prisma.idMapping.findMany({
|
|
where: whereClause,
|
|
});
|
|
|
|
// Map each entity
|
|
const mappings = dbMappings.map(mapping => mapPrismaMappingToDomain(mapping));
|
|
```
|
|
|
|
## Contrast with Domain Provider Mappers
|
|
|
|
### DB Mappers (BFF Infrastructure)
|
|
|
|
```typescript
|
|
// Location: apps/bff/src/infra/mappers/
|
|
// Purpose: Prisma → Domain
|
|
|
|
mapPrismaUserToDomain(prismaUser: PrismaUser): AuthenticatedUser {
|
|
// Transforms database entity to domain type
|
|
}
|
|
```
|
|
|
|
### Provider Mappers (Domain Layer)
|
|
|
|
```typescript
|
|
// Location: packages/domain/{domain}/providers/{provider}/mapper.ts
|
|
// Purpose: External API Response → Domain
|
|
|
|
Providers.Salesforce.transformOrder(
|
|
sfOrder: SalesforceOrderRecord
|
|
): OrderDetails {
|
|
// Transforms external provider data to domain type
|
|
}
|
|
```
|
|
|
|
## Key Differences
|
|
|
|
| Aspect | DB Mappers | Provider Mappers |
|
|
|--------|-----------|------------------|
|
|
| **Location** | `apps/bff/src/infra/mappers/` | `packages/domain/{domain}/providers/` |
|
|
| **Input** | Prisma entity | External API response |
|
|
| **Layer** | BFF Infrastructure | Domain |
|
|
| **Purpose** | Hide ORM implementation | Transform business data |
|
|
| **Import in Domain** | ❌ Never | ✅ Internal use only |
|
|
| **Import in BFF** | ✅ Yes | ✅ Yes |
|
|
|
|
## Best Practices
|
|
|
|
### ✅ DO
|
|
|
|
- Keep mappers simple and focused
|
|
- Use TypeScript types from Prisma client
|
|
- Handle null/undefined consistently
|
|
- Transform Date objects to ISO strings
|
|
- Document special cases in comments
|
|
- Centralize all DB mappers in `/infra/mappers/`
|
|
|
|
### ❌ DON'T
|
|
|
|
- Add business logic to mappers
|
|
- Call external services from mappers
|
|
- Import DB mappers in domain layer
|
|
- Create one-off mappers scattered in services
|
|
- Transform external API data here (use domain provider mappers)
|
|
|
|
## Testing
|
|
|
|
DB mappers are pure functions and easy to test:
|
|
|
|
```typescript
|
|
describe('mapPrismaUserToDomain', () => {
|
|
it('should map Prisma user to domain user', () => {
|
|
const prismaUser: PrismaUser = {
|
|
id: 'user-123',
|
|
email: 'test@example.com',
|
|
role: 'USER',
|
|
mfaSecret: 'secret',
|
|
emailVerified: true,
|
|
lastLoginAt: new Date('2025-01-01'),
|
|
createdAt: new Date('2024-01-01'),
|
|
updatedAt: new Date('2024-01-01'),
|
|
// ... other fields
|
|
};
|
|
|
|
const result = mapPrismaUserToDomain(prismaUser);
|
|
|
|
expect(result.id).toBe('user-123');
|
|
expect(result.mfaEnabled).toBe(true);
|
|
expect(result.lastLoginAt).toBe('2025-01-01T00:00:00.000Z');
|
|
});
|
|
});
|
|
```
|
|
|
|
## Summary
|
|
|
|
DB mappers are:
|
|
- ✅ **Infrastructure concerns** (BFF layer)
|
|
- ✅ **Pure transformation functions** (Prisma → Domain)
|
|
- ✅ **Centralized** in `/infra/mappers/`
|
|
- ✅ **Consistently named** `mapPrismaXxxToDomain()`
|
|
- ❌ **Never used in domain layer**
|
|
- ❌ **Never contain business logic**
|
|
|
|
This clear separation ensures the domain layer remains pure and independent of infrastructure choices.
|
|
|