6.9 KiB
6.9 KiB
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:
mapPrisma{EntityName}ToDomain(entity: Prisma{Entity}): Domain{Type}
Examples:
mapPrismaUserToDomain()- User entity → AuthenticatedUsermapPrismaMappingToDomain()- IdMapping entity → UserIdMappingmapPrismaOrderToDomain()- Order entity → OrderDetails (if we had one)
When to Create a DB Mapper
Create a DB mapper when you need to:
- ✅ Convert Prisma query results to domain types
- ✅ Transform database timestamps to ISO strings
- ✅ Handle nullable database fields → domain optional fields
- ✅ Map database column names to domain property names (if different)
Do NOT create a DB mapper when:
- ❌ Transforming external API responses (use domain provider mappers)
- ❌ Transforming between domain types (handle in domain layer)
- ❌ Applying business logic (belongs in domain)
Example: User Mapper
File: apps/bff/src/infra/mappers/user.mapper.ts
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
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
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
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)
// Location: apps/bff/src/infra/mappers/
// Purpose: Prisma → Domain
mapPrismaUserToDomain(prismaUser: PrismaUser): AuthenticatedUser {
// Transforms database entity to domain type
}
Provider Mappers (Domain Layer)
// 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:
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.