Primitive Obsession
Primitive Obsession
Qu'est-ce que la Primitive Obsession ?
La Primitive Obsession est un anti-pattern en programmation qui se produit lorsque des types primitifs (comme les chaînes, les entiers, les booléens, etc.) sont utilisés pour représenter des concepts métier complexes. Cela peut entraîner un code difficile à comprendre, à maintenir et à étendre, car les primitives ne capturent pas la richesse sémantique ou le comportement des concepts qu'elles représentent.
Cas courants de Primitive Obsession
Voici quelques situations typiques où la Primitive Obsession se manifeste :
- Identifiants : Utiliser des chaînes ou des entiers simples pour représenter des ID.
- Informations de contact : Stocker des numéros de téléphone, adresses email, etc. sous forme de simples chaînes.
- Unités de mesure : Représenter des montants, des distances ou des poids par de simples nombres.
- États et énumérations : Utiliser des chaînes ou des entiers pour représenter des états ou des types.
- Dates et heures : Manipuler des dates comme des chaînes ou des timestamps.
Exemples en PHP
Mauvaise utilisation des types primitifs
class Order {
private $orderId;
private $customerId;
private $totalAmount;
private $status; // "pending", "shipped", "delivered"
private $email; // Email du client
public function __construct(string $orderId, string $customerId, float $totalAmount, string $status, string $email) {
$this->orderId = $orderId;
$this->customerId = $customerId;
$this->totalAmount = $totalAmount;
$this->status = $status;
$this->email = $email;
}
public function getOrderId(): string {
return $this->orderId;
}
public function getCustomerId(): string {
return $this->customerId;
}
public function getTotalAmount(): float {
return $this->totalAmount;
}
public function getStatus(): string {
return $this->status;
}
public function getEmail(): string {
return $this->email;
}
public function setStatus(string $status): void {
// Pas de validation, n'importe quelle chaîne peut être acceptée
$this->status = $status;
}
}
Amélioration avec des objets de valeur
class OrderId {
private $id;
public function __construct(string $id) {
if (empty($id)) {
throw new InvalidArgumentException("L'ID de commande ne peut pas être vide");
}
$this->id = $id;
}
public function getValue(): string {
return $this->id;
}
public function equals(OrderId $other): bool {
return $this->id === $other->getValue();
}
}
class CustomerId {
private $id;
public function __construct(string $id) {
if (empty($id)) {
throw new InvalidArgumentException("L'ID client ne peut pas être vide");
}
$this->id = $id;
}
public function getValue(): string {
return $this->id;
}
public function equals(CustomerId $other): bool {
return $this->id === $other->getValue();
}
}
class Money {
private $amount;
private $currency;
public function __construct(float $amount, string $currency = 'EUR') {
if ($amount < 0) {
throw new InvalidArgumentException("Le montant ne peut pas être négatif");
}
$this->amount = $amount;
$this->currency = $currency;
}
public function getAmount(): float {
return $this->amount;
}
public function getCurrency(): string {
return $this->currency;
}
public function add(Money $other): Money {
if ($this->currency !== $other->getCurrency()) {
throw new InvalidArgumentException("Impossible d'additionner des montants de devises différentes");
}
return new Money($this->amount + $other->getAmount(), $this->currency);
}
}
class OrderStatus {
private const PENDING = 'pending';
private const SHIPPED = 'shipped';
private const DELIVERED = 'delivered';
private const CANCELED = 'canceled';
private static $validStatuses = [
self::PENDING,
self::SHIPPED,
self::DELIVERED,
self::CANCELED,
];
private $status;
public function __construct(string $status) {
if (!in_array($status, self::$validStatuses)) {
throw new InvalidArgumentException("Statut de commande invalide: {$status}");
}
$this->status = $status;
}
public function getValue(): string {
return $this->status;
}
public static function pending(): self {
return new self(self::PENDING);
}
public static function shipped(): self {
return new self(self::SHIPPED);
}
public static function delivered(): self {
return new self(self::DELIVERED);
}
public static function canceled(): self {
return new self(self::CANCELED);
}
public function isPending(): bool {
return $this->status === self::PENDING;
}
public function isShipped(): bool {
return $this->status === self::SHIPPED;
}
public function isDelivered(): bool {
return $this->status === self::DELIVERED;
}
public function isCanceled(): bool {
return $this->status === self::CANCELED;
}
}
class EmailAddress {
private $email;
public function __construct(string $email) {
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException("Adresse email invalide: {$email}");
}
$this->email = $email;
}
public function getValue(): string {
return $this->email;
}
public function getDomain(): string {
return substr(strrchr($this->email, "@"), 1);
}
}
class Order {
private $orderId;
private $customerId;
private $totalAmount;
private $status;
private $email;
public function __construct(
OrderId $orderId,
CustomerId $customerId,
Money $totalAmount,
OrderStatus $status,
EmailAddress $email
) {
$this->orderId = $orderId;
$this->customerId = $customerId;
$this->totalAmount = $totalAmount;
$this->status = $status;
$this->email = $email;
}
public function getOrderId(): OrderId {
return $this->orderId;
}
public function getCustomerId(): CustomerId {
return $this->customerId;
}
public function getTotalAmount(): Money {
return $this->totalAmount;
}
public function getStatus(): OrderStatus {
return $this->status;
}
public function getEmail(): EmailAddress {
return $this->email;
}
public function updateStatus(OrderStatus $newStatus): void {
// La validation est déjà incluse dans l'objet OrderStatus
$this->status = $newStatus;
}
public function isPending(): bool {
return $this->status->isPending();
}
public function isShipped(): bool {
return $this->status->isShipped();
}
}
Exemples en Go
Mauvaise utilisation des types primitifs
type Order struct {
OrderID string
CustomerID string
TotalAmount float64
Status string Email string
}
func NewOrder(orderID, customerID string, amount float64, status, email string) Order {
return Order{
OrderID: orderID,
CustomerID: customerID,
TotalAmount: amount,
Status: status,
Email: email,
}
}
func (o *Order) SetStatus(status string) {
o.Status = status
}
Amélioration avec des types définis et des validations
package main
import (
"errors"
"fmt"
"regexp"
)
type OrderID string
func NewOrderID(id string) (OrderID, error) {
if id == "" {
return "", errors.New("l'ID de commande ne peut pas être vide")
}
return OrderID(id), nil
}
func (id OrderID) String() string {
return string(id)
}
type CustomerID string
func NewCustomerID(id string) (CustomerID, error) {
if id == "" {
return "", errors.New("l'ID client ne peut pas être vide")
}
return CustomerID(id), nil
}
func (id CustomerID) String() string {
return string(id)
}
type Money struct {
Amount float64
Currency string
}
func NewMoney(amount float64, currency string) (Money, error) {
if amount < 0 {
return Money{}, errors.New("le montant ne peut pas être négatif")
}
if currency == "" {
currency = "EUR"
}
return Money{Amount: amount, Currency: currency}, nil
}
func (m Money) Add(other Money) (Money, error) {
if m.Currency != other.Currency {
return Money{}, errors.New("impossible d'additionner des montants de devises différentes")
}
result, err := NewMoney(m.Amount+other.Amount, m.Currency)
return result, err
}
type OrderStatus string
const (
OrderStatusPending OrderStatus = "pending"
OrderStatusShipped OrderStatus = "shipped"
OrderStatusDelivered OrderStatus = "delivered"
OrderStatusCanceled OrderStatus = "canceled"
)
func NewOrderStatus(status string) (OrderStatus, error) {
s := OrderStatus(status)
switch s {
case OrderStatusPending, OrderStatusShipped, OrderStatusDelivered, OrderStatusCanceled:
return s, nil
default:
return "", fmt.Errorf("statut de commande invalide: %s", status)
}
}
func (s OrderStatus) IsPending() bool {
return s == OrderStatusPending
}
func (s OrderStatus) IsShipped() bool {
return s == OrderStatusShipped
}
func (s OrderStatus) IsDelivered() bool {
return s == OrderStatusDelivered
}
func (s OrderStatus) IsCanceled() bool {
return s == OrderStatusCanceled
}
type EmailAddress string
var emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`)
func NewEmailAddress(email string) (EmailAddress, error) {
if !emailRegex.MatchString(email) {
return "", fmt.Errorf("adresse email invalide: %s", email)
}
return EmailAddress(email), nil
}
func (e EmailAddress) String() string {
return string(e)
}
type Order struct {
OrderID OrderID
CustomerID CustomerID
TotalAmount Money
Status OrderStatus
Email EmailAddress
}
func NewOrder(orderID OrderID, customerID CustomerID, amount Money, status OrderStatus, email EmailAddress) Order {
return Order{
OrderID: orderID,
CustomerID: customerID,
TotalAmount: amount,
Status: status,
Email: email,
}
}
func (o *Order) UpdateStatus(status OrderStatus) {
o.Status = status
}
func (o *Order) IsPending() bool {
return o.Status.IsPending()
}
func (o *Order) IsShipped() bool {
return o.Status.IsShipped()
}
func ExampleUsage() {
orderID, _ := NewOrderID("ORD-12345")
customerID, _ := NewCustomerID("CUST-6789")
amount, _ := NewMoney(99.99, "EUR")
status, _ := NewOrderStatus("pending")
email, _ := NewEmailAddress("client@exemple.fr")
order := NewOrder(orderID, customerID, amount, status, email)
if order.IsPending() {
fmt.Println("La commande est en attente")
}
newStatus, _ := NewOrderStatus("shipped")
order.UpdateStatus(newStatus)
if order.IsShipped() {
fmt.Println("La commande a été expédiée")
}
}
Avantages de l'évitement de la Primitive Obsession
Clarté sémantique : En utilisant des objets de valeur ou des types définis, le code devient plus clair et plus expressif, car il capture mieux les intentions métier et les règles du domaine.
Validation à la source : Les objets de valeur peuvent inclure des validations, ce qui réduit les erreurs liées à l'utilisation incorrecte des types primitifs. La validation est effectuée lors de la création de l'objet, garantissant que toutes les instances sont valides.
Encapsulation : Les comportements associés aux concepts métier peuvent être encapsulés dans des classes spécifiques, ce qui facilite la maintenance et l'évolution du code.
Prévention des erreurs : L'utilisation de types spécifiques empêche les mélanges accidentels (comme passer un ID client là où un ID de commande est attendu).
Création de méthodes métier : Possibilité d'ajouter des méthodes spécifiques qui ont du sens dans le contexte métier (comme isPending()
, isShipped()
pour un statut).
Inconvénients de l'évitement de la Primitive Obsession
Complexité accrue : L'introduction de nouveaux types peut ajouter une complexité supplémentaire, surtout pour des concepts simples ou des applications de petite taille.
Surcharge de code : Créer des classes pour chaque concept peut entraîner une prolifération de classes, ce qui peut être perçu comme une surcharge inutile dans certains contextes.
Courbe d'apprentissage : Pour les équipes non familiarisées avec le DDD ou les Value Objects, cette approche peut nécessiter un temps d'adaptation.
Sérialisation/Désérialisation : Travailler avec des formats comme JSON ou les bases de données peut nécessiter des adaptateurs supplémentaires pour convertir entre les types primitifs et les Value Objects.
Quand utiliser des Value Objects ?
Il est judicieux de remplacer les primitives par des Value Objects dans les cas suivants :
- Lorsque les valeurs ont des règles de validation spécifiques (comme les emails, les numéros de téléphone).
- Quand des comportements sont associés à la valeur (comme un calcul de TVA pour un prix).
- Pour distinguer différents types d'identifiants ou de références qui pourraient être confondus.
- Lorsqu'il existe un ensemble fini de valeurs possibles (comme des statuts, des types).
- Quand la valeur représente un concept important du domaine métier.
Conclusion
La Primitive Obsession est un anti-pattern courant qui peut nuire à la qualité et à la maintenabilité du code. En remplaçant les types primitifs par des objets de valeur ou des types définis, les développeurs peuvent créer un code plus clair, plus robuste et plus aligné avec les concepts métier.
Bien que cette approche puisse sembler verbeuse au premier abord, elle offre des avantages significatifs en termes de sécurité, de clarté et de maintenabilité. Comme pour toute technique, il est important de trouver le bon équilibre et d'appliquer cette approche là où elle apporte une réelle valeur ajoutée.
Ressources

Darkaine @ Mémos
Je découvre, j'apprends, je prends des notes et je les partage.