Entité, Value Object, Data Transfer Object, Service.
Réflexion autour de la classification des différents types d'objets, en PHP.
Comme beaucoup de langages, PHP est un langage orienté objet.
Quand on développe dans un tel langage, on manipule donc des objets à tour de bras.
Déjà, un objet, c'est quoi ?
[...] objects, [...] can contain data and code: data in the form of fields (often known as attributes or properties), and code in the form of procedures (often known as methods).
Object-Oriented Programming
Donc un objet ne serait donc qu'un conteneur de données et de logique.
Dans le sens strict de ce qui le compose, c'est vrai, mais n'est-ce-pas un peu réducteur ?
Si vous avez déjà pratiqué un langage orienté objet, vous avez probablement déjà eu le sentiment que certaines choses devaient être faites sur certains objets, mais pas sur d'autres.
Est-ce-que vous envisageriez de mettre une méthode faisant un appel API dans un objet User
? Peut-être pas.
Mais pourquoi ? Qu'est-ce-qui différencie mon objet User
des autres objets de mon application ?
Vous le sentez sûrement venir, on range mentalement les objets dans des "familles", et on donne des caractéristiques à ces familles.
C'est donc tout le propos de cet article, passer en revue ces familles, et leurs caractéristiques, pour vous aider à vous y retrouver.
Note : Certains des termes qui seront utilisés ici peuvent rentrer en conflit avec ceux du Domain-Driven Design (DDD).
Chaque fois que ce sera le cas, une note indiquera les différences potentielles.
Une entité est un objet ayant un identifiant unique au sein d'un système.
final class Person
{
public function __construct(
public readonly string $username,
public string $firstName,
public string $lastName,
) {
}
}
final class TimeEntry
{
public function __construct(
public readonly int $id,
public readonly Person $person,
public readonly DateTimeInterface $day,
public Duration $duration,
) {
assert($id > 0);
assert($day <= new DateTimeImmutable());
}
}
Une entité peut avoir de multiples propriétés, dont au moins un identifiant :
Person
est son username
TimeEntry
est son id
Si on veut savoir si 2 instances d'une même entité sont les mêmes, il faut vérifier leur identité :
Person
ayant un username
avec la valeur "john.doe"
TimeEntry
ayant un id
avec la valeur 1659
Les autres propriétés qui composent ces entités sont ce que l'on appelle son état, et cet état peut changer :
User
peut changer de firstName
ou lastName
TimeEntry
peut changer de duration
Attention : une propriété ne pouvant pas être modifiée ne représente pas nécessairement l'identité de l'entité.
Les propriétés peuvent être d'autres entités, des value objects, ou de simples scalaires.
On retrouve souvent des entités lorsqu'on interagit avec une base de données, notamment au travers d'un ORM.
Chaque ligne d'une table SQL pouvant être considérée comme une entité différente.
Attention : la notion d'entité dans un ORM et en DDD ne sont pas forcément les mêmes.
Du point de vue du DDD, une entité doit être valide à tout moment, et dès sa construction.
Les 2 notions peuvent cohabiter au sein du même projet.
Comme son nom l'indique, un value object est un wrapper autour d'une (ou plusieurs) valeur(s).
final readonly class Duration
{
public function __construct(
public int $hours,
public int $minutes,
) {
assert($this->hours >= 0);
assert($this->minutes >= 0 && $this->minutes < 60);
}
public static function fromHoursDecimal(float $decimal): self
{
$decimal = round($decimal, 2);
$hours = (int)floor($decimal);
$minutes = (int)(($decimal - $hours) * 60);
return new self($hours, $minutes);
}
public function toHoursDecimal(): float
{
return round($this->hours + $this->minutes / 60, 2);
}
public function compare(self $duration): Comparison
{
return Comparison::fromMembersComparison($this->toHoursDecimal(), $duration->toHoursDecimal());
}
}
enum Comparison: int
{
case LowerThan = -1;
case Equal = 0;
case GreaterThan = 1;
public static function fromMembersComparison(int|float|string $left, int|float|string $right): self
{
return self::from($left <=> $right);
}
}
Un value object n'a pas d'identité ni d'état, et est habituellement immutable :
Duration
puisque la classe est readonlyComparison
puisque c'est un Enum
Un value object peut avoir plusieurs propriétés (même s'il est conseillé de viser un minimum).
Ces propriétés peuvent être d'autres value objects, ou de simples scalaires.
S'il est habituellement immutable, c'est qu'un value object est intrinsèquement lié à sa(ses) valeur(s).
Si on change sa(ses) valeurs, ce n'est plus le même value object.
Si on veut savoir si 2 instances d'un même value object sont les mêmes, il faut vérifier leur(s) valeur(s) :
Duration
ayant un hours
avec la valeur 1
et un minutes
avec la valeur 45
Comparison
ayant un value
avec la valeur 0
(mais pour les enum, vous pouvez comparer strictement 2 objets)Un value object sert donc à ajouter de la logique métier autour de certaines valeurs particulières.
Un enum n'étant qu'une forme très particulière de value object : où la liste de valeurs possibles est connue à l'avance.
Un objet DateTimeImmutable
est également un value object (en revanche, son pendant DateTime
ne satisfait pas à la règle d'immutabilité).
Attention : Du point de vue du DDD, un value object doit être valide à sa construction.
Un DTO est un conteneur de données qui doivent être rassemblées pour être transférées en une fois.
final class AddTimeEntry
{
public function __construct(
public string $username,
public DateTimeInterface $day,
public float $duration,
) {
}
}
final class UpdateTimeEntry
{
public function __construct(
public int $id,
public float $duration,
) {
}
}
Un DTO n'a pas d'identité, et peut être mutable :
AddTimeEntry
sont modifiables après sa constructionUpdateTimeEntry
sont modifiables après sa constructionLes propriétés qui composent un DTO peuvent être d'autres DTO, des value objects, ou de simples scalaires.
Contrairement à une entité ou un value object, un DTO n'a aucune garantie de validité.
Même s'il arrive fréquemment que l'on souhaite les valider avant de les considérer.
On retrouve souvent des DTO lorsque l'on souhaite rassembler une grande liste de paramètres dans un seul objet.
Une commande, un message, ou un événement peuvent également être considérés comme des DTOs.
Attention : en DDD, un événement, bien qu'il puisse être considéré comme un DTO, est un objet précieux qui doit également être valide en toute circonstance.
Comme son nom l'indique, un service effectue une action ou calcule une information.
final readonly class TimeTracker
{
private function __construct(
private PersonRepository $personRepository,
private TimeEntryRepository $timeEntryRepository,
) {
}
public function add(AddTimeEntry $command): void
{
$this->timeEntryRepository->add(
$this->personRepository->get($command->username),
$command->day,
Duration::fromHoursDecimal($command->duration),
);
}
public function update(UpdateTimeEntry $command): void
{
$entry = $this->timeEntryRepository->get($command->id);
$duration = Duration::fromHoursDecimal($command->duration);
if ($duration->compare($entry->duration) === Comparison::Equal) {
return;
}
$entry->duration = $duration;
$this->timeEntryRepository->update($entry);
}
}
Un service a souvent des propriétés, des dépendances, qui l'aident à faire son travail.
Il est recommandé que ces propriétés soient immutables (pour éviter les effets de bords), même s'il peut arriver que cela soit nécessaire.
Ces propriétés peuvent être d'autres services, des value objects, ou de simples scalaires.
Un service est toujours valide, mais sa validité réside dans votre capacité à l'instancier.
Attention : il faut dissocier la notion de service au sens où on l'entend ici à celle d'un service dans Symfony (par exemple).
Dans Symfony, un service est une instance d'une classe, disponible dans le conteneur de service.
Il peut donc exister plusieurs instances d'une même classe au sein du même conteneur, avec un jeu de dépendances différentes (par exemple).
Ce qui serait vraiment génial, ça serait de pouvoir faire valider ces règles sur nos familles d'objets.
Par exemple, avoir des règles de code style différentes, selon que la famille à laquelle la classe appartient.
Pour commencer, il faudrait déjà que l'on soit capable d'associer une famille à chacune de nos classes.
L'utilisation d'un attribut pourrait satisfaire ce besoin :
#[Attribute(Attribute::TARGET_CLASS)]
final readonly class ObjectType
{
public const ENTITY = 'entity';
public const VALUE_OBJECT = 'vo';
public const DATA_TRANSFER_OBJECT = 'dto';
public const SERVICE = 'service';
public function __construct(
public string $type,
) {
assert(in_array($type, [
self::ENTITY,
self::VALUE_OBJECT,
self::DATA_TRANSFER_OBJECT,
self::SERVICE,
]));
}
}
#[ObjectType(ObjectType::VALUE_OBJECT)]
enum Comparison: int
{
}
#[ObjectType(ObjectType::VALUE_OBJECT)]
final readonly class Duration
{
}
#[ObjectType(ObjectType::ENTITY)]
final class Person
{
}
#[ObjectType(ObjectType::ENTITY)]
final class TimeEntry
{
}
#[ObjectType(ObjectType::DATA_TRANSFER_OBJECT)]
final class AddTimeEntry
{
}
#[ObjectType(ObjectType::DATA_TRANSFER_OBJECT)]
final class UpdateTimeEntry
{
}
#[ObjectType(ObjectType::SERVICE)]
final readonly class TimeTracker
{
}
Reste à collecter l'ensemble des classes avec cet attribut :
use olvlvl\ComposerAttributeCollector\Attributes;
#[Attribute(Attribute::TARGET_CLASS)]
final readonly class ObjectType
{
//...
/**
* @return \Generator<string>
*/
public static function files(string $type): \Generator
{
foreach (Attributes::findTargetClasses(self::class) as $target) {
/** @var ObjectType $attribute */
$attribute = $target->attribute;
if ($attribute->type === $type) {
yield (new \ReflectionClass($target->name))->getFileName();
}
}
}
}
Note : Pour se simplifier la vie dans la collecte des classes ayant un attribut, on a utilisé
olvlvl/composer-attribute-collector
.
Comme notre binaire output les chemins de chaque classe ayant la famille de classe qui nous intéresse, on peut récupérer son output et le passer à notre outil de checkstyle préféré.
Chez PrestaConcept, on utilise symplify/easy-coding-standard
.
Un fichier de configuration du checkstyle par famille plus tard :
// ecs.dto.php
use App\ObjectType;
use Symplify\EasyCodingStandard\Config\ECSConfig;
require_once __DIR__ . '/vendor/attributes.php';
return ECSConfig::configure()
->withPaths(\iterator_to_array(ObjectType::files(ObjectType::DATA_TRANSFER_OBJECT)))
->withRules([
// todo every Data Transfert Object must not be readonly
// todo every Data Transfert Object must be public
// ...
]);
// ecs.entity.php
use App\ObjectType;
use Symplify\EasyCodingStandard\Config\ECSConfig;
require_once __DIR__ . '/vendor/attributes.php';
return ECSConfig::configure()
->withPaths(\iterator_to_array(ObjectType::files(ObjectType::ENTITY)))
->withRules([
// todo every Entity must have at least one readonly property
// ...
]);
// ecs.service.php
use App\ObjectType;
use Symplify\EasyCodingStandard\Config\ECSConfig;
require_once __DIR__ . '/vendor/attributes.php';
return ECSConfig::configure()
->withPaths(\iterator_to_array(ObjectType::files(ObjectType::SERVICE)))
->withRules([
// todo every Service must final
// todo every Service properties must be private
// ...
]);
// ecs.vo.php
use App\ObjectType;
use Symplify\EasyCodingStandard\Config\ECSConfig;
require_once __DIR__ . '/vendor/attributes.php';
return ECSConfig::configure()
->withPaths(\iterator_to_array(ObjectType::files(ObjectType::VALUE_OBJECT)))
->withRules([
// todo every Value Object must be readonly
// todo every Value Object properties must be public
// ...
]);
Et nous voilà prêts à automatiser la vérification :
vendor/bin/ecs check --config=ecs.dto.php
vendor/bin/ecs check --config=ecs.entity.php
vendor/bin/ecs check --config=ecs.service.php
vendor/bin/ecs check --config=ecs.vo.php
Bien évidemment, les règles que vous allez écrire dans chacun de ces fichiers vous regardent.
Libre à vous d'implémenter ce que vous voulez sur chaque famille, selon vos convictions.