Génère des mappers bidirectionnels entre Domain Entities et Prisma Models pour l'isolation de la couche persistence...
Créer des mappers bidirectionnels robustes pour convertir les Domain Entities en Prisma Models et vice-versa, en maintenant une stricte séparation entre le Domain Layer et la couche de persistence.
En DDD, le Domain Layer doit être totalement isolé de toute infrastructure technique, y compris la base de données.
Problème sans mappers :
// ❌ BAD - Domain entity dépend de Prisma
import { Club as PrismaClub } from '@prisma/client';
export class Club extends PrismaClub { // VIOLATION DDD
// Domain logic here
}
Solution avec mappers :
// ✅ GOOD - Domain entity est pure
export class Club {
// Pure TypeScript, aucune dépendance Prisma
private constructor(
private readonly id: string,
private name: ClubName, // Value Object
// ...
) {}
}
// Mapper dans l'Infrastructure Layer
export class ClubMapper {
static toDomain(prismaClub: PrismaClub): Club { /* ... */ }
static toPrisma(club: Club): PrismaClubCreateInput { /* ... */ }
}
bounded-context/
└── infrastructure/
└── persistence/
├── repositories/
│ └── club.repository.ts # Uses mappers
└── mappers/
├── club.mapper.ts # Club entity mapper
├── subscription.mapper.ts # Subscription entity mapper
├── invitation.mapper.ts # Invitation entity mapper
└── index.ts # Barrel export
Un mapper contient deux méthodes statiques :
toDomain() : Prisma Model → Domain EntitytoPrisma() : Domain Entity → Prisma Model (pour create/update)// infrastructure/persistence/mappers/club.mapper.ts
import { Club as PrismaClub } from '@prisma/client';
import { Club } from '../../../domain/entities/club.entity';
import { ClubName } from '../../../domain/value-objects/club-name.vo';
export class ClubMapper {
/**
* Convertit un Prisma Model en Domain Entity
*/
static toDomain(prismaClub: PrismaClub): Club {
// Reconstruct Value Objects from primitive values
const name = ClubName.create(prismaClub.name);
// Reconstruct Entity using all-args constructor or factory method
return new Club(
prismaClub.id,
name,
prismaClub.description,
prismaClub.ownerId,
prismaClub.createdAt,
prismaClub.updatedAt,
);
}
/**
* Convertit une Domain Entity en Prisma Create/Update Input
*/
static toPrisma(club: Club): Prisma.ClubCreateInput {
return {
id: club.getId(),
name: club.getName().getValue(), // Extract primitive from Value Object
description: club.getDescription(),
ownerId: club.getOwnerId(),
createdAt: club.getCreatedAt(),
updatedAt: new Date(),
};
}
/**
* Optionnel : Méthode spécifique pour les updates
*/
static toUpdateInput(club: Club): Prisma.ClubUpdateInput {
return {
name: club.getName().getValue(),
description: club.getDescription(),
updatedAt: new Date(),
// Exclude id, ownerId, createdAt (immutables)
};
}
}
// Domain Entity avec Value Objects
export class Subscription {
constructor(
private readonly id: string,
private plan: SubscriptionPlan, // Value Object
private status: SubscriptionStatus, // Value Object
private readonly startDate: Date,
) {}
}
// Mapper
export class SubscriptionMapper {
static toDomain(prismaSubscription: PrismaSubscription): Subscription {
// Reconstruct Value Objects from string primitives
const plan = SubscriptionPlan.fromString(prismaSubscription.plan);
const status = SubscriptionStatus.fromString(prismaSubscription.status);
return new Subscription(
prismaSubscription.id,
plan,
status,
prismaSubscription.startDate,
);
}
static toPrisma(subscription: Subscription): Prisma.SubscriptionCreateInput {
return {
id: subscription.getId(),
plan: subscription.getPlan().toString(), // Extract primitive
status: subscription.getStatus().toString(), // Extract primitive
startDate: subscription.getStartDate(),
};
}
}
// Prisma Schema
// model Club {
// id String @id
// name String
// subscription Subscription? @relation(...)
// members Member[]
// }
export class ClubMapper {
/**
* Mapping simple sans relations
*/
static toDomain(prismaClub: PrismaClub): Club {
return new Club(
prismaClub.id,
ClubName.create(prismaClub.name),
prismaClub.description,
prismaClub.ownerId,
prismaClub.createdAt,
);
}
/**
* Mapping avec relations (requires Prisma includes)
*/
static toDomainWithRelations(
prismaClub: PrismaClub & {
subscription?: PrismaSubscription;
members?: PrismaMember[];
},
): Club {
const club = new Club(
prismaClub.id,
ClubName.create(prismaClub.name),
prismaClub.description,
prismaClub.ownerId,
prismaClub.createdAt,
);
// Map 1-to-1 relation (subscription)
if (prismaClub.subscription) {
const subscription = SubscriptionMapper.toDomain(prismaClub.subscription);
club.setSubscription(subscription);
}
// Map 1-to-many relation (members)
if (prismaClub.members) {
const members = prismaClub.members.map(m => MemberMapper.toDomain(m));
club.setMembers(members);
}
return club;
}
/**
* Mapping to Prisma (create)
*/
static toPrisma(club: Club): Prisma.ClubCreateInput {
return {
id: club.getId(),
name: club.getName().getValue(),
description: club.getDescription(),
owner: {
connect: { id: club.getOwnerId() }, // Relation via connect
},
// Don't include subscription or members here
// They are created separately via their own repositories
};
}
/**
* Mapping to Prisma avec nested create (optionnel)
*/
static toPrismaWithSubscription(
club: Club,
subscription: Subscription,
): Prisma.ClubCreateInput {
return {
id: club.getId(),
name: club.getName().getValue(),
description: club.getDescription(),
owner: {
connect: { id: club.getOwnerId() },
},
subscription: {
create: SubscriptionMapper.toPrisma(subscription), // Nested create
},
};
}
}
export class InvitationMapper {
static toDomain(prismaInvitation: PrismaInvitation): Invitation {
// Convert Prisma Date to Domain Date
const createdAt = new Date(prismaInvitation.createdAt);
const expiresAt = new Date(prismaInvitation.expiresAt);
// Handle nullable dates
const usedAt = prismaInvitation.usedAt
? new Date(prismaInvitation.usedAt)
: null;
return new Invitation(
prismaInvitation.id,
prismaInvitation.clubId,
InvitationType.fromString(prismaInvitation.type),
prismaInvitation.email,
createdAt,
expiresAt,
usedAt,
);
}
static toPrisma(invitation: Invitation): Prisma.InvitationCreateInput {
return {
id: invitation.getId(),
clubId: invitation.getClubId(),
type: invitation.getType().toString(),
email: invitation.getEmail(),
createdAt: invitation.getCreatedAt(),
expiresAt: invitation.getExpiresAt(),
usedAt: invitation.getUsedAt(), // Can be null
};
}
}
// Prisma Schema
// enum SubscriptionPlanEnum {
// FREE
// PRO
// UNLIMITED
// }
export class SubscriptionMapper {
static toDomain(prismaSubscription: PrismaSubscription): Subscription {
// Convert Prisma enum to Domain Value Object
const plan = SubscriptionPlan.fromString(prismaSubscription.plan);
return new Subscription(
prismaSubscription.id,
plan,
// ...
);
}
static toPrisma(subscription: Subscription): Prisma.SubscriptionCreateInput {
return {
id: subscription.getId(),
plan: subscription.getPlan().toString() as SubscriptionPlanEnum, // Type assertion
// ...
};
}
}
// Prisma Schema
// model Training {
// id String @id
// name String
// metadata Json? // Flexible JSON field
// }
interface TrainingMetadata {
difficulty: string;
duration: number;
tags: string[];
}
export class TrainingMapper {
static toDomain(prismaTraining: PrismaTraining): Training {
// Parse JSON field
const metadata = prismaTraining.metadata as TrainingMetadata | null;
return new Training(
prismaTraining.id,
prismaTraining.name,
metadata,
);
}
static toPrisma(training: Training): Prisma.TrainingCreateInput {
return {
id: training.getId(),
name: training.getName(),
metadata: training.getMetadata(), // Prisma handles JSON serialization
};
}
}
// infrastructure/persistence/repositories/club.repository.ts
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../../prisma/prisma.service';
import { IClubRepository } from '../../../domain/repositories/club.repository.interface';
import { Club } from '../../../domain/entities/club.entity';
import { ClubMapper } from '../mappers/club.mapper';
@Injectable()
export class ClubRepository implements IClubRepository {
constructor(private readonly prisma: PrismaService) {}
async create(club: Club): Promise<Club> {
// 1. Convert Domain Entity → Prisma Input
const prismaData = ClubMapper.toPrisma(club);
// 2. Save to database
const created = await this.prisma.club.create({
data: prismaData,
});
// 3. Convert Prisma Model → Domain Entity
return ClubMapper.toDomain(created);
}
async findById(id: string): Promise<Club | null> {
const prismaClub = await this.prisma.club.findUnique({
where: { id },
});
if (!prismaClub) return null;
// Convert Prisma Model → Domain Entity
return ClubMapper.toDomain(prismaClub);
}
async findByIdWithRelations(id: string): Promise<Club | null> {
const prismaClub = await this.prisma.club.findUnique({
where: { id },
include: {
subscription: true,
members: true,
},
});
if (!prismaClub) return null;
// Use specialized mapper method for relations
return ClubMapper.toDomainWithRelations(prismaClub);
}
async update(club: Club): Promise<Club> {
// 1. Convert to update input
const updateData = ClubMapper.toUpdateInput(club);
// 2. Update in database
const updated = await this.prisma.club.update({
where: { id: club.getId() },
data: updateData,
});
// 3. Convert back to domain
return ClubMapper.toDomain(updated);
}
async delete(id: string): Promise<void> {
await this.prisma.club.delete({
where: { id },
});
}
async findAll(options: {
page: number;
limit: number;
search?: string;
}): Promise<{ data: Club[]; total: number }> {
const skip = (options.page - 1) * options.limit;
const where = options.search
? {
name: {
contains: options.search,
mode: 'insensitive' as const,
},
}
: {};
const [prismaClubs, total] = await Promise.all([
this.prisma.club.findMany({
where,
skip,
take: options.limit,
orderBy: { createdAt: 'desc' },
}),
this.prisma.club.count({ where }),
]);
// Convert array of Prisma Models → Domain Entities
const clubs = prismaClubs.map(pc => ClubMapper.toDomain(pc));
return { data: clubs, total };
}
}
toDomain() et toPrisma()toDomain()toPrisma()infrastructure/persistence/mappers/club-managementMappers existants à consulter :
infrastructure/persistence/mappers/club.mapper.tsinfrastructure/persistence/mappers/subscription.mapper.tsinfrastructure/persistence/mappers/invitation.mapper.tsinfrastructure/persistence/mappers/member.mapper.tsRéférence : volley-app-backend/src/club-management/infrastructure/persistence/mappers/
❌ Mapper avec logique métier
❌ Exposer Prisma Types dans le Domain
import { Club as PrismaClub } dans domain❌ Ne pas reconstruire les Value Objects
ClubName.create(prismaClub.name)❌ Mapper qui appelle la DB
await this.prisma.club.findMany() dans mapper❌ Oublier de gérer les relations
toDomainWithRelations()// infrastructure/persistence/mappers/club.mapper.spec.ts
import { ClubMapper } from './club.mapper';
import { Club } from '../../../domain/entities/club.entity';
import { ClubName } from '../../../domain/value-objects/club-name.vo';
describe('ClubMapper', () => {
describe('toDomain()', () => {
it('should convert Prisma model to Domain entity', () => {
// Arrange
const prismaClub = {
id: 'club-123',
name: 'Volley Club Paris',
description: 'Best club',
ownerId: 'user-123',
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-02'),
};
// Act
const club = ClubMapper.toDomain(prismaClub);
// Assert
expect(club).toBeInstanceOf(Club);
expect(club.getId()).toBe('club-123');
expect(club.getName().getValue()).toBe('Volley Club Paris');
expect(club.getDescription()).toBe('Best club');
});
it('should reconstruct Value Objects correctly', () => {
const prismaClub = {
id: 'club-123',
name: 'Volley Club',
description: null,
ownerId: 'user-123',
createdAt: new Date(),
updatedAt: new Date(),
};
const club = ClubMapper.toDomain(prismaClub);
expect(club.getName()).toBeInstanceOf(ClubName);
});
});
describe('toPrisma()', () => {
it('should convert Domain entity to Prisma input', () => {
// Arrange
const club = Club.create('Volley Club', 'Description', 'user-123');
// Act
const prismaInput = ClubMapper.toPrisma(club);
// Assert
expect(prismaInput).toMatchObject({
id: club.getId(),
name: 'Volley Club',
description: 'Description',
ownerId: 'user-123',
});
});
it('should extract primitives from Value Objects', () => {
const club = Club.create('Club Name', 'Desc', 'user-123');
const prismaInput = ClubMapper.toPrisma(club);
expect(typeof prismaInput.name).toBe('string');
});
});
describe('Bidirectional mapping', () => {
it('should maintain data integrity in round-trip', () => {
// Domain → Prisma → Domain
const originalClub = Club.create('Volley Club', 'Description', 'user-123');
const prismaInput = ClubMapper.toPrisma(originalClub);
const reconstructedClub = ClubMapper.toDomain({
...prismaInput,
createdAt: new Date(),
updatedAt: new Date(),
} as any);
expect(reconstructedClub.getName().getValue()).toBe(originalClub.getName().getValue());
expect(reconstructedClub.getDescription()).toBe(originalClub.getDescription());
});
});
});
Pour aller plus loin :
Rappel : Les mappers sont la frontière entre votre Domain Layer pur et l'infrastructure de persistence. Ils garantissent que votre logique métier reste indépendante de la technologie de base de données.