I. Du coup, les objets c'est bien ?▲
Pour dire vrai, ça dépend : lorsqu'on veut utiliser les fonctionnalités d'un framework manipulant des données et ne sachant travailler qu'avec des « objets » exposant tous leurs attributs il faut admettre que ce n'est pas idéal…
Faire des objets sera bien plus pertinent si l’on veut :
- avoir un code qui représente clairement le métier pour lequel il a été écrit ;
- assurer la cohérence des états de nos objets métier ;
- faire des traitements métier ;
- assurer la pérennité d'une solution en limitant les effets de bord, en facilitant la compréhension, en autorisant les refactorings et en permettant des évolutions sans chute de vélocité.
Pour bénéficier de ces avantages, il faut :
- assurer la cohérence de nos objets ;
- faire des méthodes effectuant des traitements métier ;
- passer d'objets adaptés à un besoin technique donné aux objets représentant notre métier ;
- designer notre code.
II. Assurer la cohérence de nos objets▲
Coder des objets qui peuvent être dans des états qui n'existent pas est une très mauvaise idée ! Nos objets ne doivent pouvoir être que dans des états qu'ils savent correctement traiter, et ça commence dès la construction.
II-A. Faire des types▲
La première manière d'éviter des situations qu'on ne saura pas traiter est de faire des types. Si l’on utilise essentiellement des primitives, alors on doit accepter toutes les valeurs possibles de ces primitives : un dictionnaire Klingon est une valeur tout à fait acceptable pour une String.
En faisant des types, on va améliorer la sémantique et s'assurer qu'on reçoit le bon type de données, mais on facilitera aussi l'utilisation de nos API (il n'est jamais évident d'invoquer une méthode qui prend 3 Booleans et 2 Strings…).
En fait, vouloir faire ces traitements en se basant essentiellement sur des types primitifs est un antipattern très répandu (et extrêmement nuisible) : primitive obsession. Faire apparaître des types est un refactoring très simple à faire et qui vous apportera rapidement beaucoup.
Vous pouvez essayer de faire un objet Username qui contiendra l'identifiant de votre utilisateur connecté et utiliser cet objet plutôt qu'une simple String. De cette manière, vous pourrez simplement vous assurer de ne pas logger le vrai identifiant de votre utilisateur, mais une version non lisible en changeant l'implémentation de toString() (vous vous faciliterez ainsi le droit à l'oubli en n'ayant pas à parser vos logs).
II-B. Vérifier les données en entrée▲
Un autre point essentiel pour assurer la cohérence est de vérifier les données en entrée de nos objets (que ce soit lors de la construction ou lors du passage des paramètres de methods).
Je ne peux que vous conseiller de vous fabriquer une petite API d'assertions (qui sera plus ou moins complexe à faire en fonction du langage et de vos besoins). Je vous conseille aussi de créer une API fluent qui facilitera les multiples contrôles sur un même champ.
Par exemple, on peut imaginer faire ces contrôles dans un constructeur :
Assert.notNull
(
"id"
, id);
Assert.notBlank
(
"key"
, key);
Assert.field
(
"name"
, name).notBlank
(
).maxLength
(
100
);
Pour ce faire, on aurait la class Assert suivante :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
37.
38.
39.
40.
41.
42.
43.
44.
45.
46.
public
final
class
Assert {
private
Assert
(
) {}
public
static
void
notNull
(
String fieldName, Object input) {
if
(
input ==
null
) {
throw
MissingMandatoryValueException.forNullValue
(
fieldName);
}
}
public
static
void
notBlank
(
String fieldName, String input) {
if
(
input ==
null
) {
throw
MissingMandatoryValueException.forNullValue
(
fieldName);
}
if
(
input.isBlank
(
)) {
throw
MissingMandatoryValueException.forBlankValue
(
fieldName);
}
}
public
static
StringAsserter field
(
String fieldName, String value) {
return
new
StringAsserter
(
fieldName, value);
}
public
static
class
StringAsserter {
private
final
String fieldName;
private
final
String value;
private
StringAsserter
(
String fieldName, String value) {
this
.fieldName =
fieldName;
this
.value =
value;
}
public
StringAsserter notBlank
(
) {
Assert.notBlank
(
fieldName, value);
return
this
;
}
public
StringAsserter maxLength
(
int
maxLength) {
if
(
value !=
null
&&
value.length
(
) >
maxLength) {
throw
StringSizeExceededException
.builder
(
)
.field
(
fieldName)
.currentLength
(
value.length
(
))
.maxLength
(
maxLength).build
(
);
}
return
this
;
}
}
}
Les exceptions pour cet exemple sont basées sur Problem (de Zalando) :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
public
class
MissingMandatoryValueException extends
AbstractThrowableProblem {
private
MissingMandatoryValueException
(
String message) {
super
(
ErrorConstants.DEFAULT_TYPE, message, Status.INTERNAL_SERVER_ERROR);
}
public
static
MissingMandatoryValueException forNullValue
(
String fieldName) {
return
new
MissingMandatoryValueException
(
defaultMessage
(
fieldName) +
" (null)"
);
}
public
static
MissingMandatoryValueException forEmptyValue
(
String fieldName) {
return
new
MissingMandatoryValueException
(
defaultMessage
(
fieldName) +
" (empty)"
);
}
public
static
MissingMandatoryValueException forBlankValue
(
String fieldName) {
return
new
MissingMandatoryValueException
(
defaultMessage
(
fieldName) +
" (blank)"
);
}
private
static
String defaultMessage
(
String fieldName) {
return
"The field
\"
"
+
fieldName +
"
\"
is mandatory and wasn't set"
;
}
}
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
public
class
StringSizeExceededException extends
AbstractThrowableProblem {
private
StringSizeExceededException
(
StringSizeExceededExceptionBuilder builder) {
super
(
ErrorConstants.DEFAULT_TYPE, builder.message
(
), Status.INTERNAL_SERVER_ERROR);
}
public
static
StringSizeExceededExceptionBuilder builder
(
) {
return
new
StringSizeExceededExceptionBuilder
(
);
}
static
class
StringSizeExceededExceptionBuilder {
private
String field;
private
int
currentLength;
private
int
maxLength;
public
StringSizeExceededExceptionBuilder field
(
String field) {
this
.field =
field;
return
this
;
}
public
StringSizeExceededExceptionBuilder currentLength
(
int
currentLength) {
this
.currentLength =
currentLength;
return
this
;
}
public
StringSizeExceededExceptionBuilder maxLength
(
int
maxLength) {
this
.maxLength =
maxLength;
return
this
;
}
private
String message
(
) {
return
"Length of
\"
"
+
field +
"
\"
must be under "
+
maxLength +
" but was "
+
currentLength;
}
public
StringSizeExceededException build
(
) {
return
new
StringSizeExceededException
(
this
);
}
}
}
Un autre intérêt de la class Assert ici est de normaliser les exceptions et leurs messages, on pourra ainsi simplement les adapter en fonction des besoins.
Vous l'avez certainement remarqué, mais différents Design Patterns de construction (static factory et builder en l'occurrence) ont été utilisés dans les exemples précédents : c'est aussi un moyen très important d'assurer la cohérence en ne permettant la construction d'un objet que lorsque tous les éléments sont renseignés !
Un autre point essentiel : nos objets ne renvoient pas de valeurs null, on renverra des Collections vides ou des Optional vides pour les objets qui peuvent être ne pas être renseignés !
II-C. Faire des objets immuables▲
Assurer la cohérence d'un objet est relativement simple si on sait que ses différents attributs ne peuvent pas changer. En revanche, quand tous les objets changent, assurer la cohérence de chacun d'entre eux peut être très compliqué.
Pour éviter cette complexité, le plus simple est encore de ne permettre les modifications que lorsque c'est nécessaire et de faire des objets immuables dans tous les autres cas.
Cependant, en Java, l'API Collection ne nous aide pas vraiment, car elle ne présente pas d'interface permettant simplement la consultation des Collections. Nous sommes alors obligés de casser le Principe de Substitution de Liskov en utilisant Collections.unmodifiableXXX(…) (ce qui reste un moindre mal).
Dans tous les cas, faire des types immuables est souvent une très bonne solution pour éviter des effets de bord indésirables (il est par exemple peu probable que la mise à jour du Username d'un User n'ai pas d'autres impacts qu'un simple changement dans User).
III. Faire des traitements métier▲
Dès lors que nos objets assurent leur cohérence, on peut commencer à faire des traitements métier et non pas uniquement de la manipulation de données !
L’une des premières étapes essentielles pour faire des traitements métier est d'exposer des methods qui explicitent ce qui est fait, pas comment c'est fait. Dans cette optique, on trouvera vraiment très peu de setters dans nos objets métier : changer une valeur est rarement un traitement métier !
Exposer des opérations fonctionnelles et non pas techniques facilitera grandement :
- l'utilisation des API ;
- la compréhension du métier pour les nouveaux arrivants ;
- le refactoring : on fait toujours la même opération, mais d'une manière tout à fait différente (cela n'affecte pas les appelants) ;
- la réponse aux changements métier : comme on expose des opérations métier, un petit changement métier se traduira dans la grande majorité des cas par un petit changement dans le code (et non pas une refonte de l'application).
Les traitements métier faits dans nos objets ont très souvent besoin de déclencher des actions ailleurs dans notre produit. Par exemple, la validation d'un compte utilisateur va modifier l'objet représentant cet utilisateur, mais on peut vouloir lui envoyer un courriel ou changer ses rôles dans le système de gestion des droits. Ces actions peuvent être vues comme des événements qui vont être envoyés pour être traités par des handlers dédiés. Cependant, la question de la propagation de ces événements se pose souvent, plusieurs stratégies sont alors envisageables.
- Injecter un objet permettant de faire cette propagation lors de la construction de notre objet métier. Dans la majorité des cas, cette stratégie n'est pas très bonne, car on met un élément purement technique dans quelque chose représentant du métier. De plus, il est fort probable qu'on ne se serve de cet outil de broadcast que dans quelques traitements (compliquant alors la construction dans tous les cas là où il ne sera utile que dans des cas bien précis).
- On peut aussi injecter cet objet permettant la propagation en paramètre des méthodes qui en ont besoin (en vérifiant toujours sa présence). De cette manière, c'est notre objet métier qui va assurer la construction des événements et leur diffusion. C'est une stratégie tout à fait viable !
- On peut enfin renvoyer un événement comme résultat de notre traitement métier et laisser une couche d'orchestration (qui fera l'appel pour le traitement métier) faire la propagation de l'événement. Cette stratégie est aussi viable et permet de ne pas du tout ajouter de dépendance à des éléments techniques dans nos objets métier.
Les événements dont il est question ici sont des objets métier comme les autres. Ils décrivent ce qui s'est passé. Ils sont forcément immuables (un événement est quelque chose qui s'est passé, il ne peut pas être modifié).
IV. Passer d'une version à une autre▲
Maintenant que nos objets métier assurent leur cohérence et font vraiment des traitements métier, nous avons développé la majorité de la valeur de notre solution. Cependant, on devra bien souvent transformer ces objets pour les rendre persistants ou pour les exposer à l'extérieur.
Il existe plusieurs stratégies pour faire ces mappings. Après avoir passé quelques années à tester différentes stratégies, ma favorite est maintenant de faire les conversions dans les objets dédiés à un usage particulier (par exemple une exposition REST ou une persistance en utilisant un ORM). Avec une class Branch dans mon code métier et sa version pour l'exposition RestBranch, j'aurai :
2.
3.
4.
5.
6.
7.
8.
9.
10.
class
RestBranch {
// Fields and constructor
static
RestBranch from
(
Branch branch) {
// Build a RestBranch from a Branch
}
// Getters to expose my JSON model
Branch toDomain
(
...) {
// Sometimes i need extra parameters to build my domain here
// Build a Branch from a RestBranch and extra parameters
}
}
Je n'ai jamais été convaincu par les différents frameworks de mapping automatique. Dans tous les cas, on teste ces mappings et je trouve qu'on passe plus de temps à comprendre pourquoi tel ou tel mapping ne se fait pas qu'à gagner du temps à ne pas écrire une ligne de code. Certains de ces Frameworks ne fonctionnent pas avec les visibilités package alors que c'est typiquement le besoin que j'ai sur ce type d'objets qui sont souvent dans un package dédié à une feature et un usage donné.
V. Designer notre code▲
Tout ça n'a pas d'intérêt si on garde un design pauvre de notre code. Là aussi, j'ai essayé plein de méthodes de design. Peu de temps après ma sortie d'école, je soutenais même qu'il ne fallait surtout pas se lancer dans l'écriture du code sans avoir pensé un design sur papier (ou tableau) !
J'ai maintenant une approche différente (même si je me remets au tableau de temps en temps). En fait, en faisant un design au tableau, on ne peut avoir qu'une vision d'ensemble, on va forcément oublier des choses qui seront nécessaires pour l'implémentation ou certains cas qu'on ne verra que pendant la réalisation.
Ma méthode de design favorite est actuellement le TDD : eh oui, le TDD est avant tout une méthode de design et non pas une méthode de test ! Pour rappel, le TDD est très simple à définir, c'est un cycle en 3 étapes :
Pendant la phase RED, on écrit un premier test qui ne passe pas (si il ne compile pas, il ne passe pas).
Pendant la phase GREEN, on écrit le code le plus direct permettant à notre test de passer (sans casser les tests précédents).
Pendant la phase REFACTOR, on travaille notre code pour qu'il soit plus élégant, qu'il exprime mieux le métier, qu'il apporte une meilleure réponse…
Ce cycle se répète toutes les quelques secondes ou minutes (on en fait plusieurs centaines dans une journée). Ces cycles permettent l'émergence du design. De cette manière, notre code sera bien meilleur qu'un code désigné au tableau puisque :
- on prendra en compte tous les cas, qu'ils soient métier ou techniques ;
- on répondra clairement au besoin en étant capable d'invoquer simplement nos méthodes (on ne peut pas faire la phase RED sinon) ;
- on n'aura pas de code superflu ou inutile : si on n'a pas de test RED, on n'écrit pas de nouveau code !
Cependant, même si cette manière de travailler se résume en quelques minutes, sa maîtrise prendra beaucoup plus de temps.
VI. Vivre dans un monde d'objets▲
Quand on commence à faire des objets dans nos applications, il est difficile de revenir en arrière tant les apports sont évidents au quotidien :
- vélocité ;
- qualité ;
- facilité d'évolution ;
- compréhension des traitements ;
- …
On ressent alors rapidement le besoin de mettre en place une Clean Architecture, mais ça, c'est une autre histoire…
VII. Remerciements▲
Cet article a été publié avec l'aimable autorisation de Colin Damon. L'article original peut être vu sur le blog de la société Ippon.
Nous remercions également Winjerome pour la mise au gabarit et escartefigue pour la relecture orthographique de cet article.