From ca9a74fe69cb56b212fd168fdd50c3518e27635f Mon Sep 17 00:00:00 2001 From: "ext.jeremy.guillot@maxicoffee.domains" Date: Mon, 24 Mar 2025 14:56:18 +0100 Subject: [PATCH] feat: debut du domain Shared avec Contracts et Jobs + rules pour cursor --- .cursor/rules/api_platform.mdc | 221 +++++++------ .cursor/rules/jobs.mdc | 163 ++++++++++ .cursor/rules/persistence.mdc | 302 ++++++++++++++++++ .../Contract/CommandHandlerInterface.php | 2 +- .../Contract/CommandInterface.php | 2 +- .../Contract/FailedJobRepositoryInterface.php | 16 + .../Contract/JobRepositoryInterface.php | 17 + .../Contract/QueryHandlerInterface.php | 2 +- .../{ => Domain}/Contract/QueryInterface.php | 2 +- .../Contract/ResponseInterface.php | 2 +- .../Domain/Exception/JobNotFoundException.php | 16 + .../Exception/JobNotRetryableException.php | 16 + src/Domain/Shared/Domain/Model/FailedJob.php | 29 ++ src/Domain/Shared/Domain/Model/Job.php | 56 ++++ src/Domain/Shared/Domain/Model/JobStatus.php | 12 + 15 files changed, 748 insertions(+), 110 deletions(-) create mode 100644 .cursor/rules/jobs.mdc create mode 100644 .cursor/rules/persistence.mdc rename src/Domain/Shared/{ => Domain}/Contract/CommandHandlerInterface.php (83%) rename src/Domain/Shared/{ => Domain}/Contract/CommandInterface.php (59%) create mode 100644 src/Domain/Shared/Domain/Contract/FailedJobRepositoryInterface.php create mode 100644 src/Domain/Shared/Domain/Contract/JobRepositoryInterface.php rename src/Domain/Shared/{ => Domain}/Contract/QueryHandlerInterface.php (85%) rename src/Domain/Shared/{ => Domain}/Contract/QueryInterface.php (58%) rename src/Domain/Shared/{ => Domain}/Contract/ResponseInterface.php (59%) create mode 100644 src/Domain/Shared/Domain/Exception/JobNotFoundException.php create mode 100644 src/Domain/Shared/Domain/Exception/JobNotRetryableException.php create mode 100644 src/Domain/Shared/Domain/Model/FailedJob.php create mode 100644 src/Domain/Shared/Domain/Model/Job.php create mode 100644 src/Domain/Shared/Domain/Model/JobStatus.php diff --git a/.cursor/rules/api_platform.mdc b/.cursor/rules/api_platform.mdc index 7093d9b..6af2d4b 100644 --- a/.cursor/rules/api_platform.mdc +++ b/.cursor/rules/api_platform.mdc @@ -3,19 +3,16 @@ description: globs: alwaysApply: true --- -# API Platform dans Mangarr - -## Structure de l'API -L'API est organisée dans la couche Infrastructure de chaque domaine : - ``` Domain/Manga/Infrastructure/ApiPlatform/ -├── Resource/ # Configuration des ressources API -│ └── MangaResource.php -├── State/ # Providers et Processors - ├── Provider/ # State Providers - └── Processor/ # State Processors - +├── Resource/ # Resources API par opération +│ └── GetMangaResource.php # Resources pour l'opération Get +│ └── CreateMangaResource.php # Resources pour l'opération Create +├── State/ # Providers et Processors par opération + ├── Provider/ # State Providers + │ └── GetMangaStateProvider.php + └── Processor/ # State Processors + └── CreateMangaStateProcessor.php ``` ## Règles d'Organisation @@ -23,18 +20,30 @@ Domain/Manga/Infrastructure/ApiPlatform/ ### 1. Resources - Localisation : `Infrastructure/ApiPlatform/Resource/` - Principes : - - Une classe = une ressource API + - Une Resource par Operation + - Validation des données avec les attributs Symfony dans la Resource - Documentation exhaustive avec les attributs PHP 8 - - Validation contraintes avec les attributs Symfony - - Nommage : `{Nom}Resource` + - Nommage : `{Operation}Resource` + - Contient tous les attributs nécessaires en public + - Doit implémenter les interfaces de validation appropriées -### 2. State Providers/Processors -- Localisation : `Infrastructure/ApiPlatform/State/` +### 2. State Providers +- Localisation : `Infrastructure/ApiPlatform/State/Provider/` - Principes : - - Utiliser les cas d'utilisation du domaine (Commands/Queries) - - Ne pas contenir de logique métier - - Conversion Resource ↔ Command/Query - - Nommage : `{Action}{Resource}StateProvider/Processor` + - Un Provider par Operation de type Query + - Utilise les QueryHandler du domaine + - Convertit la Response du QueryHandler en Resource + - Renvoie toujours une Resource + - Nommage : `{Operation}StateProvider` + +### 3. State Processors +- Localisation : `Infrastructure/ApiPlatform/State/Processor/` +- Principes : + - Un Processor par Operation de type Command + - Utilise les CommandHandler du domaine + - Convertit la Resource en Command + - Renvoie uniquement un code HTTP + - Nommage : `{Operation}StateProcessor` ## Exemples de Code @@ -44,9 +53,7 @@ namespace App\Domain\Manga\Infrastructure\ApiPlatform\Resource; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Get; -use ApiPlatform\Metadata\Post; use App\Domain\Manga\Infrastructure\ApiPlatform\State\Provider\GetMangaStateProvider; -use App\Domain\Manga\Infrastructure\ApiPlatform\State\Processor\CreateMangaStateProcessor; use Symfony\Component\Validator\Constraints as Assert; #[ApiResource( @@ -55,84 +62,44 @@ use Symfony\Component\Validator\Constraints as Assert; new Get( uriTemplate: '/mangas/{id}', provider: GetMangaStateProvider::class, - description: 'Récupère un manga par son identifiant', - openapi: [ - 'summary' => 'Récupère un manga', - '200' => [ - 'description' => 'Manga trouvé', - 'content' => [ - 'application/json' => [ - 'schema' => [ - 'type' => 'object', - 'properties' => [ - 'id' => ['type' => 'string', 'format' => 'uuid'], - 'title' => ['type' => 'string'], - 'description' => ['type' => 'string', 'nullable' => true], - 'authors' => [ - 'type' => 'array', - 'items' => ['type' => 'string'] - ], - 'coverUrl' => ['type' => 'string', 'format' => 'uri', 'nullable' => true] - ], - 'required' => ['id', 'title', 'authors'] - ] - ] - ] - ], - '404' => [ - 'description' => 'Manga non trouvé' - ] - ] - ), - new Post( - uriTemplate: '/mangas', - processor: CreateMangaStateProcessor::class, - description: 'Crée un nouveau manga', - openapi: [ - 'requestBody' => [ - 'content' => [ - 'application/json' => [ - 'schema' => [ - 'type' => 'object', - 'properties' => [ - 'title' => ['type' => 'string'], - 'description' => ['type' => 'string', 'nullable' => true], - 'authors' => [ - 'type' => 'array', - 'items' => ['type' => 'string'] - ], - 'coverUrl' => ['type' => 'string', 'format' => 'uri', 'nullable' => true] - ], - 'required' => ['title'] - ] - ] - ] - ], - 'responses' => [ - '201' => [ - 'description' => 'Manga créé' - ], - '400' => [ - 'description' => 'Données invalides' - ] - ] - ] + output: GetMangaResource::class, + description: 'Récupère un manga par son identifiant' ) ] )] -class MangaResource +class GetMangaResource { + public function __construct( + #[Assert\NotBlank] + #[Assert\Uuid] + public readonly string $id, + + #[Assert\NotBlank] + public readonly string $title, + + public readonly ?string $description = null, + + #[Assert\NotBlank] + #[Assert\All([ + new Assert\Type('string') + ])] + public readonly array $authors = [], + + #[Assert\Url] + public readonly ?string $coverUrl = null + ) {} } ``` ### 2. State Provider ```php +namespace App\Domain\Manga\Infrastructure\ApiPlatform\Resource\CreateManga; namespace App\Domain\Manga\Infrastructure\ApiPlatform\State\Provider; use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ProviderInterface; use App\Domain\Manga\Application\Query\GetMangaByIdQuery; -use App\Domain\Shared\Contract\Response; +use App\Domain\Manga\Infrastructure\ApiPlatform\Resource\GetMangaResource; use Symfony\Component\Messenger\MessageBusInterface; class GetMangaStateProvider implements ProviderInterface @@ -141,7 +108,7 @@ class GetMangaStateProvider implements ProviderInterface private readonly MessageBusInterface $queryBus ) {} - public function provide(Operation $operation, array $uriVariables = [], array $context = []): ?Response + public function provide(Operation $operation, array $uriVariables = [], array $context = []): ?GetMangaResource { $query = new GetMangaByIdQuery($uriVariables['id']); $response = $this->queryBus->dispatch($query); @@ -150,18 +117,72 @@ class GetMangaStateProvider implements ProviderInterface return null; } - return $response; + return new GetMangaResource( + id: $response->id, + title: $response->title, + description: $response->description, + authors: $response->authors, + coverUrl: $response->coverUrl + ); } } ``` -### 3. State Processor +### 3. Resource CreateManga +```php +namespace App\Domain\Manga\Infrastructure\ApiPlatform\Resource; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Post; +use App\Domain\Manga\Infrastructure\ApiPlatform\State\Processor\CreateMangaStateProcessor; +use Symfony\Component\Validator\Constraints as Assert; + +#[ApiResource( + shortName: 'Manga', + operations: [ + new Post( + uriTemplate: '/mangas', + processor: CreateMangaStateProcessor::class, + input: CreateMangaResource::class, + status: 201, + description: 'Crée un nouveau manga' + ) + ] +)] +class CreateMangaResource +{ + public function __construct( + #[Assert\NotBlank(message: 'Le titre est obligatoire')] + #[Assert\Length(min: 1, max: 255)] + public readonly string $title, + + #[Assert\Length(max: 1000)] + public readonly ?string $description = null, + + #[Assert\NotNull] + #[Assert\Count(min: 1, max: 10)] + #[Assert\All([ + new Assert\Type('string'), + new Assert\Length(min: 1, max: 100) + ])] + public readonly array $authors = [], + + #[Assert\Url] + #[Assert\Length(max: 255)] + public readonly ?string $coverUrl = null + ) {} +} +``` + +### 4. State Processor ```php namespace App\Domain\Manga\Infrastructure\ApiPlatform\State\Processor; use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ProcessorInterface; use App\Domain\Manga\Application\Command\CreateMangaCommand; +use App\Domain\Manga\Infrastructure\ApiPlatform\Resource\CreateMangaResource; +use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Messenger\MessageBusInterface; class CreateMangaStateProcessor implements ProcessorInterface @@ -170,9 +191,9 @@ class CreateMangaStateProcessor implements ProcessorInterface private readonly MessageBusInterface $commandBus ) {} - public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): void + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): int { - assert($data instanceof MangaResource); + assert($data instanceof CreateMangaResource); $command = new CreateMangaCommand( title: $data->title, @@ -182,6 +203,8 @@ class CreateMangaStateProcessor implements ProcessorInterface ); $this->commandBus->dispatch($command); + + return Response::HTTP_CREATED; } } ``` @@ -195,19 +218,7 @@ class CreateMangaStateProcessor implements ProcessorInterface - Documentation des codes d'erreur ### 2. Validation -- Validation stricte des entrées +- Validation dans les Resources uniquement - Groupes de validation par contexte - Messages d'erreur explicites -- Validation des types et formats - -### 3. Sécurité -- Définition claire des accès -- Validation des permissions -- Sanitization des entrées -- Gestion des erreurs sécurisée - -### 4. Performance -- Pagination par défaut -- Sélection des champs (sparse fieldsets) -- Gestion des includes (relationships) -- Cache approprié \ No newline at end of file +- Validation des types et formats \ No newline at end of file diff --git a/.cursor/rules/jobs.mdc b/.cursor/rules/jobs.mdc new file mode 100644 index 0000000..4f953ef --- /dev/null +++ b/.cursor/rules/jobs.mdc @@ -0,0 +1,163 @@ +--- +description: need to create or find a job +globs: +alwaysApply: false +--- +# Architecture des Jobs dans Mangarr + +## Vue d'ensemble + +Le système de jobs de Mangarr est conçu pour gérer les tâches asynchrones et de longue durée de manière uniforme à travers tous les domaines. Il est basé sur une architecture centralisée dans le domaine `Shared` et peut être étendu par chaque domaine spécifique. + +## Structure + +``` +src/Domain/Shared/ +├── Domain/ +│ ├── Model/ +│ │ ├── Job.php # Classe abstraite de base +│ │ ├── JobStatus.php # États possibles d'un job +│ │ └── FailedJob.php # Représentation d'un job échoué +│ ├── Contract/ +│ │ ├── JobRepositoryInterface.php +│ │ └── FailedJobRepositoryInterface.php +│ └── Exception/ +│ ├── JobNotFoundException.php +│ └── JobNotRetryableException.php +└── Infrastructure/ + ├── Persistence/ + │ ├── Entity/ + │ │ ├── JobEntity.php + │ │ └── FailedJobEntity.php + │ └── Repository/ + │ ├── DoctrineJobRepository.php + │ └── DoctrineFailedJobRepository.php + └── Service/ + └── JobRetryService.php +``` + +## Cycle de Vie d'un Job + +### États Possibles +```php +enum JobStatus: string +{ + case PENDING = 'pending'; // Job créé, en attente d'exécution + case IN_PROGRESS = 'in_progress';// Job en cours d'exécution + case COMPLETED = 'completed'; // Job terminé avec succès + case FAILED = 'failed'; // Job échoué définitivement + case CANCELLED = 'cancelled'; // Job annulé manuellement +} +``` + +### Transitions d'États +1. `PENDING` → `IN_PROGRESS` : Lors du démarrage du job +2. `IN_PROGRESS` → `COMPLETED` : Lorsque le job se termine avec succès +3. `IN_PROGRESS` → `FAILED` : Lorsque le job échoue et atteint le nombre maximum de tentatives +4. `IN_PROGRESS` → `PENDING` : Lorsque le job échoue mais peut être réessayé +5. Tout état → `CANCELLED` : Lorsque le job est annulé manuellement + +## Création d'un Nouveau Type de Job + +1. **Créer une classe de job spécifique** +```php +class MyCustomJob extends Job +{ + public function __construct( + string $id, + public readonly string $someData, + public readonly array $additionalData = [] + ) { + parent::__construct($id, 'my_custom_job'); + } +} +``` + +2. **Définir le Handler** +```php +class MyCustomJobHandler +{ + public function __invoke(MyCustomJob $job): void + { + try { + $job->start(); + // Logique métier + $job->complete(); + } catch (\Exception $e) { + $job->fail($e->getMessage()); + } + } +} +``` + +## Gestion des Échecs + +### Retry Automatique +- Un job peut être réessayé tant que `$attempts < $maxAttempts` +- Lors d'un échec, si des tentatives sont encore possibles, le statut redevient `PENDING` +- Les informations d'échec sont conservées dans `FailedJob` + +### Informations de Debug +Chaque job contient : +- `failureReason` : La raison de l'échec +- `attempts` : Nombre de tentatives effectuées +- `context` : Données contextuelles pour le debug +- `createdAt`, `startedAt`, `completedAt` : Timestamps pour le suivi + +## Bonnes Pratiques + +### 1. Création de Jobs +```php +$job = new MyCustomJob( + id: Uuid::v4(), + someData: 'data', + additionalData: ['key' => 'value'] +); +``` + +### 2. Gestion du Contexte +```php +$job->context['important_info'] = 'value'; +$job->context['debug_data'] = $debugInfo; +``` + +### 3. Retry Manuel +```php +if ($failedJob->canBeRetried()) { + $job->attempts = 0; + $job->status = JobStatus::PENDING; + $jobRepository->save($job); +} +``` + +### 4. Monitoring +- Utiliser `findByStatus()` pour surveiller les jobs par état +- Utiliser `findFailedJobs()` pour vérifier les échecs +- Consulter `FailedJob` pour les détails des échecs + +## Règles Importantes + +1. **Idempotence** + - Les jobs doivent être idempotents + - Gérer les cas de réexécution + - Vérifier l'état avant les opérations + +2. **Contexte** + - Toujours fournir un contexte utile + - Inclure les IDs des entités concernées + - Ajouter des informations de debug pertinentes + +3. **Durée** + - Les jobs doivent être de longue durée + - Pour les opérations courtes, utiliser des appels directs + - Prévoir des timeouts appropriés + +4. **Statut** + - Ne jamais modifier le statut directement + - Utiliser les méthodes `start()`, `complete()`, `fail()`, `cancel()` + - Toujours sauvegarder après un changement de statut + +5. **Échecs** + - Capturer et logger toutes les exceptions + - Fournir des messages d'erreur explicites + - Conserver le contexte d'échec pour le debug \ No newline at end of file diff --git a/.cursor/rules/persistence.mdc b/.cursor/rules/persistence.mdc new file mode 100644 index 0000000..800503d --- /dev/null +++ b/.cursor/rules/persistence.mdc @@ -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 \ No newline at end of file diff --git a/src/Domain/Shared/Contract/CommandHandlerInterface.php b/src/Domain/Shared/Domain/Contract/CommandHandlerInterface.php similarity index 83% rename from src/Domain/Shared/Contract/CommandHandlerInterface.php rename to src/Domain/Shared/Domain/Contract/CommandHandlerInterface.php index 99f8903..a9fe28f 100644 --- a/src/Domain/Shared/Contract/CommandHandlerInterface.php +++ b/src/Domain/Shared/Domain/Contract/CommandHandlerInterface.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Domain\Shared\Contract; +namespace App\Domain\Shared\Domain\Contract; interface CommandHandlerInterface { diff --git a/src/Domain/Shared/Contract/CommandInterface.php b/src/Domain/Shared/Domain/Contract/CommandInterface.php similarity index 59% rename from src/Domain/Shared/Contract/CommandInterface.php rename to src/Domain/Shared/Domain/Contract/CommandInterface.php index 1fa324a..e621403 100644 --- a/src/Domain/Shared/Contract/CommandInterface.php +++ b/src/Domain/Shared/Domain/Contract/CommandInterface.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Domain\Shared\Contract; +namespace App\Domain\Shared\Domain\Contract; interface CommandInterface { diff --git a/src/Domain/Shared/Domain/Contract/FailedJobRepositoryInterface.php b/src/Domain/Shared/Domain/Contract/FailedJobRepositoryInterface.php new file mode 100644 index 0000000..059b06f --- /dev/null +++ b/src/Domain/Shared/Domain/Contract/FailedJobRepositoryInterface.php @@ -0,0 +1,16 @@ +id, + jobType: $job->type, + failureReason: $job->failureReason ?? 'Unknown error', + context: $job->context, + failedAt: new \DateTimeImmutable(), + attempt: $job->attempts + ); + } +} \ No newline at end of file diff --git a/src/Domain/Shared/Domain/Model/Job.php b/src/Domain/Shared/Domain/Model/Job.php new file mode 100644 index 0000000..5ea2372 --- /dev/null +++ b/src/Domain/Shared/Domain/Model/Job.php @@ -0,0 +1,56 @@ +status = JobStatus::PENDING; + $this->createdAt = new \DateTimeImmutable(); + } + + public function start(): void + { + $this->status = JobStatus::IN_PROGRESS; + $this->startedAt = new \DateTimeImmutable(); + $this->attempts++; + } + + public function complete(): void + { + $this->status = JobStatus::COMPLETED; + $this->completedAt = new \DateTimeImmutable(); + } + + public function fail(string $reason): void + { + $this->failureReason = $reason; + $this->status = $this->attempts >= $this->maxAttempts + ? JobStatus::FAILED + : JobStatus::PENDING; + $this->completedAt = new \DateTimeImmutable(); + } + + public function cancel(): void + { + $this->status = JobStatus::CANCELLED; + $this->completedAt = new \DateTimeImmutable(); + } + + public function canBeRetried(): bool + { + return $this->status === JobStatus::FAILED && $this->attempts < $this->maxAttempts; + } +} \ No newline at end of file diff --git a/src/Domain/Shared/Domain/Model/JobStatus.php b/src/Domain/Shared/Domain/Model/JobStatus.php new file mode 100644 index 0000000..d72cd9c --- /dev/null +++ b/src/Domain/Shared/Domain/Model/JobStatus.php @@ -0,0 +1,12 @@ +