Agent skill
Prisma Domain Mapper Generator
Génère des mappers bidirectionnels entre Domain Entities et Prisma Models pour l'isolation de la couche persistence. À utiliser lors de la création de mappers, repositories, ou quand l'utilisateur mentionne "mapper", "Prisma", "persistence", "toPrisma", "toDomain", "repository implementation".
Install this agent skill to your Project
npx add-skill https://github.com/majiayu000/claude-skill-registry/tree/main/skills/data/prisma-domain-mapper-generator
SKILL.md
Prisma Domain Mapper Generator
🎯 Mission
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.
🏗️ Philosophie des Mappers
Pourquoi des Mappers ?
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 { /* ... */ }
}
Avantages des Mappers
- ✅ Domain pur : Aucune dépendance vers Prisma dans le domain
- ✅ Flexibilité : Changer de DB sans toucher au domain
- ✅ Testabilité : Tester le domain sans DB
- ✅ Évolutivité : Adapter le modèle de données sans casser le domain
- ✅ Clarté : Séparation explicite des responsabilités
📁 Organisation des Mappers
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
🔄 Mapper Bidirectionnel
Structure d'un Mapper
Un mapper contient deux méthodes statiques :
toDomain(): Prisma Model → Domain EntitytoPrisma(): Domain Entity → Prisma Model (pour create/update)
Template Mapper Simple
// 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)
};
}
}
🎨 Patterns de Mapping
1. Mapping avec Value Objects
// 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(),
};
}
}
2. Mapping avec Relations (1-to-1, 1-to-many)
// 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
},
};
}
}
3. Mapping avec Dates et Types Complexes
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
};
}
}
4. Mapping avec Enums
// 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
// ...
};
}
}
5. Mapping avec JSON Fields
// 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
};
}
}
🔗 Utilisation dans les Repositories
Repository Implementation avec Mapper
// 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 };
}
}
✅ Checklist pour les Mappers
Responsabilités du Mapper
- Deux méthodes statiques :
toDomain()ettoPrisma() - Reconstruit les Value Objects dans
toDomain() - Extrait les primitives des Value Objects dans
toPrisma() - Gère les relations si nécessaire
- Gère les types complexes (dates, JSON, enums)
- Gère les valeurs nullables correctement
- Pas de logique métier (seulement transformation)
Règles Strictes
- ✅ Mappers dans
infrastructure/persistence/mappers/ - ✅ Un mapper par entité domain
- ✅ Méthodes statiques uniquement (pas d'état)
- ✅ Pas de logique métier dans les mappers
- ✅ Toujours reconstruire les Value Objects
- ❌ JAMAIS de références Prisma dans le domain
- ❌ JAMAIS de logique métier dans le mapper
- ❌ JAMAIS d'appels à la DB dans le mapper
🎓 Exemples Concrets du Projet
Bounded Context club-management
Mappers existants à consulter :
infrastructure/persistence/mappers/club.mapper.tsinfrastructure/persistence/mappers/subscription.mapper.tsinfrastructure/persistence/mappers/invitation.mapper.tsinfrastructure/persistence/mappers/member.mapper.ts
Référence : volley-app-backend/src/club-management/infrastructure/persistence/mappers/
🚨 Erreurs Courantes à Éviter
-
❌ Mapper avec logique métier
- ✅ FAIRE : Transformer uniquement les données
- ❌ NE PAS FAIRE : Valider ou calculer dans le mapper
-
❌ Exposer Prisma Types dans le Domain
- ✅ FAIRE : Domain Entity pure TypeScript
- ❌ NE PAS FAIRE :
import { Club as PrismaClub }dans domain
-
❌ Ne pas reconstruire les Value Objects
- ✅ FAIRE :
ClubName.create(prismaClub.name) - ❌ NE PAS FAIRE : Passer directement la string
- ✅ FAIRE :
-
❌ Mapper qui appelle la DB
- ✅ FAIRE : Mapper transforme les données seulement
- ❌ NE PAS FAIRE :
await this.prisma.club.findMany()dans mapper
-
❌ Oublier de gérer les relations
- ✅ FAIRE : Créer une méthode séparée
toDomainWithRelations() - ❌ NE PAS FAIRE : Ignorer les relations ou les gérer de manière incohérente
- ✅ FAIRE : Créer une méthode séparée
🧪 Tester les Mappers
Template de Test
// 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());
});
});
});
📚 Skills Complémentaires
Pour aller plus loin :
- ddd-bounded-context : Architecture DDD complète avec bounded contexts
- cqrs-command-query : Commands/Queries qui utilisent les repositories
- ddd-testing : Tests des repositories et mappers
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.
Recommended Agent Skills
Expand your agent's capabilities with these related and highly-rated skills.
agent-ops-spec
Manage specification documents in .agent/specs/. Use when user provides requirements, acceptance criteria, or feature descriptions that need to be tracked and validated against implementation.
agent-ops-state
Maintain .agent state files. Use at session start, after meaningful steps, and before concluding: read/update constitution/memory/focus/issues/baseline consistently.
agent-ops-spec
Manage specification documents in .agent/specs/. Use when user provides requirements, acceptance criteria, or feature descriptions that need to be tracked and validated against implementation.
agent-ops-testing
Test strategy, execution, and coverage analysis. Use when designing tests, running test suites, or analyzing test results beyond baseline checks.
agent-ops-testing
Test strategy, execution, and coverage analysis. Use when designing tests, running test suites, or analyzing test results beyond baseline checks.
agent-ops-state
Maintain .agent state files. Use at session start, after meaningful steps, and before concluding: read/update constitution/memory/focus/issues/baseline consistently.
Didn't find tool you were looking for?