Explorons un cas concret de modélisation de Value Object centré sur une mesure physique pour un projet industriel.

Préambule

Dans le cadre d'un projet industriel dédié au développement d'un logiciel de dessin technique automatisé, j'ai largement exploité ces objets. Cet article a pour but de partager la méthode de modélisation que nous avons mise en œuvre mais transposée aux mesures de température.

Le pattern Value Object

Avant de plonger dans les détails de l'implémentation, il est pertinent de revoir brièvement la notion de value objects.

Les value objects se distinguent par le fait qu'ils sont définis par la valeur de leurs attributs. Autrement dit, deux value objects sont considérés comme équivalents si tous leurs attributs sont identiques. Cette caractéristique les oppose aux entités, qui sont identifiées de manière unique, au moyen d'un identifiant spécifique - souvent dénommé Id.

L'adoption de value objects dans la conception logicielle présente plusieurs avantages:

  • Ils permettent d'encapsuler et de valider des données complexes, renforçant ainsi l'intégrité et la fiabilité du domaine métier.
  • Leur immuabilité — l'impossibilité de modifier leur état une fois créés — contribue également à la sûreté et à la stabilité de l'application.
  • En incarnant des concepts métier spécifiques sous forme d'objets, ils facilitent la compréhension du domaine et améliorent la communication entre développeurs et experts métier.

Contexte métier

Dans les industries chimiques, pétrochimiques, ou dans le domaine de l'ingénierie thermique, l'équation PV=nRT, connue sous le nom de loi des gaz parfaits, est fréquemment utilisée pour prédire le comportement des gaz sous différentes conditions de température, de pression et de volume. Cette équation relie la pression (P), le volume (V), le nombre de moles (n) de gaz, la constante des gaz parfaits (R), et la température (T) pour décrire l'état d'une certaine quantité de gaz idéal.

Cette équation peut, par exemple, être utilisée pour dimensionner des réacteurs, des réservoirs de stockage ou des systèmes de conduites en fonction des conditions opérationnelles attendues.

Implémentations

Implémentation naïve

Partons sur une implémentation naïve du calcul du volume d'un gaz en nous basant sur cette équation.

Imaginons que nous souhaitons calculer le volume occupé par une certaine quantité de gaz à une pression et une température données, en utilisant l'équation des gaz parfaits. Voici un exemple d'implémentation:

public static class GasVolumeCalculator {

    private const double R = 8.314; // Constante des gaz parfaits J/(mol.K)

    // Calcul du volume d'un gaz parfait à partir de P, n, et T
    public static double Calculate(double pressure, double moles, double temperature) {
        // Assurez-vous que la température est en Kelvin pour cette équation
        double volume = (moles * R * temperature) / pressure;
        return volume;
    }
}
public static void Main(string[] args) {
    double pressure = double.Parse(args[0]); // Convertit le premier argument en double pour la pression
    double moles = double.Parse(args[1]); // Convertit le deuxième argument en double pour les moles
    double temperature = double.Parse(args[2]); // Convertit le troisième argument en double pour la température
    double volume = GasVolumneCalculator.Calculate(pressure, moles, temperature);
    Console.WriteLine($"Le volume occupé par le gaz est de {volume} mètres cubes.");
}

Ci-dessous un exemple d'utilisation du programme:

> GasVolumeCalculator.exe 101325 1 298
Le volume occupé par le gaz est de 0.024451734517641256 mètres cubes.

Dans le cadre d'un projet simple cette implémentation est largement suffisante.

Cependant si on imagine des processus chimiques complexes avec ses lots de calculs on imagine facilement les problèmes qui pourraient survenir.

Manque de précision

Dans le contexte de notre cas d'utilisation, le choix initial du type double pour représenter des mesures de température et d'autres grandeurs physiques peut conduire à des problèmes de précision, notamment en raison de la manière dont les doubles gèrent les nombres à virgule flottante et les erreurs d'arrondi associées.

Si l'ensemble du système repose sur des doubles pour effectuer des calculs, envisager ultérieurement de passer à decimal pour améliorer la précision peut s'avérer extrêmement difficile: cette transition n'est pas seulement une question de changer le type de données ; elle implique également de revoir toutes les opérations mathématiques, car decimal et double n'ont pas les mêmes comportements pour les calculs.

Absence de validation

L'absence de mécanismes de validation intégrés lors de l'utilisation de types primitifs comme double pour représenter des températures peut mener à des erreurs significatives, notamment en ignorant des contraintes physiques fondamentales telles que le zéro absolu.

En physique, le zéro absolu est la température la plus basse possible, où aucune énergie thermique ne reste dans une substance. Cette température est de -273.15°C ou 0 Kelvin.

Sans une validation adéquate, rien n'empêche une saisie utilisateur d'assigner une valeur de température en dessous de ce seuil physique, ou même un calcul (!!!), conduisant à des résultats physiquement irréalistes et potentiellement à des erreurs dans le traitement des données.

Difficulté à gérer les unités de mesure

Les types primitifs comme double ne portent pas d'information sur l'unité des mesures, ici de la température. Par exemple, une variable représentant une température en degrés Celsius pourrait accidentellement être confondue avec une température en Kelvin ou en Fahrenheit.

De plus, s'il y a de nombreuses unités qui coexistent dans les traitements, il y aura potentiellement besoin de nombreuses conversions d'unités qui pourraient poser des problèmes de précision (retour point 1).

Perte de sémantique

L'utilisation de types primitifs ne transmet pas le sens ou le contexte de la mesure: la variable k représente-t-elle une température, une pression, une distance, ... ?

Naming ! Me direz-vous. C'est vraie, le bon nommage des variables est un point essentiel mais il n'est parfois pas suffisant: lors du passage de paramètre il est facile d'inverser l'ordre des paramètres et d'introduire ainsi une erreur.

Problèmes de réutilisabilité et de maintenance

Certaines opérations intrinsèques à la température (comme les conversions d'unités) vont se retrouver facilement dupliquée du fait qu'elles ne rencontrent pas de support. L'utilisation de helpers n'est d'ailleurs pas forcément la solution - nombre de projets dans lesquels j'ai retrouvé le même code dans différents helpers car ils n'apparaissent pas comme "support naturel" de l'opération.

Incapacité à encapsuler des comportements

Les types primitifs ne permettent pas d'encapsuler des comportements ou des règles métier spécifiques aux mesures, comme des conditions de validité ou des méthodes de conversion spéciales.

Risque d'incohérence dans les données

Dans des systèmes complexes avec de nombreuses interactions entre composants, l'absence d'une représentation forte des mesures augmente le risque d'incohérence des données, surtout lorsque différentes parties du système manipulent ces mesures de manière indépendante.

Seconde implémentation

Cette approche initiale, simple et directe, risque de conduire à un anti-pattern connu sous le nom de "primitive obsession" dans le contexte d'un projet complexe - si vous n'êtes pas familier avec ce concept, je vous recommande de consulter cet article.

Pour améliorer cette mise en œuvre, nous pouvons adopter le pattern objet valeur, qui offre une solution plus élaborée ( - spoiler alert - cette implémentation basique n'est pas encore satisfaisante, mais faisons par étape).

public enum TemperatureUnit {
    Celsius,
    Fahrenheit,
    Kelvin,
    Rankine
}

public sealed class Temperature {

    public double          Value { get; private set; }
    public TemperatureUnit Unit  { get; private set; }

    public Temperature(double value, TemperatureUnit unit) {
        if (unit == TemperatureUnit.Kelvin && value < 0) {
            throw new ArgumentException("La température ne peut pas être inférieure au zéro absolu (0 Kelvin).");
        }
        // autres cas de validation

        Value = value;
        Unit = unit;
    }

    public override string ToString() {
        return $"{Value} {Unit}";
    }

    // ...
}

Cette implémentation de la température en tant que value object offre une amélioration par rapport à l'utilisation de simples double. Cependant elle présente encore des limitations dans notre contexte, notamment en raison de la gestion des unités.

L'intuitivité de manipuler des températures avec des unités explicites, comme le Celsius, le Fahrenheit ou le Kelvin, cache en réalité une complexité sous-jacente lorsqu'il s'agit de réaliser des calculs impliquant plusieurs de ces objets. Chaque opération entre températures de différentes unités nécessite des conversions préalables, augmentant ainsi la perte de précision.

De plus, la vérification systématique du zéro absolu, bien qu'essentielle pour garantir la validité des données, introduit une complexité supplémentaire. Cette vérification, pour être exhaustive, requiert soit une série de conditions préalables pour chaque unité, soit une conversion systématique en Kelvin uniquement pour effectuer cette vérification. Dans les deux cas, cela alourdit l'implémentation et peut impacter les performances pour des vérifications qui, dans la pratique, ne serviront que rarement.

Troisième implémentation

La sélection des unités de température est principalement dictée par le contexte d'utilisation, comme la localisation géographique de l'utilisateur ou le contexte spécifique de la mesure.

Toutefois, les experts du domaine privilégient la plupart du temps une unité standard pour simplifier leurs calculs. Considérons le Kelvin comme leur unité de référence. Sur cette base, adoptons le pattern de la méthode de fabrique pour créer nos instances d'objets Temperature, facilitant ainsi une gestion cohérente et centralisée des conversions de température.

public sealed class Temperature {

    public static Temperature FromKelvin(double kelvin) {
        return new Temperature(kelvin);
    }

    public static Temperature FromCelsius(double celsius) {
        double kelvin = celsius + 273.15;

        return new Temperature(kelvin);
    }

    public static Temperature FromFahrenheit(double fahrenheit) {
        double kelvin = (fahrenheit - 32) * 5/9 + 273.15;

        return new Temperature(kelvin);
    }

    // Ajoutez ici des méthodes de fabrique pour d'autres unités si nécessaire

    private double _value;

    private Temperature(double value) {
        if (value < 0) { throw new ArgumentException("La température ne peut pas être inférieure au zéro absolu (0 Kelvin).");
        }

        _value = value;
    }

    public double ToKelvin() {
        return _value ;
    }

    public double ToCelsius() {
        return _value - 273.15;
    }

    public double ToFahrenheit() {
        return (_value - 273.15) * 9/5 + 32;
    }

    // Ajoutez ici des méthodes de conversion pour d'autres unités si nécessaire
   
    public override string ToString() {
        return $"{KelvinValue} K";
    }
}

L'implémentation présentée ci-dessus s'aligne davantage sur un standard de code prêt pour la production.

En rendant le constructeur privé et en favorisant l'emploi de méthodes statiques, nous clarifions nettement les conversions dès l'introduction du cas d'utilisation.

De surcroît, les méthodes de conversion d'instance, intégrées directement au sein du value object Temperature plutôt que dans un helper externe, manifestent explicitement l'intention de conversion à la conclusion du cas d'usage.

Le champ _value est désormais inaccessible directement, ce qui encourage l'usage explicite des méthodes de conversion. Vous pourriez vous demander alors comment réaliser des opérations telles que les additions, multiplications, etc., avec une température.

Amélioration: surcharge des opérateurs

Quatrième étape de notre implémentation du type Temperature:

    // Surcharge des opérateurs pour addition et soustraction
    public static Temperature operator +(Temperature a, Temperature b) => new Temperature(a._value + b._value);
    public static Temperature operator -(Temperature a, Temperature b) => new Temperature(a._value - b._value);

    // Surcharge des opérateurs pour multiplication et division par un double
    public static Temperature operator *(Temperature a, double b) => new Temperature(a._value * b);
    public static Temperature operator /(Temperature a, double b) => new Temperature(a._value / b);

    // Surcharge des opérateurs de comparaison
    public static bool operator >(Temperature a, Temperature b) => a._value > b._value;
    public static bool operator <(Temperature a, Temperature b) => a._value < b._value;
    public static bool operator >=(Temperature a, Temperature b) => a._value >= b._value;
    public static bool operator <=(Temperature a, Temperature b) => a._value <= b._value;

Il est désormais simple de travailler non plus avec un double mais directement avec un type Temperature. Le code ci-dessous donne un exemple:

class Program {

    static void Main(string[] args) {

        // Création de deux températures
        Temperature temp1 = Temperature.FromCelsius(25); // 25°C
        Temperature temp2 = Temperature.FromFahrenheit(68); // soit 20°C

        // Addition des deux températures
        Temperature additionResult = temp1 + temp2;
        Console.WriteLine($"Addition: {additionResult.ToCelsius()}°C"); // Addition: 45°C

        // Soustraction des deux températures
        Temperature subtractionResult = temp1 - temp2;
        Console.WriteLine($"Soustraction: {subtractionResult.ToCelsius()}°C"); // Soustraction: 5°C

        // Multiplication de temp1 par un facteur
        Temperature multiplicationResult = temp1 * 2;
        Console.WriteLine($"Multiplication: {multiplicationResult.ToCelsius()}°C"); // Multiplication: 50°C

        // Comparaison de temp1 avec temp2
        if (temp1 >= temp2) {
            Console.WriteLine("Temp1 est supérieure ou égale à Temp2");
        } else {
            Console.WriteLine("Temp1 est inférieure à Temp2");
        } // Temp1 est supérieure ou égale à Temp2
    }
}

La question de la conversion ne se pose qu'en début de use case et en fin de use case - dans notre exemple simpliste, en début de méthode et en fin de méthode. Dans le cœur des traitements, nous sommes content de ne traiter que des températures, aucune charge cognitive n'est nécessaire afin de savoir en quelle unité on travaille, ce qui se passe en générale quand les experts métiers font leur calculs.

Profitant de l'ajout de ces opérateurs, abordons un aspect crucial des value objects qui n'a pas encore été traité : l'égalité basée sur les valeurs. Je suggère de réaliser cette implémentation en utilisant la bibliothèque Value. Vous devez d'abord ajouter cette bibliothèque à votre projet. Si vous utilisez .NET Core ou .NET Framework, vous pouvez le faire via la ligne de commande ou votre IDE préféré. Par exemple, en utilisant la ligne de commande dotnet :

dotnet add package Value

Pour intégrer l'égalité basée sur les valeurs en utilisant la bibliothèque Value, il suffit d'adapter la classe Temperature pour hériter de ValueType<Temperature>:

public sealed class Temperature : ValueType<Temperature> {

    private readonly double _value;

    // ....

    protected override IEnumerable<object> GetAllAttributesToBeUsedForEquality() {
        return new object[] { _value };
    }

}

Amélioration de la précision

Nous utilisons actuellement le type primitif double comme base pour notre implémentation. Cependant, il est facile d'imaginer des scénarios où les arrondis et la précision des calculs pourraient poser problème. L'avantage de notre encapsulation et des méthodes que nous avons intégrées est qu'elle nous permet, si nécessaire, de remplacer ce type primitif par un decimal pour une précision accrue. Ainsi, dans le cadre de processus complexes, les calculs s'appuieraient sur des decimal. Les conversions vers et depuis le type double ne seraient nécessaires qu'aux points d'entrée et de sortie du cas d'utilisation, où les questions de précision sont moins critiques.

using System;
using System.Collections.Generic;
using Value;

public sealed class Temperature : ValueType<Temperature> {

    private readonly decimal _value; // Utilisation de decimal pour une meilleure précision

    private Temperature(decimal value) {
        if (value < 0) {
            throw new ArgumentException("La température ne peut pas être inférieure au zéro absolu (0 Kelvin)."); }

        _value = value;
    }

    public static Temperature FromKelvin(double kelvin) => new Temperature((decimal)kelvin);
    public static Temperature FromCelsius(double celsius) => new Temperature((decimal)celsius + 273.15m);
    public static Temperature FromFahrenheit(double fahrenheit) => new Temperature(((decimal)fahrenheit - 32) * 5/9 + 273.15m);

    // Méthodes de conversion d'instance retournant des valeurs double
    public double ToKelvin() => (double)_value;
    public double ToCelsius() => (double)(_value - 273.15m);
    public double ToFahrenheit() => (double)((_value - 273.15m) * 9/5 + 32);

    // ...

    protected override IEnumerable<object> GetAllAttributesToBeUsedForEquality() {
        return new List<object>() { _value };
    }

}

Quid du zéro absolu ?

Le zéro absolu est un élément important dans notre domaine, rendre sa présence explicite est extrêmement pertinente.

public sealed class Temperature: ValueType<Temperature> {

    private const decimal ABSOLUTE_ZERO_KELVIN = 0;

    public static readonly Temperature AbsolutZero = Temperature.FromKelvin(ABSOLUTE_ZERO_KELVIN);
    //public static readonly Temperature MaxValue = new Temperature(decimal.MaxValue);

    private Temperature(decimal value) {
        if (value < ABSOLUTE_ZERO_KELVIN) {
            throw new ArgumentException("La température ne peut pas être inférieure au zéro absolu."); }

        _value = value;
    }
}

Plutôt que d'utiliser le classique nommage MinValue de .Net il est important d'utiliser un nom de domaine, ici AbsolutZero. Il est également envisageable de proposer la valeur MaxValue (technique quant à elle) si nécessaire.

Implémentation finale

Avant de finaliser et soumettre ma version, qui pourrait différer de la vôtre pour s'adapter spécifiquement à votre secteur, une ultime révision est nécessaire.

Comme discuté précédemment, lors de l'établissement d'une température, il est impératif d'empêcher l'assignation d'une valeur en dessous du zéro absolu, pour lequel nous avons implémenté une exception. Toutefois, dans le cadre de nos opérations, la soustraction de 20 K à une température initiale de 15 K ne devrait pas déclencher cette exception, du moins dans mon champ d'application. Cette opération est envisageable et le résultat devrait être limité à 0 K.

Vous avez également pu remarquer l'utilisation de magic numbers dans le code. C'est quelque chose qui ne devrait pas exister. Ces valeurs ont une signification précise dans notre domaine. Explicitons-le!

Enfin, pour simplifier ce code, je ne vais plus traité avec les double.

Avec ces dernières modifications nous avons désormais une implémentation assez complète de notre value object Temperature. Il pourra être enrichi si nécessaire par d'autres méthodes tout en faisant attention de ne pas cassé l'immutabilité ou/et l'encapsulation. Voici le code complet:

using System;
using System.Collections.Generic;
using Value;

public sealed class Temperature : ValueType<Temperature> {

    private const decimal ABSOLUTE_ZERO_KELVIN                  = 0;
    private const decimal CELSIUS_TO_KELVIN_CONVERSION_POINT    = 273.15m;
    private const decimal FAHRENHEIT_TO_CELSIUS_RATIO           = 5m / 9m;
    private const decimal FREEZING_POINT_OF_WATER_IN_FAHRENHEIT = 32m;

    public static readonly Temperature AbsolutZero = new(ABSOLUTE_ZERO_KELVIN);
    public static readonly Temperature MaxValue    = new(decimal.MaxValue);

    public static Temperature FromKelvin(decimal kelvin) {
        decimal kelvinDecimal = kelvin;
        AssertIsGreaterThanAbsoluteZero(kelvinDecimal);

        return new Temperature(kelvinDecimal);
    }

    public static Temperature FromCelsius(decimal celsius) {
        decimal kelvin = celsius + CELSIUS_TO_KELVIN_CONVERSION_POINT;
        AssertIsGreaterThanAbsoluteZero(kelvin);

        return new Temperature(kelvin);
    }

    public static Temperature FromFahrenheit(decimal fahrenheit) {
        decimal celsius = (fahrenheit - FREEZING_POINT_OF_WATER_IN_FAHRENHEIT) * FAHRENHEIT_TO_CELSIUS_RATIO;
        decimal kelvin  = celsius + CELSIUS_TO_KELVIN_CONVERSION_POINT;
        AssertIsGreaterThanAbsoluteZero(kelvin);

        return new Temperature(kelvin);
    }

    private static void AssertIsGreaterThanAbsoluteZero(decimal kelvin) {
        if (kelvin < ABSOLUTE_ZERO_KELVIN) { throw new ArgumentException("La température ne peut pas être inférieure au zéro absolu."); }
    }

    public static bool operator >(Temperature a, Temperature b) {
        return a._value > b._value;
    }

    public static bool operator <(Temperature a, Temperature b) {
        return a._value < b._value;
    }

    public static bool operator >=(Temperature a, Temperature b) {
        return a._value >= b._value;
    }

    public static bool operator <=(Temperature a, Temperature b) {
        return a._value <= b._value;
    }

    public static Temperature operator +(Temperature a, Temperature b) {
        return new Temperature(a._value + b._value);
    }

    public static Temperature operator -(Temperature a, Temperature b) {
        return new Temperature(a._value - b._value);
    }

    public static Temperature operator *(Temperature a, decimal b) {
        return new Temperature(a._value * b);
    }

    public static Temperature operator /(Temperature a, decimal b) {
        return new Temperature(a._value / b);
    }

    private readonly decimal _value;

    private Temperature(decimal value) {
        _value = value <= ABSOLUTE_ZERO_KELVIN ? ABSOLUTE_ZERO_KELVIN : value;
    }

    public decimal ToKelvin() {
        return _value;
    }

    public decimal ToCelsius() {
        return _value - CELSIUS_TO_KELVIN_CONVERSION_POINT;
    }

    public decimal ToFahrenheit() {
        return (_value - CELSIUS_TO_KELVIN_CONVERSION_POINT) / FAHRENHEIT_TO_CELSIUS_RATIO + FREEZING_POINT_OF_WATER_IN_FAHRENHEIT;
    }

    public override string ToString() {
        return $"{_value} K";
    }

    protected override IEnumerable<object> GetAllAttributesToBeUsedForEquality() {
        return new List<object> { _value };
    }

}

NOTE

Dans cet article, j'ai structuré le développement et l'évolution du type Temperature pour illustrer les problèmes rencontrés et comment les résoudre étape par étape.

Lorsque je développe, j'utilise une méthode de développement dirigée par les tests ( TDD) particulièrement efficace et simple à mettre en œuvre avec des objets de type valeur.

Si vous les utilisez dans vos projets, vous constaterez rapidement que leur utilisation offre des avantages considérables. Ils ont la capacité de saisir une grande partie de la complexité et de la redondance d'un système, contribuant à sa structuration. En conséquence, votre code devient nettement plus simple. Lorsque ces objets sont utilisés conjointement avec des tests unitaires, le nombre de bugs diminue également de façon significative.

Retour au cas d'utilisation

Différentes options

Comme mentionné plus haut dans l'article, l'accès direct à la valeur sous-jacente d'un objet valeur est déconseillé, ce qui explique pourquoi la valeur décimale est cachée et uniquement accessible via des méthodes d'exportation spécifiant les unités. Cette approche présente plusieurs avantages, notamment en termes de sécurité et de maintenabilité du code.

Cependant, pour effectuer des calculs impliquant des types différents comme dans notre cas V = nRT/P où je rappelle que T est la température, P la pression, R la constante universelle des gaz parfaits en J/(mol·K) et n le nombre de moles. on risque de devoir souvent exposer finalement cette valeur.

Plusieurs options sont envisageables.

Déjà, étant donné notre décision de représenter la température comme un objet valeur dans notre contexte complexe, il est probable que nous souhaiterions appliquer la même approche à la pression et au volume. L'idée est d'encapsuler la valeur interne au sein d'un objet à typage fort pour mieux contrôler ses invariants – par exemple, empêcher l'établissement d'une température en dessous du zéro absolu –, gérer les conversions et assurer l'utilisation de l'unité appropriée, entre autres aspects.

Première option

Pour éviter de créer de nouveaux objets valeur, n et R sont de simples décimaux. Dans ce cas, on va casser notre typage par endroit. On essayera de limiter les endroits où cela se passe et seront conscient, à ces endroits particulier du code que le retour aux types primitifs impose plus d'attention.

Seconde option

Allons jusqu'au bout des choses et créons des types qui représentent les concepts "intermédiaire" comme un nombre de mole. Il possède déjà un invariant simple, il ne peut être négatif. Plus simple que la température il ne possède qu'une unité.

Il est également envisageable de définir un type représentant les joules par mole kelvin. Cependant, prudence est de mise ! La création d'un tel type doit être justifiée par une logique métier claire, et il n'est pas question de lui attribuer un nom arbitraire choisi uniquement par nous, les développeurs. Les experts du domaine concerné doivent être consultés pour s'accorder sur un nom approprié pour ce concept. Si l'idée s'avère dénuée de pertinence métier, mieux vaut l'abandonner. En revanche, si elle est jugée utile, alors allez-y.

Utilisation

Reprenons le code de notre calculateur avec un exemple hybride dans lequel certains types ont été mise en value object et d'autres non:

public static class GasVolumeCalculator {

    // Calcul du volume d'un gaz parfait à partir de P, n, et T
    public static double Calculate(Pressure pressure, Mole moles, Temperature temperature) {
        ThermalEnergy energy = temperature.GetEnergyFor(moles);
        Volume volume = energy / pressure;

        return volume;
    }
}

avec

public sealed class Temperature: Value<Temperature> {

    // ...

    public ThermalEnergy GetEnergyFor(Mole moles) {
        decimal thermalEnergyDecimal = (decimal)moles * Constant.R * _temperature;

        return new ThermalEnergy(thermalEnergyDecimal);
    }

}

En adoptant des types fortement typés et en développant un langage spécifique au domaine (DSL), le code gagne en clarté et en expressivité

NOTE

Un Domain-Specific Language (DSL) est une forme de langage informatique conçue pour cibler un type particulier de problème ou pour être utilisée dans un contexte d'application spécifique. Contrairement aux langages de programmation généraux comme C# ou Java, qui sont conçus pour être polyvalents et couvrir un large éventail de problèmes de programmation (dit langages impératifs), un DSL (langage déclaratif) est optimisé pour un ensemble restreint de tâches et vise à simplifier la programmation dans un domaine spécifique.

Dans notre cas, les objets valeur, grâce à leur encapsulation et à leurs méthodes, constituent notre DSL. Ce code n'est pas conçu pour manipuler les températures, les énergies, les pressions, etc., de manière universelle, mais de façon adapté à notre domaine d'activité. Les optimisations et la structure même du modèle pourraient paraître étranges à une autre équipe spécialisée dans un domaine différent traitant également de pression et de volume, mais elles sont parfaitement ajustées aux besoins spécifiques du domaine sur lequel nous nous concentrons.

Conclusion

Les value objects représentent un pilier fondamental dans la capture de la complexité d'une application. Ils permettent de rendre explicite la logique métier, facilitant ainsi la maintenance du code et réduisant les erreurs.

La création de ces objets ne relève pas uniquement du domaine des développeurs, mais nécessite également une implication active des experts métier. Leur expertise est cruciale pour définir et valider le modèle de domaine, que ce soit dans le cadre de DDD, de méthodologies agiles, ou autres. Le degré de suppression des types primitifs dépend donc étroitement de cette collaboration, et il est essentiel de ne pas introduire de concepts artificiels dans le modèle sans validation préalable des experts. Toutefois, il est également pertinent de remettre en question ces mêmes experts, car parfois, certains concepts implicites pour eux peuvent être clarifiés et améliorés dans le processus de digitalisation.

J'espère que cet article vous a fourni des pistes de réflexion utiles. N'hésitez pas à intégrer les value objects dans votre code pour des données variées telles que les montants, les pourcentages, les ratios, les devises, etc. Toutefois, gardez à l'esprit que l'utilisation des value objects ne consiste pas simplement à satisfaire une préférence personnelle, mais à répondre à un besoin métier spécifique.

Références