Value Objects — 04 — Value Objects dans un projet réel (exemple complet)

Exemple complet d'intégration des Value Objects dans un domaine e-commerce TypeScript et Python : UserId, Email, Money, OrderId, OrderStatus. Composition et expressivité.

04 — Value Objects dans un projet réel (exemple complet)

Ce que tu vas apprendre

  • Comment composer plusieurs Value Objects dans un domaine cohérent
  • Un exemple complet : domaine e-commerce (utilisateurs, commandes, prix)
  • La différence de lisibilité entre un code avec et sans Value Objects
  • Comment les Value Objects s'intègrent dans une architecture hexagonale

Prérequis


Le domaine : une boutique en ligne simplifiée

On modélise un sous-ensemble d'une boutique en ligne :

  • Un User (entité) avec un UserId, un Email, un Username
  • Un Order (entité) avec un OrderId, un OrderStatus, des OrderItem
  • Chaque OrderItem a un ProductId, une Quantity et un Money (prix unitaire)
5 Value Objects 2 Entités 0 état invalide possible
UserId, Email, Money, Quantity, OrderStatus User, Order Validation à la création

Les Value Objects du domaine

UserId et OrderId

typescriptimport { randomUUID } from "crypto";

class UserId {
  private constructor(private readonly value: string) {}

  static create(value: string): UserId {
    if (!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)) {
      throw new Error(`UUID invalide : ${value}`);
    }
    return new UserId(value);
  }

  static generate(): UserId {
    return new UserId(randomUUID());
  }

  equals(other: UserId): boolean {
    return this.value === other.value;
  }

  toString(): string {
    return this.value;
  }
}

class OrderId {
  private constructor(private readonly value: string) {}

  static create(value: string): OrderId {
    if (!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)) {
      throw new Error(`UUID invalide : ${value}`);
    }
    return new OrderId(value);
  }

  static generate(): OrderId {
    return new OrderId(randomUUID());
  }

  equals(other: OrderId): boolean {
    return this.value === other.value;
  }

  toString(): string {
    return this.value;
  }
}

UserId et OrderId sont deux types distincts. Le compilateur refuse d'utiliser l'un à la place de l'autre — une catégorie entière de bugs disparaît.

Money

typescriptclass Money {
  private constructor(
    readonly amount: number,
    readonly currency: string
  ) {}

  static create(amount: number, currency: string): Money {
    if (!Number.isInteger(amount) || amount < 0) {
      throw new Error("Le montant doit être un entier positif (en centimes)");
    }
    if (!["EUR", "USD", "GBP"].includes(currency)) {
      throw new Error(`Devise inconnue : ${currency}`);
    }
    return new Money(amount, currency);
  }

  add(other: Money): Money {
    if (this.currency !== other.currency) {
      throw new Error("Impossible d'additionner deux devises différentes");
    }
    return Money.create(this.amount + other.amount, this.currency);
  }

  multiply(factor: number): Money {
    if (!Number.isInteger(factor) || factor < 0) {
      throw new Error("Le facteur doit être un entier positif");
    }
    return Money.create(this.amount * factor, this.currency);
  }

  equals(other: Money): boolean {
    return this.amount === other.amount && this.currency === other.currency;
  }

  toJSON(): { amount: number; currency: string } {
    return { amount: this.amount, currency: this.currency };
  }

  static fromJSON(data: { amount: number; currency: string }): Money {
    return Money.create(data.amount, data.currency);
  }

  // Formatage lisible — 1999 EUR → "19,99 €"
  format(locale = "fr-FR"): string {
    return new Intl.NumberFormat(locale, {
      style: "currency",
      currency: this.currency,
    }).format(this.amount / 100);
  }
}

Quantity

typescriptclass Quantity {
  private constructor(readonly value: number) {}

  static create(value: number): Quantity {
    if (!Number.isInteger(value) || value <= 0) {
      throw new Error("La quantité doit être un entier strictement positif");
    }
    return new Quantity(value);
  }

  equals(other: Quantity): boolean {
    return this.value === other.value;
  }
}

OrderStatus

typescripttype OrderStatusValue = "pending" | "confirmed" | "shipped" | "delivered" | "cancelled";

class OrderStatus {
  private constructor(private readonly value: OrderStatusValue) {}

  private static readonly VALID_TRANSITIONS: Record<OrderStatusValue, OrderStatusValue[]> = {
    pending: ["confirmed", "cancelled"],
    confirmed: ["shipped", "cancelled"],
    shipped: ["delivered"],
    delivered: [],
    cancelled: [],
  };

  static create(value: string): OrderStatus {
    const validStatuses: OrderStatusValue[] = ["pending", "confirmed", "shipped", "delivered", "cancelled"];
    if (!validStatuses.includes(value as OrderStatusValue)) {
      throw new Error(`Statut invalide : ${value}`);
    }
    return new OrderStatus(value as OrderStatusValue);
  }

  static pending(): OrderStatus {
    return new OrderStatus("pending");
  }

  canTransitionTo(next: OrderStatus): boolean {
    return OrderStatus.VALID_TRANSITIONS[this.value].includes(next.value);
  }

  transitionTo(next: OrderStatus): OrderStatus {
    if (!this.canTransitionTo(next)) {
      throw new Error(
        `Transition invalide : ${this.value}${next.value}`
      );
    }
    return next;
  }

  equals(other: OrderStatus): boolean {
    return this.value === other.value;
  }

  toString(): string {
    return this.value;
  }
}

OrderStatus encapsule les règles de transition entre statuts. Pas besoin de if/switch partout dans le code — le VO connaît ses propres règles.


Les Entités : User et Order

typescriptclass User {
  private constructor(
    readonly id: UserId,
    private email: Email,
    private username: string,
    readonly createdAt: Date
  ) {}

  static create(email: Email, username: string): User {
    if (!username.trim()) {
      throw new Error("Le nom d'utilisateur ne peut pas être vide");
    }
    return new User(UserId.generate(), email, username.trim(), new Date());
  }

  static reconstitute(
    id: UserId,
    email: Email,
    username: string,
    createdAt: Date
  ): User {
    return new User(id, email, username, createdAt);
  }

  changeEmail(newEmail: Email): void {
    this.email = newEmail;
  }

  getEmail(): Email {
    return this.email;
  }

  equals(other: User): boolean {
    return this.id.equals(other.id);
  }
}

interface OrderItem {
  productId: string;
  quantity: Quantity;
  unitPrice: Money;
}

class Order {
  private constructor(
    readonly id: OrderId,
    readonly userId: UserId,
    private status: OrderStatus,
    private items: OrderItem[],
    readonly createdAt: Date
  ) {}

  static create(userId: UserId, items: OrderItem[]): Order {
    if (items.length === 0) {
      throw new Error("Une commande doit contenir au moins un article");
    }
    return new Order(
      OrderId.generate(),
      userId,
      OrderStatus.pending(),
      items,
      new Date()
    );
  }

  confirm(): void {
    this.status = this.status.transitionTo(
      OrderStatus.create("confirmed")
    );
  }

  ship(): void {
    this.status = this.status.transitionTo(
      OrderStatus.create("shipped")
    );
  }

  cancel(): void {
    this.status = this.status.transitionTo(
      OrderStatus.create("cancelled")
    );
  }

  getTotal(): Money {
    return this.items.reduce(
      (total, item) => total.add(item.unitPrice.multiply(item.quantity.value)),
      Money.create(0, "EUR")
    );
  }

  getStatus(): OrderStatus {
    return this.status;
  }

  equals(other: Order): boolean {
    return this.id.equals(other.id);
  }
}

Deux méthodes de construction

User.create() crée un nouvel utilisateur avec un ID généré.

User.reconstitute() recrée un utilisateur depuis la base de données — l'ID est fourni, pas généré.

Cette distinction est importante : à la lecture depuis la BDD, on ne veut pas générer un nouvel ID.

`getTotal()` sans bug possible

getTotal() additionne des Money avec Quantity. Les règles (devise identique, montant positif) sont garanties par les VOs — pas besoin de vérifications supplémentaires dans Order.

Transitions de statut

confirm(), ship(), cancel() délèguent la validation de la transition à OrderStatus. Si la transition est invalide, le VO lève une erreur. Order n'a pas à connaître les règles de transition.


Utilisation du domaine

Voici comment ce domaine s'utilise dans un use case :

typescript// use-case : passer une commande
async function placeOrder(
  userEmail: string,
  items: Array<{ productId: string; quantity: number; unitPriceCents: number }>
): Promise<string> {
  // Validation au niveau domaine — pas dans le use case
  const email = Email.create(userEmail);

  const orderItems: OrderItem[] = items.map(item => ({
    productId: item.productId,
    quantity: Quantity.create(item.quantity),
    unitPrice: Money.create(item.unitPriceCents, "EUR"),
  }));

  // À ce stade, tous les VOs sont validés
  // On ne peut pas avoir une quantité négative ou un email invalide
  const user = await userRepository.findByEmail(email);
  const order = Order.create(user.id, orderItems);

  await orderRepository.save(order);

  console.log(`Commande ${order.id} créée — total : ${order.getTotal().format()}`);
  return order.id.toString();
}

La lisibilité du code de domaine est radicalement différente de la version avec primitives :

typescript// Sans Value Objects — vérifications éparpillées, types génériques
async function placeOrder(email: string, items: any[]) {
  if (!email.match(/@/)) throw new Error("Email invalide");
  if (items.length === 0) throw new Error("Panier vide");
  for (const item of items) {
    if (item.quantity <= 0) throw new Error("Quantité invalide");
    if (item.unitPrice < 0) throw new Error("Prix invalide");
    if (!["EUR", "USD"].includes(item.currency)) throw new Error("Devise invalide");
  }
  // ... suite
}

// Avec Value Objects — validation déléguée, code métier lisible
async function placeOrder(userEmail: string, items: RawItem[]) {
  const email = Email.create(userEmail);        // valide ou throw
  const orderItems = items.map(toOrderItem);    // valide ou throw
  const order = Order.create(userId, orderItems); // valide ou throw
  // ... suite — pas une seule vérification manuelle ici
}

Version Python complète

pythonimport uuid
import re
from dataclasses import dataclass, field
from datetime import datetime
from typing import List

# --- Value Objects ---

@dataclass(frozen=True)
class UserId:
    value: str

    def __post_init__(self) -> None:
        try:
            uuid.UUID(self.value)
        except ValueError:
            raise ValueError(f"UUID invalide : {self.value}")

    @classmethod
    def generate(cls) -> 'UserId':
        return cls(str(uuid.uuid4()))

@dataclass(frozen=True)
class OrderId:
    value: str

    def __post_init__(self) -> None:
        try:
            uuid.UUID(self.value)
        except ValueError:
            raise ValueError(f"UUID invalide : {self.value}")

    @classmethod
    def generate(cls) -> 'OrderId':
        return cls(str(uuid.uuid4()))

@dataclass(frozen=True)
class Email:
    value: str

    def __post_init__(self) -> None:
        normalized = self.value.strip().lower()
        if not re.match(r'^[^\s@]+@[^\s@]+\.[^\s@]+{{body}}#x27;, normalized):
            raise ValueError(f"Email invalide : {self.value}")
        object.__setattr__(self, 'value', normalized)

@dataclass(frozen=True)
class Money:
    amount: int      # en centimes
    currency: str

    VALID_CURRENCIES = frozenset({"EUR", "USD", "GBP"})

    def __post_init__(self) -> None:
        if self.amount < 0:
            raise ValueError("Montant négatif interdit")
        if self.currency not in self.VALID_CURRENCIES:
            raise ValueError(f"Devise inconnue : {self.currency}")

    def add(self, other: 'Money') -> 'Money':
        if self.currency != other.currency:
            raise ValueError("Devises incompatibles")
        return Money(amount=self.amount + other.amount, currency=self.currency)

    def multiply(self, factor: int) -> 'Money':
        if factor < 0:
            raise ValueError("Facteur négatif interdit")
        return Money(amount=self.amount * factor, currency=self.currency)

@dataclass(frozen=True)
class Quantity:
    value: int

    def __post_init__(self) -> None:
        if self.value <= 0:
            raise ValueError("La quantité doit être strictement positive")

# --- Entités ---

@dataclass
class OrderItem:
    product_id: str
    quantity: Quantity
    unit_price: Money

class Order:
    def __init__(
        self,
        order_id: OrderId,
        user_id: UserId,
        items: List[OrderItem],
        status: str = "pending",
        created_at: datetime = None
    ) -> None:
        if not items:
            raise ValueError("Une commande doit contenir au moins un article")
        self._id = order_id
        self._user_id = user_id
        self._items = list(items)
        self._status = status
        self._created_at = created_at or datetime.utcnow()

    VALID_TRANSITIONS = {
        "pending": {"confirmed", "cancelled"},
        "confirmed": {"shipped", "cancelled"},
        "shipped": {"delivered"},
        "delivered": set(),
        "cancelled": set(),
    }

    @classmethod
    def create(cls, user_id: UserId, items: List[OrderItem]) -> 'Order':
        return cls(OrderId.generate(), user_id, items)

    def _transition_to(self, next_status: str) -> None:
        if next_status not in self.VALID_TRANSITIONS[self._status]:
            raise ValueError(f"Transition invalide : {self._status}{next_status}")
        self._status = next_status

    def confirm(self) -> None:
        self._transition_to("confirmed")

    def ship(self) -> None:
        self._transition_to("shipped")

    def cancel(self) -> None:
        self._transition_to("cancelled")

    def get_total(self) -> Money:
        total = Money(amount=0, currency="EUR")
        for item in self._items:
            total = total.add(item.unit_price.multiply(item.quantity.value))
        return total

    @property
    def id(self) -> OrderId:
        return self._id

    @property
    def status(self) -> str:
        return self._status

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, Order):
            return NotImplemented
        return self._id == other._id

    def __hash__(self) -> int:
        return hash(self._id)


# --- Usage ---

def place_order(user_email: str, raw_items: list) -> str:
    email = Email(user_email)   # valide ou ValueError

    items = [
        OrderItem(
            product_id=item["product_id"],
            quantity=Quantity(item["quantity"]),
            unit_price=Money(amount=item["unit_price_cents"], currency="EUR"),
        )
        for item in raw_items
    ]

    user_id = UserId.generate()  # En vrai : récupéré depuis la BDD via email
    order = Order.create(user_id, items)
    order.confirm()

    total = order.get_total()
    print(f"Commande {order.id.value} — total : {total.amount / 100:.2f} {total.currency}")
    return order.id.value

Intégration avec une architecture hexagonale

Les Value Objects vivent dans le domaine — la couche centrale de l'architecture hexagonale. Ils ne dépendent d'aucun framework, d'aucune BDD, d'aucune librairie externe.

src/
├── domain/
│   ├── value-objects/
│   │   ├── email.ts
│   │   ├── money.ts
│   │   ├── quantity.ts
│   │   ├── user-id.ts
│   │   └── order-id.ts
│   ├── entities/
│   │   ├── user.ts
│   │   └── order.ts
│   └── repositories/
│       ├── user-repository.ts   (interface)
│       └── order-repository.ts  (interface)
├── application/
│   └── use-cases/
│       └── place-order.ts
└── infrastructure/
    └── persistence/
        ├── postgres-user-repository.ts
        └── postgres-order-repository.ts

Les adaptateurs (infrastructure/) reçoivent des données brutes depuis la BDD et appellent les fromJSON() / constructeurs des VOs pour reconstituer les entités. La validation s'exécute à nouveau — si une donnée corrompue est en BDD, elle est détectée à la lecture.


Ce que le pattern change concrètement

Avant d'introduire les Value Objects dans ce domaine, le code de use case ressemblait à ça :

typescript// Avant : vérifications éparpillées, primitives partout
if (!email.includes("@")) throw new Error("...");
if (quantity <= 0) throw new Error("...");
if (unitPrice < 0) throw new Error("...");
if (!["EUR", "USD"].includes(currency)) throw new Error("...");

Après :

typescript// Après : une ligne par concept, validation déléguée
const email = Email.create(rawEmail);
const quantity = Quantity.create(rawQuantity);
const price = Money.create(rawPrice, rawCurrency);

Le use case exprime l'intention métier, pas les règles de validation. Les règles sont dans les VOs — testées une fois, réutilisées partout.


Résumé de la série

Article Contenu
00 — Introduction Définition, 3 propriétés, premier exemple Email
01 — Implémentation Factory method, validation, Result type, sérialisation
02 — vs Entités Identité vs valeur, composition, tableau de décision
03 — Tests 5 axes de test, Vitest, pytest, it.each, @parametrize
04 — Projet réel Domaine e-commerce complet, VOs composés, architecture hexagonale

Le pattern Value Object n'est pas complexe à implémenter. La discipline vient de l'habitude de se poser la question : "ce concept a-t-il des règles que la primitive ne peut pas exprimer ?" Si oui, c'est un Value Object.

Réservez un audit gratuit de 30 minutes. Je vous montre concrètement ce qu'on peut automatiser.