feat: debut du domain Shared avec Contracts et Jobs + rules pour cursor

This commit is contained in:
ext.jeremy.guillot@maxicoffee.domains
2025-03-24 14:56:18 +01:00
parent 19a697c712
commit ca9a74fe69
15 changed files with 748 additions and 110 deletions

View File

@@ -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
├── 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é

163
.cursor/rules/jobs.mdc Normal file
View File

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

View 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

View File

@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace App\Domain\Shared\Contract;
namespace App\Domain\Shared\Domain\Contract;
interface CommandHandlerInterface
{

View File

@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace App\Domain\Shared\Contract;
namespace App\Domain\Shared\Domain\Contract;
interface CommandInterface
{

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Domain\Shared\Domain\Contract;
use App\Domain\Shared\Domain\Model\FailedJob;
interface FailedJobRepositoryInterface
{
public function save(FailedJob $failedJob): void;
public function get(string $id): ?FailedJob;
public function delete(string $id): void;
public function findAll(): array;
public function findByJobType(string $jobType): array;
public function findByJobId(string $jobId): array;
public function findRetryableJobs(): array;
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Domain\Shared\Domain\Contract;
use App\Domain\Shared\Domain\Model\Job;
use App\Domain\Shared\Domain\Model\JobStatus;
interface JobRepositoryInterface
{
public function save(Job $job): void;
public function get(string $id): ?Job;
public function findByStatus(JobStatus $status): array;
public function findByType(string $type): array;
public function findPendingJobs(): array;
public function findInProgressJobs(): array;
public function findFailedJobs(): array;
}

View File

@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace App\Domain\Shared\Contract;
namespace App\Domain\Shared\Domain\Contract;
interface QueryHandlerInterface
{

View File

@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace App\Domain\Shared\Contract;
namespace App\Domain\Shared\Domain\Contract;
interface QueryInterface
{

View File

@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace App\Domain\Shared\Contract;
namespace App\Domain\Shared\Domain\Contract;
interface ResponseInterface
{

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Domain\Shared\Domain\Exception;
class JobNotFoundException extends \DomainException
{
public static function withId(string $id): self
{
return new self(sprintf('Job with id "%s" not found', $id));
}
public static function withJobId(string $jobId): self
{
return new self(sprintf('Failed job with job id "%s" not found', $jobId));
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Domain\Shared\Domain\Exception;
class JobNotRetryableException extends \DomainException
{
public static function maxAttemptsReached(string $jobId, int $maxAttempts): self
{
return new self(sprintf('Job "%s" has reached its maximum number of attempts (%d)', $jobId, $maxAttempts));
}
public static function notFailed(string $jobId): self
{
return new self(sprintf('Cannot retry job "%s" because it is not in failed status', $jobId));
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Domain\Shared\Domain\Model;
class FailedJob
{
public function __construct(
public readonly string $id,
public readonly string $jobId,
public readonly string $jobType,
public readonly string $failureReason,
public readonly array $context,
public readonly \DateTimeImmutable $failedAt,
public readonly int $attempt
) {}
public static function fromJob(Job $job): self
{
return new self(
id: uniqid('failed_', true),
jobId: $job->id,
jobType: $job->type,
failureReason: $job->failureReason ?? 'Unknown error',
context: $job->context,
failedAt: new \DateTimeImmutable(),
attempt: $job->attempts
);
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Domain\Shared\Domain\Model;
abstract class Job
{
public JobStatus $status;
public \DateTimeImmutable $createdAt;
public ?\DateTimeImmutable $startedAt = null;
public ?\DateTimeImmutable $completedAt = null;
public ?string $failureReason = null;
public int $attempts = 0;
public int $maxAttempts = 3;
public array $context = [];
public function __construct(
public readonly string $id,
public readonly string $type
) {
$this->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;
}
}

View File

@@ -0,0 +1,12 @@
<?php
namespace App\Domain\Shared\Domain\Model;
enum JobStatus: string
{
case PENDING = 'pending';
case IN_PROGRESS = 'in_progress';
case COMPLETED = 'completed';
case FAILED = 'failed';
case CANCELLED = 'cancelled';
}