Files
Mangarr/.cursor/rules/persistence.mdc
2025-03-24 14:56:18 +01:00

302 lines
8.4 KiB
Plaintext

---
description:
globs:
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