Afin d’introduire le pattern du value object nous allons nous plonger dans une situation où son utilisation est pertinente en présentant en premier lieu les problèmes que celui-ci résout.
I. L’espace du problème
Aujourd’hui, le product owner vient de te confier ta première tâche en autonomie.
Lorsque tu es entré dans cette entreprise d’agence de voyage, tu ne pensais pas que ça irait aussi vite. Mais ça y est tu dois ajouter une fonctionnalité, plutôt simple d’après le PO, qui consiste à envoyer une notification par sms aux voyageurs qui viennent de fournir leur numéro de téléphone sur leur réservation.
Effectivement ça semble plutôt simple, mais tu ne t’emballes pas car tu ne sais pas sur quel genre de code base tu vas tomber.
Heureusement un de tes collègues t’a indiqué le fichier où ajouter cette notification, alors c’est parti tu mets les mains dans le code et tombes sur ce use case:
class AddTravelerPhoneToBooking{ constructor(private repository: BookingRepository) {} public async execute(phoneNumber: string) { const booking = await this.repository.getBooking(); booking.addTravelerPhoneNumber(phoneNumber); await this.repository.save(booking); } }
Finalement tu es rassuré c’est clean et droit au but.
Ton collègue t’a également fait part d’une classe SmsGateway
que tu pouvais utiliser pour envoyer le sms, toute la tuyauterie est déjà codée !
class AddTravelerPhoneToBooking{ constructor(private repository: BookingRepository, private sms: SmsGateway) {} public async execute(phoneNumber: string) { await sms.send(phoneNumber, 'Successfully added your phone number'); const booking = await this.repository.getBooking(); booking.addTravelerPhoneNumber(phoneNumber); await this.repository.save(booking); } }
La gateway est injectée et appelée: le tour est joué ! 💪
…mais non, l’appli plante avec une erreur qui ressemble à SmsGateway Error: phone number is invalid, can’t send sms 💀
.
Un voyageur a dû renseigner n’importe quoi dans ses informations, on va ajouter un check.
class AddTravelerPhoneToBooking{ constructor(private repository: BookingRepository, private sms: SmsGateway) {} public async execute(phoneNumber: string) { if (isValidPhoneNumber(phoneNumber) === false) throw new InvalidPhoneNumber(); await sms.send(phoneNumber, 'Successfully received your phone number'); const booking = await this.repository.getBooking(); booking.addTravelerPhoneNumber(phoneNumber); await this.repository.save(booking); } }
C’est alors que ton collègue te dit que cette validation du numéro est déjà présente dans la classe Booking !
class Booking { // ... addTravelerPhoneNumber(phoneNumber: string) { if (isValidPhoneNumber(phoneNumber) === false) { throw new InvalidPhoneNumber(); } this.travelerPhoneNumber = phoneNumber; } }
Voilà que de la duplication commence à pointer le bout de son nez…
Et puis tu réfléchis… Tu es bien embêté. Comment savoir si une donnée a déjà été validée ? Qui est responsable cette validation: le use case ? le modèle Booking ? ou peut-être qu’il aurait fallu le valider avant même d’appeler le use case ?
C’est là qu’intervient notre héros du jour: le value object.
II. L’espace de la solution
Il s’agit d’une classe qui va représenter une donnée, sera responsable de sa cohérence et de son comportement.
Allez c’est partit:
expect(new PhoneNumber('0695545434').toString()).toEqual("0695545434"); expect(new PhoneNumber('toto@mail.io')).toThrow(InvalidPhoneNumber);
class PhoneNumber { constructor(private number: string) { if (isValidPhoneNumber(number) === false) { throw new InvalidPhoneNumber(); } } toString = () => this.number; }
Facile !
Maintenant où est-ce qu’on retrouve ce value object ? Dans l’idée il faut que la donnée soit validée AVANT de pénétrer la logique métier. Ici le point d’entrée de notre logique métier c’est notre use case, on va alors instancier notre value object juste avant: dans notre controller.
// AVANT async controller(phoneNumber: string) { const useCase = new AddTravelerPhoneToBooking(this.repository, this.sms); return useCase.execute(phoneNumber); }
// APRES async controller(phoneNumber: string) { const useCase = new AddTravelerPhoneToBooking(this.repository, this.sms); return useCase.execute(new PhoneNumber(phoneNumber)); }
L’instancier ici nous permet d’appliquer le principe de Fail Fast qui consiste à remonter l’erreur le plus tôt possible.
Ce qui est beau c’est qu’on peut désormais supprimer la validation du numéro de téléphone du use case AddTravelerPhoneToBooking et celle du modèle Booking.
Tout fonctionne mais le PO te remonte que tu as oublié qu’on recevait également l’indicatif international du voyageur !
Là tu te dis: facile je connais la technique maintenant, je n’ai qu’à faire un nouveau value object qui vérifiera le format de l’indicatif et le tour le joué. Mais pas si vite! Pas besoin d’en créer un nouveau, les value objects peuvent contenir autant de données que nécessaires tant qu’elles constituent un ensemble cohérent.
On y retourne.
expect(new PhoneNumber('+33', '695545434').toString()).toEqual("+33695545434"); expect(new PhoneNumber('+33', 'toto@mail.io')).toThrow(InvalidPhoneNumber); expect(new PhoneNumber('john doe', '695545434')).toThrow(InvalidInternationalCode);
class PhoneNumber { constructor(private code: string, private number: string) { if (isValidInternationalCode(code) === false) { throw new InvalidInternationalCode(code); } if (isValidPhoneNumber(number) === false) { throw new InvalidPhoneNumber(); } } toString = () => `${this.code}${this.number}`; }
On avance ! Mais il faut savoir que les value objects sont défini par la somme de leurs attributs et non par référence.
Puisque ici on code notre value object en typescript on va devoir s’armer d’une méthode equal
.
const phone = new PhoneNumber('+33','695545434'); const phoneCopy = new PhoneNumber('+33','695545434'); expect(phone === phoneCopy).toBe(false); expect(phone.equals(phoneCopy)).toBe(true);
class PhoneNumber { // ... equals = (other: PhoneNumber) => this.code === other.code && this.number === other.number; }
Une autre de leurs caractéristiques est qu’ils sont immuables, on ne peut pas changer leurs données.
En effet un value object étant défini par ses données, si on change ne serait-ce qu’un chiffre d’un numéro de téléphone ou si on modifie son code international il s’agit alors d’un numéro totalement différent. Pour représenter cela nous allons créer une nouvelle instance de l’objet à chaque modification.
const frenchPhone = new PhoneNumber('+33','695545434'); const koreanPhone = frenchPhone.changeCountryCode('+82'); expect(frenchPhone.toString()).toEqual("+33695545434"); expect(koreanPhone.toString()).toEqual("+82695545434");
class PhoneNumber { // ... changeCountryCode = (code: string) => new PhoneNumber(code, this.number); }
III. Conclusion sur le value object
À travers ce scénario on a vu que les value objects permettent de rassembler une donnée et ses comportements au même endroit et ainsi augmenter la cohésion du code pour réduire sa complexité.
Mais ce pattern permet également d’introduire le language du métier dans le code. C’est un bénéfice qui a été fortement mis en avant par les adeptes du Domain Driven Design. En effet en restant dans le contexte du métier d’agence de voyages on peut facilement imaginer des value objects comme reservation number, departure date, travel duration… Des termes qui seront communs aux développeurs et aux sachants métier.
Pour conclure je dirais que le value object possède un excellent rapport qualité-prix: il coûte peu à mettre en place et permet d’apporter beaucoup de valeur au projet. L’essayer c’est l’adopter, alors donne lui sa chance 😉
Leave a Reply