feat: debut du domain Shared avec Contracts et Jobs + rules pour cursor
This commit is contained in:
parent
19a697c712
commit
ca9a74fe69
302
.cursor/rules/persistence.mdc
Normal file
302
.cursor/rules/persistence.mdc
Normal file
@@ -0,0 +1,302 @@
|
||||
---
|
||||
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
|
||||
Reference in New Issue
Block a user