- 38 min de lecture
Value Objects en DDD : remplacer les types primitifs
Un montant en float laisse passer des valeurs que le métier interdit. Les Value Objects du DDD les bloquent à faible coût : Python, Rust, ORM et API.
Prenons une situation type. Une facture calcule son total en float, arrondit au moment de l'affichage et sort un montant qui diffère d'un centime de celui que recalcule la comptabilité. Dans la même application, deux comptes coexistent pour alice@Exemple.fr et alice@exemple.fr : la même personne, deux historiques de commandes, deux consentements marketing. Aucun de ces bugs ne vient d'un manque d'attention. Ils viennent du type choisi : un float ne sait pas ce qu'est un centime, une str ne sait pas ce qu'est une adresse e-mail. Remplacer ces types primitifs par un Value Object, un petit type immuable qui se valide à sa création, supprime cette classe de bugs pour un coût faible.
Le Value Object est souvent rangé dans la boîte « Domain-Driven Design », donc perçu comme réservé aux gros projets qui ont adopté toute la méthode. Ce n'est pas le cas : c'est le pattern du DDD tactique le plus simple à adopter seul, et probablement le plus rentable. Cet article est le premier volet d'une série sur le DDD tactique ; il montre comment repérer les primitifs qui portent une règle métier, les remplacer en Python et en Rust, puis les stocker en base et les exposer dans une API sans revenir aux primitifs.
Primitive obsession : ce que coûte un type primitif
L'obsession des types primitifs (primitive obsession) consiste à représenter des concepts du domaine, un montant, une devise, une adresse e-mail, un identifiant de commande, par les types de base du langage : float, int, str. Le nom vient du catalogue de « code smells » du livre Refactoring de Martin Fowler (1999, deuxième édition en 2018), l'expression « code smell » elle-même ayant été forgée par Kent Beck pendant l'écriture du livre. Le remède porte un nom tout aussi explicite dans le catalogue : Replace Primitive with Object.
Le problème n'est pas esthétique. Un type primitif accepte toutes les valeurs que le langage autorise, alors que le métier n'en autorise qu'une petite partie. Trois conséquences en découlent.
Le flottant ne représente pas les centimes
Un float Python est un nombre binaire IEEE 754 sur 53 bits de précision. La plupart des fractions décimales n'y ont pas de représentation exacte : la documentation de Python rappelle que 0.1 + 0.1 + 0.1 == 0.3 vaut False, et qu'arrondir avant de comparer ne corrige rien. L'effet sur un montant est direct : round(2.675, 2) donne 2.67 et non 2.68, parce que la valeur réellement stockée est légèrement inférieure à 2,675. La documentation précise que ce n'est pas un bug. C'est le comportement normal du type, et il produit le centime d'écart de la facture.
La même documentation renvoie au module decimal, qu'elle présente comme adapté aux applications comptables. Encore faut-il que chaque développeur pense à l'utiliser, à chaque calcul.
La validation recopiée à chaque point d'entrée
Une adresse e-mail en str doit être nettoyée et contrôlée partout où elle entre : formulaire d'inscription, import CSV, API partenaire, back-office, script de migration. Chaque point d'entrée écrit sa propre version de la règle. Un jour, l'une d'elles met le domaine en minuscules et l'autre non ; le doublon alice@Exemple.fr et alice@exemple.fr naît de cet écart, pas d'une erreur ponctuelle.
Des paramètres interchangeables
Une fonction refund(amount, currency) appelée avec refund(currency, amount) échoue peut-être à l'exécution. Une fonction assign(customer_id, order_id) dont les deux arguments sont des entiers ne signale rien quand on les inverse : elle rattache la commande 42 au client 1337 au lieu de l'inverse, et la base l'accepte.
L'exemple suivant réunit les trois défauts dans une dizaine de lignes de code qui passent la relecture sans difficulté.
from dataclasses import dataclass
@dataclass
class PlaceOrder:
customer_id: int
email: str # " alice@Exemple.fr " et "alice@exemple.fr" passent tous deux
subtotal: float
def order_total(command: PlaceOrder, discount: float) -> float:
return round(command.subtotal * (1 - discount), 2)
def assign(customer_id: int, order_id: int) -> None: ...
command = PlaceOrder(42, " alice@Exemple.fr ", 5.35)
print(order_total(command, discount=0.5)) # 2.67, la comptabilité attend 2,68
assign(1337, command.customer_id) # arguments inversés, aucune erreurRien dans ce code n'est faux du point de vue du langage. Tout est faux du point de vue du métier. Le coût se paie plus tard : des bugs silencieux qui ne lèvent aucune exception, des scripts de correction de données sur des mois d'historique, et des règles métier dispersées que personne ne retrouve le jour où il faut les modifier.
Qu'est-ce qu'un Value Object ?
Un Value Object (objet valeur) est un objet défini uniquement par ses attributs : deux Value Objects aux attributs identiques sont interchangeables. Il est immuable, comparé par valeur et valide dès sa création, et il porte les opérations propres au concept qu'il représente, comme additionner deux montants de même devise.
La notion vient d'Eric Evans, dans Domain-Driven Design (2003), dans la partie consacrée aux briques de base d'un modèle (chapitre 5). Dans sa DDD Reference, qui résume le livre, Evans décrit un Value Object comme un objet qui décrit ou calcule une caractéristique d'une chose, sans identité conceptuelle. Sa règle tient en une phrase : « When you care only about the attributes and logic of an element of the model, classify it as a value object. » Il recommande de lui donner les opérations liées au concept, de le traiter comme immuable et de n'écrire que des opérations sans effet de bord.
Martin Fowler insiste, dans sa note « Value Object » (2016), sur deux propriétés : l'égalité se fonde sur la valeur des attributs et non sur la référence, et l'immuabilité évite les bugs d'aliasing, quand deux parties du code partagent le même objet et que l'une le modifie sous les pieds de l'autre. Il y conseille explicitement de remplacer des chaînes de caractères par des Value Objects. Vaughn Vernon, dans Implementing Domain-Driven Design (2013), détaille ces propriétés une à une : l'objet mesure ou décrit quelque chose du domaine, forme un tout conceptuel, se remplace plutôt qu'il ne se modifie.
L'idée est plus ancienne que le DDD. Dès 1994, Ward Cunningham décrivait dans son langage de patterns CHECKS un pattern « Whole Value » : construire des valeurs spécialisées pour quantifier le modèle du domaine (une devise, une période, un numéro de téléphone), qui transportent une information complète et utilisable de l'interface jusqu'au modèle.
Concrètement, un Value Object réunit cinq propriétés :
- Défini par ses attributs : un montant de 19,99 EUR vaut n'importe quel autre montant de 19,99 EUR.
- Immuable : on ne modifie pas un montant, on en calcule un nouveau.
- Comparé par valeur : l'égalité porte sur les attributs, pas sur l'adresse mémoire.
- Valide dès sa création : un montant négatif ou une adresse sans
@ne peut pas exister, parce que le constructeur refuse de le créer. - Porteur des opérations du concept : l'addition de deux montants vit dans le type
Money, et elle renvoie unMoney. Evans appelle ce principe « Closure of Operations » : une opération dont le résultat est du même type que ses arguments.
Faut-il adopter le DDD pour en profiter ? Non. Aucune de ces propriétés ne suppose des agrégats, des repositories ou un atelier d'event storming. Un Value Object s'introduit dans un projet Django ou une API Rust sans rien changer à son architecture, et il rend service dès le premier type converti.
Value Object ou Entity : la question de l'identité
Le Value Object se définit par contraste avec l'Entity (entité). Evans décrit une Entity comme un objet défini par une identité qui traverse le temps et les représentations, à distinguer d'un autre objet même lorsque leurs attributs sont identiques. Il ajoute : « Mistaken identity can lead to data corruption. »
La question qui tranche est donc simple : deux instances aux attributs identiques sont-elles la même chose ? Deux commandes de trois livres pour le même client, le même jour, au même prix, restent deux commandes : chacune a son cycle de vie, son paiement, son expédition. La commande (Order, celle de l'article sur les transactions distribuées entre microservices) est une Entity. Son montant total et l'adresse e-mail du client sont des Value Objects : deux montants de 59,97 EUR sont indiscernables, et personne ne suit « ce montant-là » dans le temps.
Les critères se résument ainsi :
- Identité : une Entity en a une (un identifiant stable) ; un Value Object n'en a pas, ses attributs suffisent.
- Cycle de vie : une Entity naît, évolue et disparaît (une commande passe de « en attente » à « expédiée ») ; un Value Object est créé, utilisé, puis remplacé.
- Mutabilité : une Entity change d'état en gardant son identité ; un Value Object ne change jamais, on en construit un autre.
- Égalité : deux Entities sont égales si leurs identifiants le sont ; deux Value Objects sont égaux si tous leurs attributs le sont.
Le classement dépend du contexte, pas de la chose elle-même. Dans un service de facturation, une adresse de livraison est un Value Object : on l'imprime sur la facture, et si le client déménage, on en enregistre une nouvelle. Dans un service de livraison qui géolocalise chaque adresse, lui attache des consignes d'accès et un historique de passages, la même adresse devient une Entity qu'on suit dans le temps. C'est l'une des raisons de délimiter des bounded contexts : chaque contexte modélise le même mot selon ce qu'il en fait.
Se tromper de catégorie a un coût visible. Une Entity traitée comme une valeur, et deux clients homonymes fusionnent. Une valeur traitée comme une Entity, et la base se remplit d'identifiants, de tables de jointure et de mises à jour en cascade pour des données qu'il suffisait de remplacer. Le prochain volet de la série porte sur les Entities.
Écrire un Value Object en Python avec une dataclass gelée
En Python, la bibliothèque standard fournit presque tout. Une dataclass déclarée avec frozen=True refuse toute affectation après construction (elle lève FrozenInstanceError) et génère __eq__, qui compare les champs un à un, ainsi que __hash__. Une dataclass gelée peut donc servir de clé de dictionnaire ou d'élément d'un ensemble, ce qu'une dataclass mutable ne permet pas. La méthode __post_init__, appelée à la fin du constructeur généré, accueille la validation. L'option slots=True réduit l'empreinte mémoire de chaque instance.
Un montant : Decimal et devise
Un montant se compose de deux attributs indissociables : une quantité exacte en Decimal et une devise. La devise n'est pas un détail : toutes n'ont pas deux décimales. La liste ISO 4217 tenue par SIX compte 17 codes sans décimale (dont le yen japonais, JPY) et 7 à trois décimales (dont le dinar koweïtien, KWD, et le dinar bahreïni, BHD). La devise porte donc le nombre de décimales, et le montant s'arrondit selon sa devise.
from __future__ import annotations
from dataclasses import dataclass
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
from enum import StrEnum
class Currency(StrEnum):
EUR = "EUR"
USD = "USD"
JPY = "JPY"
@property
def decimals(self) -> int:
return _ISO_4217_DECIMALS[self]
_ISO_4217_DECIMALS = {Currency.EUR: 2, Currency.USD: 2, Currency.JPY: 0}
class CurrencyMismatchError(ValueError):
passLe montant lui-même tient en une classe gelée : validation et arrondi dans __post_init__, construction depuis une chaîne avec of, addition contrôlée par la devise.
@dataclass(frozen=True, slots=True)
class Money:
amount: Decimal
currency: Currency
def __post_init__(self) -> None:
# Refuser le float plutôt que le convertir : Decimal(0.1) vaut
# 0.1000000000000000055511151231257827021181583404541015625.
if not isinstance(self.amount, Decimal):
raise TypeError(f"montant non décimal : {self.amount!r}")
if not self.amount.is_finite():
raise ValueError(f"montant non fini : {self.amount}")
# Arrondi unique et explicite : mi-chemin (2,675) loin de zéro, comme
# NUMERIC dans PostgreSQL. La base n'aura jamais à arrondir elle-même.
quantum = Decimal(1).scaleb(-self.currency.decimals)
amount = self.amount.quantize(quantum, rounding=ROUND_HALF_UP)
# Règle de ce domaine : un total de commande n'est jamais négatif.
# Un avoir ou un remboursement pourrait l'autoriser.
if amount < 0:
raise ValueError(f"montant négatif : {amount}")
# Une classe gelée ne s'affecte qu'ainsi, et seulement ici.
object.__setattr__(self, "amount", amount)
@classmethod
def of(cls, amount: str, currency: Currency) -> Money:
try:
return cls(Decimal(amount), currency)
except InvalidOperation:
raise ValueError(f"montant invalide : {amount!r}") from None
def __add__(self, other: Money) -> Money:
if other.currency is not self.currency:
raise CurrencyMismatchError(f"{self.currency} + {other.currency}")
return Money(self.amount + other.amount, self.currency)
price = Money.of("19.99", Currency.EUR)
assert price == Money.of("19.990", Currency.EUR)
assert price + price == Money.of("39.98", Currency.EUR)
assert Money.of("2.675", Currency.EUR).amount == Decimal("2.68")Trois choix méritent d'être relevés. Le constructeur refuse un float au lieu de le convertir : Decimal(0.1) reproduirait fidèlement l'erreur binaire qu'on cherche à éliminer, et le refus oblige l'appelant à passer une chaîne ou un Decimal. L'arrondi se fait une seule fois, dans le type, avec un mode explicite : le module decimal arrondit par défaut au pair, alors que le type numeric de PostgreSQL arrondit les valeurs à mi-chemin (2,675) en s'éloignant de zéro. Laisser l'arrondi à l'un ou à l'autre selon le chemin de code, c'est accepter que le même montant soit arrondi de deux façons. Enfin, additionner des euros et des dollars lève une exception dédiée : l'erreur apparaît au moment du calcul, avec un nom qui dit ce qui s'est passé, au lieu d'un total silencieusement faux.
Répartir un montant sans perdre de centime
Répartir 100,00 EUR en trois parts égales ne donne pas trois fois 33,33 EUR : il manque un centime. C'est le cas des paiements en plusieurs fois, des remises réparties sur les lignes d'une commande ou des frais partagés entre filiales. La méthode allocate calcule chaque part arrondie vers le bas, puis distribue les centimes restants un par un aux premières parts : 33,34, 33,33 et 33,33 EUR.
from collections.abc import Sequence # en plus des imports précédents
@dataclass(frozen=True, slots=True)
class Money:
# … champs et méthodes de l'exemple précédent
def allocate(self, ratios: Sequence[int]) -> list[Money]:
if not ratios or any(ratio <= 0 for ratio in ratios):
raise ValueError("ratios vides ou non strictement positifs")
# Calcul en unités mineures entières (centimes, yens) : rien ne se perd.
decimals = self.currency.decimals
total_minor = int(self.amount.scaleb(decimals))
shares = [total_minor * ratio // sum(ratios) for ratio in ratios]
# Le reste, inférieur au nombre de parts, va une unité à la fois
# aux premières parts.
for index in range(total_minor - sum(shares)):
shares[index] += 1
return [
Money(Decimal(share).scaleb(-decimals), self.currency)
for share in shares
]
parts = Money.of("100.00", Currency.EUR).allocate([1, 1, 1])
assert [part.amount for part in parts] == [
Decimal("33.34"),
Decimal("33.33"),
Decimal("33.33"),
]
assert sum(parts, Money.of("0", Currency.EUR)) == Money.of("100", Currency.EUR)La règle « la somme des parts égale le total » est désormais écrite une fois, testée une fois, et chaque échéancier de paiement en hérite. Sans elle, l'écart d'un centime se retrouve en rapprochement bancaire, ligne par ligne.
Une adresse e-mail : normaliser une fois, contrôler peu
Pour une adresse e-mail, la tentation est d'écrire une expression régulière qui couvre toute la norme. C'est une mauvaise idée. La RFC 5322 autorise des parties locales entre guillemets, qui peuvent contenir des espaces ou un second @, et des commentaires ; la RFC 6531 ajoute les caractères non ASCII. Le standard HTML lui-même définit pour <input type=email> une règle volontairement plus simple que la RFC, qu'il juge trop stricte, trop vague et trop permissive à la fois. La seule façon de savoir qu'une adresse fonctionne reste d'y envoyer un message de confirmation.
Le Value Object se contente donc d'un contrôle minimal et d'une normalisation explicite :
- supprimer les espaces en début et en fin ;
- vérifier qu'il y a une partie locale et un domaine non vides, séparés par un
@; - mettre le domaine en minuscules, ce que la RFC 5321 autorise puisque les domaines suivent les règles du DNS, insensibles à la casse ;
- conserver la partie locale telle quelle, parce que la même RFC la déclare sensible à la casse (« smith » et « Smith » peuvent être deux boîtes différentes sur certains serveurs), tout en déconseillant d'exploiter cette possibilité.
from __future__ import annotations
from dataclasses import dataclass
class InvalidEmailError(ValueError):
pass
@dataclass(frozen=True, slots=True)
class EmailAddress:
local_part: str
domain: str
def __post_init__(self) -> None:
# L'invariant tient même si l'on appelle le constructeur sans parse().
parts = (self.local_part, self.domain)
if any(not p or "@" in p or p != p.strip() for p in parts):
raise InvalidEmailError(f"adresse invalide : {self}")
if self.domain != self.domain.lower():
raise InvalidEmailError(f"domaine non normalisé : {self.domain}")
@classmethod
def parse(cls, raw: str) -> EmailAddress:
# Un seul @ : on rejette les parties locales entre guillemets que la
# RFC 5322 autorise ("a@b"@exemple.fr), rares en pratique.
parts = raw.strip().split("@")
if len(parts) != 2:
raise InvalidEmailError(f"adresse invalide : {raw!r}")
local_part, domain = parts
# RFC 5321 : domaine insensible à la casse, partie locale sensible
# (la mettre en minuscules serait un choix de produit). L'existence
# de la boîte relève de l'e-mail de confirmation.
return cls(local_part, domain.lower())
def __str__(self) -> str:
return f"{self.local_part}@{self.domain}"
duplicate = EmailAddress.parse("alice@Exemple.fr")
assert duplicate == EmailAddress.parse("alice@exemple.fr") # doublon évité
alice = EmailAddress.parse("Alice@Exemple.fr")
assert alice == EmailAddress.parse(" Alice@exemple.fr ")
assert alice != EmailAddress.parse("alice@exemple.fr") # casse de « Alice »
# Limite de frozen=True : rien n'empêche un contournement délibéré.
object.__setattr__(alice, "local_part", "")
print(alice) # « @exemple.fr » : l'invariant ne tient plusLe doublon de l'accroche, alice@Exemple.fr et alice@exemple.fr, disparaît : les deux adresses produisent le même Value Object. La partie locale pose une autre question : mettre le domaine en minuscules ne fusionne pas Alice@exemple.fr et alice@exemple.fr, comme le montre la dernière assertion de l'exemple. Django fait exactement ce choix par défaut : normalize_email ne met en minuscules que le domaine. Mettre aussi la partie locale en minuscules est une décision de produit, pas une règle technique. La RFC déconseille d'exploiter la sensibilité à la casse sans l'interdire : l'arbitrage oppose des doublons de comptes évités à un risque théorique de confondre deux boîtes distinctes. L'avantage du Value Object est que cette décision s'écrit à un seul endroit, en une ligne, et s'applique à tous les points d'entrée.
Les règles propres à un fournisseur n'ont rien à faire dans ce type. Gmail ignore les points de la partie locale pour les comptes personnels, mais pas pour les comptes professionnels : supprimer les points ou les suffixes +tag pour tous les domaines fusionnerait des adresses distinctes.
Normaliser à un seul endroit est aussi une question de sécurité. Les failles connues touchent la normalisation Unicode plutôt que la casse, mais la leçon vaut pour toute règle de normalisation. Chez GitHub, en 2016, la récupération de mot de passe normalisait l'adresse saisie pour retrouver le compte, puis envoyait le lien à l'adresse saisie, qui pouvait être une autre boîte que celle du compte. Chez Spotify, en 2013, une fonction de canonicalisation des noms d'utilisateur qui ne donnait pas le même résultat appliquée une ou deux fois permettait de réinitialiser le mot de passe d'un autre compte. Dans les deux cas, deux chemins de code appliquaient des formes différentes à la même donnée. Normaliser une fois, à la construction, et stocker la forme normalisée ferme cette porte.
Les limites de la version Python
Python ne permet pas une immuabilité absolue, et la documentation des dataclasses le dit elle-même : frozen=True émule l'immuabilité. Le constructeur généré passe lui-même par object.__setattr__, et n'importe quel code peut faire de même, comme le montrent les deux dernières lignes de l'exemple. Le gel protège contre les modifications accidentelles, pas contre un contournement délibéré ; une revue de code qui repère object.__setattr__ hors d'un constructeur suffit à tenir la règle.
typing.NewType est parfois présenté comme une alternative légère. Il n'en est pas une : EmailAddress = NewType("EmailAddress", str) aide le vérificateur de types à distinguer deux chaînes, mais à l'exécution l'appel renvoie son argument inchangé. Aucune validation, aucune normalisation. NewType convient pour distinguer deux identifiants dans un code vérifié par mypy ou pyright, pas pour garantir une règle métier.
Enfin, un Value Object dont un attribut est une collection doit rester immuable jusqu'au bout : un tuple plutôt qu'une list, et pour un dictionnaire, frozendict, arrivé avec Python 3.15 (PEP 814), qui est hachable si ses valeurs le sont. Avant Python 3.15, un tuple de paires triées fait l'affaire.
Pour le métier, le gain se lit dans les tickets : le centime d'écart disparaît parce que l'arrondi n'existe qu'à un endroit, et la règle des adresses e-mail cesse d'être recopiée dans cinq formulaires.
Écrire un Value Object en Rust : le pattern newtype
En Rust, le Value Object s'écrit avec le pattern newtype : une struct qui enveloppe un type existant, avec un champ privé et un constructeur faillible, une fonction new qui renvoie un Result ou une implémentation de TryFrom. Le champ étant privé, le seul moyen d'obtenir une valeur est de passer par ce constructeur : une valeur invalide ne peut pas exister. Les Rust API Guidelines recommandent les newtypes pour obtenir des distinctions statiques entre des valeurs de même représentation.
Des identifiants qu'on ne peut plus inverser
Le premier gain ne demande aucune validation : deux identifiants de types différents ne s'inversent plus. Une fonction qui attend un OrderId puis un CustomerId refuse de compiler si on les passe dans le désordre.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct OrderId(i64);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct CustomerId(i64);
impl OrderId {
pub const fn new(value: i64) -> Self {
Self(value)
}
pub const fn get(self) -> i64 {
self.0
}
}
// … mêmes new() et get() pour CustomerId
pub fn assign_order(order_id: OrderId, customer_id: CustomerId) {
let (order, customer) = (order_id.get(), customer_id.get());
println!("commande {order} rattachée au client {customer}");
}
fn main() {
let order_id = OrderId::new(1337);
let customer_id = CustomerId::new(42);
assign_order(order_id, customer_id);
// assign_order(customer_id, order_id);
// error[E0308]: arguments to this function are incorrect
// expected `OrderId`, found `CustomerId`
}L'erreur du tout premier exemple, une commande rattachée au mauvais client, devient une erreur de compilation. Aucun test n'a besoin de la chercher.
Le surcoût est nul en pratique. Le Rust Book indique qu'un newtype n'a aucune pénalité de performance à l'exécution, l'enveloppe disparaissant à la compilation. Pour la mémoire, une mesure locale réalisée pour cet article (rustc 1.99, x86_64) donne 8 octets pour OrderId, comme pour un i64. C'est un constat d'implémentation : la référence du langage ne garantit la même disposition mémoire que le champ enveloppé qu'avec l'attribut #[repr(transparent)], utile seulement pour le FFI ou les conversions unsafe.
Un montant sans f64
Pour un montant, Rust ferme d'emblée la porte au flottant d'une façon instructive : f64 implémente PartialEq mais ni Eq ni Hash, parce que NaN n'est pas égal à lui-même (documentation de Eq). Un Money construit sur un f64 ne pourrait donc pas dériver Eq ni servir de clé de HashMap. Le choix courant est de stocker le montant en unités mineures dans un i64 (des centimes pour l'euro, des yens pour le yen) avec la devise à côté. Quand le domaine manipule des taux, des prix unitaires à quatre décimales ou des conversions, la crate rust_decimal, conçue pour les calculs financiers à précision fixe, prend le relais.
La devise d'abord : une énumération fermée, qui connaît ses décimales et refuse un code inconnu.
use std::fmt;
use std::str::FromStr;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Currency {
Eur,
Usd,
Jpy,
}
impl Currency {
// Décimales ISO 4217 : 1999 unités mineures font 19,99 EUR, 1 999 JPY.
pub const fn decimals(self) -> u32 {
match self {
Self::Eur | Self::Usd => 2,
Self::Jpy => 0,
}
}
pub const fn code(self) -> &'static str {
match self {
Self::Eur => "EUR",
Self::Usd => "USD",
Self::Jpy => "JPY",
}
}
}
impl FromStr for Currency {
type Err = MoneyError;
fn from_str(code: &str) -> Result<Self, Self::Err> {
match code {
"EUR" => Ok(Self::Eur),
"USD" => Ok(Self::Usd),
"JPY" => Ok(Self::Jpy),
_ => Err(MoneyError::UnknownCurrency(code.to_owned())),
}
}
}Puis le montant et ses erreurs, chacune nommée, avec le Display dont serde aura besoin plus loin :
// Pas de f64 : il n'implémente ni Eq ni Hash (NaN != NaN), et 0,1 n'y a pas
// de valeur exacte. Un i64 en unités mineures garde un calcul exact.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Money {
amount_minor: i64,
currency: Currency,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MoneyError {
Negative(i64),
CurrencyMismatch(Currency, Currency),
Overflow,
UnknownCurrency(String),
}
// Display est requis par serde pour les erreurs de TryFrom.
impl fmt::Display for MoneyError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Negative(amount) => write!(f, "montant négatif : {amount}"),
Self::CurrencyMismatch(a, b) => {
write!(f, "devises différentes : {} et {}", a.code(), b.code())
}
Self::Overflow => f.write_str("dépassement de capacité"),
Self::UnknownCurrency(code) => {
write!(f, "devise inconnue : {code}")
}
}
}
}
impl std::error::Error for MoneyError {}Les opérations ensuite : le constructeur faillible, seul moyen d'obtenir un Money, et une addition qui refuse les devises différentes comme le dépassement de capacité.
impl Money {
pub fn new(
amount_minor: i64,
currency: Currency,
) -> Result<Self, MoneyError> {
// Règle de ce domaine : un total de commande n'est jamais négatif.
// Un avoir ou un remboursement pourrait l'autoriser.
if amount_minor < 0 {
return Err(MoneyError::Negative(amount_minor));
}
Ok(Self {
amount_minor,
currency,
})
}
pub fn checked_add(self, other: Self) -> Result<Self, MoneyError> {
if self.currency != other.currency {
return Err(MoneyError::CurrencyMismatch(
self.currency,
other.currency,
));
}
// i64::checked_add renvoie None au lieu de déborder en silence.
let amount_minor = self
.amount_minor
.checked_add(other.amount_minor)
.ok_or(MoneyError::Overflow)?;
Ok(Self {
amount_minor,
currency: self.currency,
})
}
// … accesseurs amount_minor() et currency()
}Enfin l'adresse e-mail, avec les mêmes règles qu'en Python, derrière une implémentation de TryFrom<String> :
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct EmailAddress(String); // forme normalisée « local@domaine »
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EmailError(String);
impl fmt::Display for EmailError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "adresse invalide : {:?}", self.0)
}
}
impl std::error::Error for EmailError {}
// TryFrom est dans le prélude depuis l'édition 2021 : aucun import.
impl TryFrom<String> for EmailAddress {
type Error = EmailError;
fn try_from(raw: String) -> Result<Self, Self::Error> {
let invalid = || EmailError(raw.clone());
// Mêmes règles que la version Python : un seul @ (limite RFC 5322),
// domaine en minuscules, partie locale conservée (RFC 5321).
let (local_part, domain) =
raw.trim().split_once('@').ok_or_else(invalid)?;
let valid = |part: &str| {
!part.is_empty() && !part.contains('@') && part.trim() == part
};
if !valid(local_part) || !valid(domain) {
return Err(invalid());
}
Ok(Self(format!("{local_part}@{}", domain.to_lowercase())))
}
}
impl From<EmailAddress> for String {
fn from(email: EmailAddress) -> Self {
email.0
}
}Le résultat est un type qui garantit, par construction, qu'un montant a une devise, ne déborde pas silencieusement et ne s'additionne jamais avec une autre devise. C'est ce que l'article sur Rust pour une application critique décrit comme la gestion des états invalides à la compilation plutôt qu'en production, appliquée à une donnée métier ordinaire. Pour l'équipe, une addition entre devises différentes n'a plus besoin d'être cherchée en revue de code : le compilateur l'arrête.
Valider à la construction plutôt que partout
Un Value Object déplace la validation. Au lieu de vérifier les données à chaque usage, on les vérifie une fois, au bord du système, en les convertissant en types qui ne peuvent porter que des valeurs valides. Ensuite, le code du domaine reçoit un EmailAddress et un Money et n'a plus rien à revérifier.
Alexis King a donné à cette approche une formule devenue classique, « Parse, don't validate » (2019). Un validateur contrôle une donnée puis jette ce qu'il a appris : la fonction suivante reçoit la même str et ne sait pas qu'elle a été vérifiée. Un parseur conserve ce savoir dans un type plus précis. King critique aussi le « shotgun parsing », ces contrôles éparpillés dans le code de traitement qui finissent par laisser passer une donnée invalide à mi-chemin d'une opération. Elle reprend la consigne « make illegal states unrepresentable », popularisée par Yaron Minsky de Jane Street dans « Effective ML Revisited » (2011) : choisir des structures de données qui ne peuvent pas représenter un état interdit.
En pratique, cela donne un point d'entrée unique par cas d'usage. Une requête brute (JSON d'API, formulaire, message) est convertie en commande typée PlaceOrder. Toutes les erreurs sont collectées en une passe, pour que l'appelant reçoive la liste complète plutôt que la première seulement. Le service qui exécute la commande ne contient plus aucun contrôle de format. La commande est volontairement réduite ici à trois champs, plus simple que celle de l'article sur les transactions distribuées. L'identifiant client y est un NewType : il suffit à ne pas le confondre avec un autre entier pour le vérificateur de types, puisqu'il n'a pas de règle à faire respecter.
# shop/commands.py
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from typing import NewType
from .email_address import EmailAddress # dataclass EmailAddress plus haut
from .money import Currency, Money # dataclass Money plus haut
CustomerId = NewType("CustomerId", int)
@dataclass(frozen=True, slots=True)
class PlaceOrder:
customer_id: CustomerId
email: EmailAddress
total: Money
class InvalidRequestError(ValueError):
def __init__(self, errors: dict[str, str]) -> None:
super().__init__(errors)
self.errors = errorsLe point d'entrée convertit chaque champ par le constructeur de son type et range chaque échec sous le nom du champ, sans s'arrêter au premier :
def parse_place_order(raw: Mapping[str, object]) -> PlaceOrder:
errors: dict[str, str] = {}
def get[T](key: str, expected: type[T]) -> T:
value = raw.get(key)
# type() plutôt qu'isinstance() : bool hérite d'int, True passerait.
if type(value) is not expected:
raise TypeError(f"{key} : {expected.__name__} attendu")
return value
def field[T](name: str, parse: Callable[[], T]) -> T | None:
try:
return parse()
except (TypeError, ValueError) as exc:
errors[name] = str(exc)
return None
customer_id = field(
"customer_id", lambda: CustomerId(get("customer_id", int))
)
email = field("email", lambda: EmailAddress.parse(get("email", str)))
# Montant en chaîne : un nombre JSON serait relu en float.
total = field(
"total",
lambda: Money.of(get("amount", str), Currency(get("currency", str))),
)
# Toutes les erreurs remontent ensemble, pas seulement la première.
if customer_id is None or email is None or total is None:
raise InvalidRequestError(errors)
return PlaceOrder(customer_id, email, total)
def place_order(command: PlaceOrder) -> None:
# Aucun contrôle de format ici : les types le garantissent.
...Le bénéfice se mesure dans les tests. Les tests de validation se concentrent sur les constructeurs des Value Objects et sur le point d'entrée ; les tests du domaine n'ont plus à couvrir « et si l'e-mail est vide ». Les erreurs de saisie sont détectées au bord du système, avec un message qui nomme le champ fautif, au lieu de surgir au milieu d'un traitement, après une écriture partielle en base.
Persister un Value Object avec Django et sqlx
La base de données est l'endroit où les primitifs reviennent le plus souvent, parce que l'ORM manipule des colonnes et non des concepts. L'objectif est que cette contrainte reste dans la couche de persistance : le domaine continue de manipuler des Money et des EmailAddress, et la conversion se fait à la lecture et à l'écriture.
Trois façons de stocker un Value Object
- Colonnes éclatées : un montant devient deux colonnes,
total_amountettotal_currency. Les requêtes, les agrégations (SUMpar devise) et les index restent simples, et chaque colonne porte sa propre contrainte. C'est l'option par défaut pour un Value Object à plusieurs attributs. - Une colonne avec conversion : un Value Object à un seul attribut, comme une adresse e-mail ou un identifiant, occupe une colonne ; un champ Django personnalisé ou un type sqlx transparent fait la conversion. C'est l'option la plus discrète.
- Une colonne JSON : souple, mais le montant redevient un nombre JSON ou une chaîne, sans type
NUMERIC, sans contrainte de colonne simple, avec des index moins efficaces. On la réserve aux Value Objects qu'on ne requête jamais.
Dans tous les cas, la base porte aussi l'invariant quand c'est possible : une contrainte CHECK sur le signe du montant ou sur la liste des devises, une précision NUMERIC cohérente avec les décimales des devises gérées. Le Value Object protège le code ; la contrainte protège la base contre les scripts SQL manuels, les imports et les autres applications qui y écrivent. Le type PostgreSQL money est à éviter : il dépend du paramètre de locale lc_monetary et ne stocke pas la devise.
Django : colonnes éclatées et champ personnalisé
Avec Django, le montant se stocke en deux champs (un DecimalField et un code de devise), et le modèle expose une propriété qui reconstruit le Money. L'adresse e-mail utilise un champ personnalisé dérivé de CharField, qui convertit dans les deux sens : from_db_value à la lecture (y compris dans les agrégations et les appels à values()), get_prep_value à l'écriture, to_python pour les formulaires et la désérialisation. Une quatrième méthode, run_validators, repasse la valeur en chaîne avant les validateurs hérités de CharField : max_length mesure une str, pas un EmailAddress, et full_clean() échouerait sans elle.
# shop/models.py
from django.db import models
from .fields import EmailAddressField
from .money import Currency, Money # dataclass Money plus haut
CURRENCY_CODES = [currency.value for currency in Currency]
class Order(models.Model):
customer_email = EmailAddressField(max_length=254)
# Colonnes éclatées plutôt que JSONField : on garde NUMERIC, les CHECK et
# SUM par devise (et l'encodeur JSON standard refuse Decimal).
# Deux décimales couvrent EUR, USD et JPY ; le KWD en demanderait trois.
total_amount = models.DecimalField(max_digits=12, decimal_places=2)
total_currency = models.CharField(
max_length=3, choices=[(code, code) for code in CURRENCY_CODES]
)
class Meta:
constraints = [
# condition= : l'argument check= est supprimé depuis Django 6.0.
models.CheckConstraint(
condition=models.Q(total_amount__gte=0),
name="order_total_amount_non_negative",
),
models.CheckConstraint(
condition=models.Q(total_currency__in=CURRENCY_CODES),
name="order_total_currency_known",
),
]
@property
def total(self) -> Money:
return Money(self.total_amount, Currency(self.total_currency))
@total.setter
def total(self, value: Money) -> None:
# Money a déjà quantifié : la base n'arrondit pas avec sa propre règle.
self.total_amount = value.amount
self.total_currency = value.currency.valueLe champ personnalisé de l'adresse e-mail, lui, se charge de la conversion dans les deux sens :
# shop/fields.py
from typing import Any
from django.core.exceptions import ValidationError
from django.db import models
from .email_address import EmailAddress, InvalidEmailError # voir plus haut
class EmailAddressField(models.CharField):
# Pas de deconstruct() : le champ garde la signature de CharField.
def from_db_value(self, value: Any, *_: Any) -> EmailAddress | None:
# Une ligne corrompue lève une erreur à la lecture, sans circuler.
return None if value is None else EmailAddress.parse(value)
def to_python(self, value: Any) -> EmailAddress | None:
if value is None or isinstance(value, EmailAddress):
return value
try:
return EmailAddress.parse(str(value))
except InvalidEmailError as exc:
raise ValidationError(str(exc), code="invalid") from exc
def get_prep_value(self, value: Any) -> str | None:
# Toute écriture repasse par parse() : une chaîne brute est normalisée.
email = self.to_python(value)
return None if email is None else str(email)
def run_validators(self, value: Any) -> None:
# Les validateurs de CharField (max_length) mesurent une chaîne.
super().run_validators(None if value is None else str(value))Un détail à connaître avant de se lancer : Django ne détecte pas un changement de classe de base d'un champ personnalisé dans les migrations. Choisir CharField dès le départ évite une migration manuelle plus tard.
sqlx : type transparent et reconstruction faillible
Côté Rust, sqlx convertit directement un newtype à un champ grâce à #[derive(sqlx::Type)] et #[sqlx(transparent)], que la documentation de sqlx::Type présente justement pour les enveloppes de domaine autour d'un type SQL. Pour Money, la lecture récupère deux colonnes, amount_minor BIGINT et currency TEXT, puis reconstruit le Value Object par son constructeur faillible : une ligne corrompue en base devient une erreur explicite à la lecture, pas une valeur invalide qui circule dans le domaine.
La table reprend les règles du domaine sous forme de contraintes :
CREATE TABLE orders (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_email TEXT NOT NULL,
-- Mêmes règles que Money::new et Currency : la base refuse ce que le
-- domaine refuse, y compris depuis un script SQL manuel.
amount_minor BIGINT NOT NULL CHECK (amount_minor >= 0),
currency TEXT NOT NULL CHECK (currency IN ('EUR', 'USD', 'JPY'))
);L'identifiant de commande est transparent pour sqlx ; la requête, elle, délègue la reconstruction de la ligne à FromRow :
use sqlx::postgres::PgRow;
use sqlx::{FromRow, PgPool, Row};
// Le newtype OrderId vu plus haut, lu et écrit comme un BIGINT.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, sqlx::Type)]
#[sqlx(transparent)]
pub struct OrderId(i64);
pub struct Order {
pub id: OrderId,
pub customer_email: EmailAddress,
pub total: Money,
}
pub async fn find_order(
pool: &PgPool,
id: OrderId,
) -> Result<Option<Order>, sqlx::Error> {
// Requête littérale (&'static str), comme sqlx 0.9 l'attend.
sqlx::query_as(
"SELECT id, customer_email, amount_minor, currency \
FROM orders WHERE id = $1",
)
.bind(id)
.fetch_optional(pool)
.await
}C'est dans from_row que chaque colonne repasse par le constructeur de son Value Object : l'adresse par TryFrom, la devise par FromStr, le montant par Money::new.
// Une valeur refusée par le domaine devient une erreur de lecture qui nomme
// la colonne, au lieu d'un objet invalide qui circule.
fn rejected(
column: &str,
source: impl std::error::Error + Send + Sync + 'static,
) -> sqlx::Error {
sqlx::Error::ColumnDecode {
index: column.to_owned(),
source: Box::new(source),
}
}
impl FromRow<'_, PgRow> for Order {
fn from_row(row: &PgRow) -> Result<Self, sqlx::Error> {
// EmailAddress n'est pas transparent : la lecture passe par TryFrom.
let email: String = row.try_get("customer_email")?;
let customer_email = EmailAddress::try_from(email)
.map_err(|error| rejected("customer_email", error))?;
let currency: String = row.try_get("currency")?;
let currency = currency
.parse()
.map_err(|error| rejected("currency", error))?;
let total = Money::new(row.try_get("amount_minor")?, currency)
.map_err(|error| rejected("amount_minor", error))?;
Ok(Self {
id: row.try_get("id")?,
customer_email,
total,
})
}
}La persistance cesse alors d'être le point faible. Une donnée invalide ne peut entrer ni par le code (le Value Object la refuse) ni par un script SQL (la contrainte la refuse), et une donnée ancienne qui ne respecte plus la règle se signale à la lecture au lieu de fausser un rapport.
Sérialiser un Value Object en JSON : API et messages
Le même principe s'applique aux frontières : conversion à l'entrée et à la sortie, et uniquement des Value Objects à l'intérieur. Le point délicat est le montant, parce que JSON n'a qu'un type numérique. La RFC 8259 autorise les implémentations à limiter la précision des nombres et ne garantit l'interopérabilité qu'à la précision d'un flottant binaire IEEE 754. En Python, json.loads convertit par défaut tout nombre décimal en float, sauf à passer parse_float=decimal.Decimal. Un montant transmis comme 19.99 reprend donc la forme d'un float dès qu'un client JavaScript ou un script Python le relit sans précaution.
Deux formes évitent le problème, et chacune est pratiquée par un grand acteur du paiement :
- Unités mineures entières plus code de devise : Stripe transmet
amountcomme un entier dans la plus petite unité de la devise (100 pour 1,00 USD, 100 pour 100 JPY). C'est la forme{ "amount_minor": 1999, "currency": "EUR" }. - Chaîne décimale plus code de devise : PayPal transmet
valuesous forme de chaîne, accompagnée decurrency_code.
Aucun des deux n'envoie de nombre JSON décimal. Attention aussi aux écarts entre la norme et les prestataires : Stripe demande de transmettre certaines devises sans décimale, comme la couronne islandaise, avec deux décimales à zéro, et PayPal refuse les décimales pour le forint hongrois alors que l'ISO lui en attribue deux (devises Stripe, devises PayPal). La table des décimales du domaine suit l'ISO ; la conversion vers les règles d'un prestataire appartient à l'adaptateur qui lui parle.
En Rust, serde valide à la désérialisation grâce aux attributs de conteneur #[serde(try_from = "String")] et #[serde(into = "String")] : le JSON entrant passe par le constructeur faillible, et une adresse invalide produit une erreur de désérialisation au lieu d'un objet invalide. L'attribut #[serde(transparent)], plus court, sérialise le champ tel quel mais sans aucune validation : il ne convient pas à un type dont l'invariant compte. On reprend les mêmes types, avec les dérivations serde. Pour Money, une structure d'échange intermédiaire fixe la forme JSON en unités mineures et repasse par Money::new à la lecture.
use serde::{Deserialize, Serialize};
// Le JSON entrant repasse par TryFrom<String> : pas de contournement.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct EmailAddress(String);
// Forme d'échange : { "amount_minor": 1999, "currency": "EUR" }.
#[derive(Serialize, Deserialize)]
struct MoneyJson {
amount_minor: i64,
currency: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(try_from = "MoneyJson", into = "MoneyJson")]
pub struct Money {
amount_minor: i64,
currency: Currency,
}
impl TryFrom<MoneyJson> for Money {
type Error = MoneyError;
// Montant et devise repassent par Money::new et Currency::from_str.
fn try_from(json: MoneyJson) -> Result<Self, Self::Error> {
Self::new(json.amount_minor, json.currency.parse()?)
}
}
impl From<Money> for MoneyJson {
fn from(money: Money) -> Self {
Self {
amount_minor: money.amount_minor,
currency: money.currency.code().to_owned(),
}
}
}Deux tests suffisent à fixer le contrat : une adresse invalide est refusée dès la désérialisation, et un montant fait l'aller-retour sans perte.
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn invalid_email_is_rejected_at_deserialization() {
let result = serde_json::from_str::<EmailAddress>(r#""alice""#);
assert!(result.is_err());
}
#[test]
fn money_round_trips_through_minor_units() -> serde_json::Result<()> {
let json = r#"{"amount_minor":1999,"currency":"EUR"}"#;
let money: Money = serde_json::from_str(json)?;
assert_eq!(Ok(money), Money::new(1999, Currency::Eur));
assert_eq!(serde_json::to_string(&money)?, json);
Ok(())
}
}Les messages asynchrones suivent la même règle que l'API. Un événement OrderPlaced publié par l'outbox transactionnelle est un contrat entre services : son montant s'y écrit en unités mineures ou en chaîne, avec la devise, et le consommateur le reconstruit par le constructeur de son propre Value Object. Un service qui relit les montants en flottant produit des écarts de rapprochement qu'on découvre à la clôture, quand il faut retrouver l'origine de chaque centime.
Quand un Value Object n'apporte rien
Le pattern a un coût, faible mais réel : une classe de plus, des conversions aux frontières, un peu de friction avec certains outils. Il ne se justifie pas partout.
- Un CRUD sans règle métier : un back-office qui affiche et modifie des fiches sans calcul ni contrainte gagne peu à envelopper chaque champ.
- Un script ponctuel : une migration jouée une fois ou un export à usage unique n'a pas besoin de garanties qui servent dans la durée.
- Une valeur sans invariant : un libellé libre, un commentaire, une description de produit. Si aucune valeur n'est interdite, il n'y a rien à protéger.
- Un découpage excessif : un Value Object pour le prénom, un pour le nom, un pour le nom complet, chacun avec ses conversions. Le code se remplit de transformations qui n'expriment aucune règle.
- Un framework qui suppose des primitifs : certains ORM, générateurs de formulaires ou outils d'administration attendent des types de base. Quand la conversion coûte plus cher que la règle qu'elle protège, mieux vaut garder le primitif dans cette couche et ne convertir qu'à l'entrée du domaine. Microsoft documente par exemple, pour Entity Framework Core, les écarts entre le Value Object théorique et ce que l'ORM sait stocker.
Selon Vladimir Khorikov, c'est le travers inverse qui domine : on sous-utilise les Value Objects bien plus souvent qu'on n'en abuse. Le critère reste simple : un primitif mérite un Value Object quand il porte une règle (un format, une plage, une unité, une devise) ou quand il peut être confondu avec un autre primitif du même type.
Reste la performance. En Rust, l'enveloppe ne coûte rien, puisque le newtype disparaît à la compilation ; seule la validation de Money::new coûte une comparaison. En Python, construire un objet coûte plus cher que manipuler un tuple. Une mesure indicative réalisée pour cet article sur un poste de développement (CPython 3.14, timeit) donne environ 190 ns pour construire un Money gelé et validé, contre environ 16 ns pour un simple tuple (Decimal, Currency). L'écart est réel en valeur relative et négligeable devant une requête SQL ou un appel réseau ; il devient mesurable dans une boucle qui traite des millions de lignes. Ces valeurs varient selon la machine et la version de Python : dans une boucle critique, on mesure avant de décider, et on peut construire les Value Objects à l'entrée et à la sortie du traitement plutôt qu'à chaque itération.
Introduire des Value Objects dans une base de code existante
Le Value Object a un avantage rare : il s'introduit progressivement, un type à la fois, sans refonte. Une démarche qui fonctionne :
- Lister les primitifs qui portent une règle : montants, devises, adresses e-mail, identifiants, plages de dates, quantités avec unité, numéros de téléphone, codes pays. Une recherche des noms de paramètres (
amount,email,_id,start,end) dans le code donne une première liste. - Commencer par celui qui a déjà causé un bug : un écart de facturation, un doublon de compte, une commande rattachée au mauvais client. Le gain est démontré d'avance, et l'équipe voit le type corriger un problème qu'elle connaît.
- L'introduire d'abord aux frontières : convertir à l'entrée de l'API, des formulaires et des imports, puis laisser le type progresser vers l'intérieur à mesure que les fonctions sont modifiées. Pendant la transition, le code interne peut encore recevoir un primitif ; il le convertit au premier usage.
- Ajouter la contrainte en base : une contrainte
CHECKou une précisionNUMERIC, après avoir corrigé les lignes existantes qui ne la respectent pas. Cette correction est souvent le moment où l'on mesure l'ampleur du problème. - Tester le constructeur : les cas valides, les cas invalides et les cas limites (zéro, devise sans décimale, adresse avec espaces). Ces tests deviennent la documentation exécutable de la règle.
Quelques questions aident à prioriser :
- Quels champs ont déjà fait l'objet d'un script de correction de données ?
- Quelle règle de format est écrite à plus d'un endroit dans le code ?
- Quelles fonctions prennent deux paramètres du même type primitif qu'on pourrait inverser ?
- Les montants traversent-ils une API ou un message sous forme de nombre JSON décimal ?
- La base accepte-t-elle aujourd'hui une valeur que le métier interdit ?
Chaque réponse positive désigne un candidat. Pour une équipe qui veut un regard extérieur sur ces priorités, ce repérage s'inscrit naturellement dans un audit de la base de code, et l'introduction des types eux-mêmes dans une mission de développement Python et Rust.
Un sujet à creuser pour votre projet ?
Discutons de votre contexte technique et de vos enjeux.
Parlons de votre projet