--- description: globs: *.php alwaysApply: false --- # Persistence dans l'Architecture Hexagonale ## Structure de la Persistence ``` Domain/Manga/ └── Infrastructure/ └── Persistence/ ├── Repository/ # Implémentations des repositories │ └── DoctrineMangaRepository.php ├── Entity/ # Entités Doctrine │ └── MangaEntity.php └── Mapper/ # Mappers Domain <-> Entity └── MangaMapper.php ``` ## Règles d'Organisation ### 1. Repositories - Localisation : `Infrastructure/Persistence/Repository/` - Principes : - Un repository par agrégat du domaine - Implémente l'interface du domaine - Utilise un mapper dédié - Gère uniquement la persistence - Pas de logique métier - Nommage : `Doctrine{Aggregate}Repository` ### 2. Entités - Localisation : `Infrastructure/Persistence/Entity/` - Principes : - Une entité par agrégat du domaine - Uniquement des getters/setters - Pas de logique métier - Nommage : `{Aggregate}Entity` - Suffixe `Entity` obligatoire pour éviter la confusion avec les modèles du domaine ### 3. Mappers - Localisation : `Infrastructure/Persistence/Mapper/` - Principes : - Un mapper par agrégat - Conversion bidirectionnelle Domain <-> Entity - Gestion des Value Objects - Nommage : `{Aggregate}Mapper` ## Exemples de Code ### 1. Query et Repository ```php namespace App\Domain\Manga\Application\Query; readonly class GetMangaListQuery { public function __construct( public int $page = 1, public int $limit = 20, public string $sortBy = 'title', public string $sortOrder = 'asc', public ?string $search = null, public array $genres = [] ) { if ($this->page < 1) { throw new \InvalidArgumentException('Page must be greater than 0'); } if ($this->limit < 1) { throw new \InvalidArgumentException('Limit must be greater than 0'); } } public function getOffset(): int { return ($this->page - 1) * $this->limit; } } namespace App\Domain\Manga\Domain\Repository; interface MangaRepositoryInterface { public function findByQuery(GetMangaListQuery $query): array; public function count(GetMangaListQuery $query): int; } namespace App\Domain\Manga\Infrastructure\Persistence\Repository; use App\Domain\Manga\Domain\Model\Manga; use App\Domain\Manga\Domain\Repository\MangaRepositoryInterface; use App\Domain\Manga\Infrastructure\Persistence\Entity\MangaEntity; use App\Domain\Manga\Infrastructure\Persistence\Mapper\MangaMapper; use Doctrine\ORM\EntityManagerInterface; readonly class DoctrineMangaRepository implements MangaRepositoryInterface { public function __construct( private EntityManagerInterface $entityManager, private MangaMapper $mapper ) {} public function findByQuery(GetMangaListQuery $query): array { $qb = $this->entityManager->createQueryBuilder() ->select('m') ->from(MangaEntity::class, 'm'); if ($query->search) { $qb->andWhere('m.title LIKE :search') ->setParameter('search', '%' . $query->search . '%'); } if (!empty($query->genres)) { $qb->andWhere('m.genres && :genres') ->setParameter('genres', $query->genres); } $qb->orderBy('m.' . $query->sortBy, $query->sortOrder) ->setFirstResult($query->getOffset()) ->setMaxResults($query->limit); return array_map( fn (MangaEntity $entity) => $this->mapper->toDomain($entity), $qb->getQuery()->getResult() ); } public function count(GetMangaListQuery $query): int { $qb = $this->entityManager->createQueryBuilder() ->select('COUNT(m.id)') ->from(MangaEntity::class, 'm'); if ($query->search) { $qb->andWhere('m.title LIKE :search') ->setParameter('search', '%' . $query->search . '%'); } if (!empty($query->genres)) { $qb->andWhere('m.genres && :genres') ->setParameter('genres', $query->genres); } return $qb->getQuery()->getSingleScalarResult(); } public function findById(string $id): ?Manga { $entity = $this->entityManager->find(MangaEntity::class, $id); return $entity ? $this->mapper->toDomain($entity) : null; } public function save(Manga $manga): void { $entity = $this->mapper->toEntity($manga); $this->entityManager->persist($entity); $this->entityManager->flush(); // Met à jour l'ID du modèle du domaine si nécessaire if ($entity->getId() && $manga->getId() === null) { $manga->updateId($entity->getId()); } } } ``` ### 2. Entity ```php namespace App\Domain\Manga\Infrastructure\Persistence\Entity; use Doctrine\ORM\Mapping as ORM; #[ORM\Entity] #[ORM\Table(name: 'manga')] class MangaEntity { #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] private ?int $id = null; #[ORM\Column(length: 255)] private string $title; #[ORM\Column(type: 'text', nullable: true)] private ?string $description = null; #[ORM\Column(type: 'json')] private array $authors = []; #[ORM\Column(length: 255, nullable: true)] private ?string $coverUrl = null; #[ORM\Column] private \DateTimeImmutable $createdAt; public function __construct() { $this->createdAt = new \DateTimeImmutable(); } // Getters public function getId(): ?int { return $this->id; } public function getTitle(): string { return $this->title; } public function getDescription(): ?string { return $this->description; } public function getAuthors(): array { return $this->authors; } public function getCoverUrl(): ?string { return $this->coverUrl; } public function getCreatedAt(): \DateTimeImmutable { return $this->createdAt; } // Setters (fluent interface) public function setTitle(string $title): self { $this->title = $title; return $this; } public function setDescription(?string $description): self { $this->description = $description; return $this; } // ... autres setters } ``` ### 3. Mapper ```php namespace App\Domain\Manga\Infrastructure\Persistence\Mapper; use App\Domain\Manga\Domain\Model\Manga; use App\Domain\Manga\Domain\Model\ValueObject\MangaId; use App\Domain\Manga\Domain\Model\ValueObject\Title; use App\Domain\Manga\Infrastructure\Persistence\Entity\MangaEntity; readonly class MangaMapper { public function toDomain(MangaEntity $entity): Manga { return new Manga( id: new MangaId((string) $entity->getId()), title: new Title($entity->getTitle()), description: $entity->getDescription(), authors: $entity->getAuthors(), coverUrl: $entity->getCoverUrl(), createdAt: $entity->getCreatedAt() ); } public function toEntity(Manga $manga): MangaEntity { $entity = new MangaEntity(); $entity->setTitle($manga->getTitle()->value()) ->setDescription($manga->getDescription()) ->setAuthors($manga->getAuthors()) ->setCoverUrl($manga->getCoverUrl()); return $entity; } } ``` ## Bonnes Pratiques ### 1. Gestion des Erreurs - Convertir les exceptions Doctrine en exceptions du domaine - Ne pas exposer les détails de l'infrastructure - Gérer les cas d'erreur spécifiques (contraintes uniques, etc.) ```php namespace App\Domain\Manga\Infrastructure\Persistence\Exception; class PersistenceException extends \RuntimeException { public static function entityNotFound(string $id): self { return new self(sprintf('Entity with id %s not found', $id)); } public static function uniqueConstraintViolation(string $field): self { return new self(sprintf('Entity with %s already exists', $field)); } } ``` ### 2. Performance - Utiliser les bonnes stratégies de chargement (EAGER vs LAZY) - Optimiser les requêtes avec des QueryBuilder - Paginer les résultats - Utiliser le cache quand nécessaire ### 3. Tests - Créer des repositories In-Memory pour les tests - Utiliser SQLite en mémoire pour les tests d'intégration - Tester les cas d'erreur - Vérifier les contraintes de base de données