---
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