302 lines
8.4 KiB
Plaintext
302 lines
8.4 KiB
Plaintext
---
|
|
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 |