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 unUserId, unEmail, unUsername - Un
Order(entité) avec unOrderId, unOrderStatus, desOrderItem - Chaque
OrderItema unProductId, uneQuantityet unMoney(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.