À l'aide de T.D.D et du Strangler pattern, refactorer le Use CaseTirer afin que la signature de Handle ressemble à :
TRequest -> Either<Error, TResponse>
soit Commands.Tirer -> Either<Error, VoidResponse>
On limitera le type Error à 1 message décrivant l'erreur qui s'est produite
Le Use Case : Tirer
Pour implémenter notre méthode nous allons repartir de la Test List actuelle :
Échoue car :
- La partie n'existe pas
- Le chasseur n'a plus de balle
- Le chasseur n'est pas dans la partie
- Les chasseurs sont à l'apéro
- La partie de chasse est terminée
Réussi pour :
- Un chassseur présent dans la partie et lui restant des balles
On y décrit nos attentes vis-à vis de la future méthode
[Fact]publicvoidCarPartieNexistePasSansException(){ // TODO extract to Given When Then methods // Arrangevar partieDeChasseId =UnePartieDeChasseInexistante(); // Actvar result =_useCase.HandleSansException(newDomain.Commands.Tirer(partieDeChasseId,Data.Bernard)); // Assertresult.Should().BeLeft(); // Par convention Left contient le cas d'erreurresult.Left().Should().Be($"La partie de chasse {partieDeChasseId} n'existe pas");SavedPartieDeChasse().Should().BeNull();}
On ne compile pas et donc le test échoue
On génère depuis le test le code de la méthode HandleSansException
public Either<Error, VoidResponse> HandleSansException(Domain.Commands.Tirer command) => throw new NotImplementedException();
On ajoute les références nous permettant d'utiliser des monades existantes : LanguageExt
On "fixe" les assertions du test pour pouvoir compiler
[Fact]publicvoidCarPartieNexistePasSansException(){ // TODO extract to Given When Then methods // Arrangevar partieDeChasseId =UnePartieDeChasseInexistante(); // Actvar result =_useCase.HandleSansException(newDomain.Commands.Tirer(partieDeChasseId,Data.Bernard)); // Assertresult.Should().BeLeft(); // Par convention Left contient le cas d'erreurresult.IfLeft(error => {error.Message.Should().Be($"La partie de chasse {partieDeChasseId} n'existe pas");SavedPartieDeChasse().Should().BeNull(); });}
On est maintenant au rouge pour une bonne raison
public Either<Error, VoidResponse> HandleSansException(Domain.Commands.Tirer command) => new Error($"La partie de chasse {command.PartieDeChasseId} n'existe pas");
Ici on peut retourner directement 1 Error grâce à l'import de namespace ci-dessous et l'implicit conversion :
usingstaticLanguageExt.Prelude;
Peut-être, faire une Factory Method pour l'instantiation des Error
public Either<Error, VoidResponse> HandleSansException(Domain.Commands.Tirer command) => AnError($"La partie de chasse {command.PartieDeChasseId} n'existe pas");
publicrecordError{publicstring Message { get; }privateError(string message) => Message = message;publicstaticErrorAnError(string message) =>new(message);}
On peut également refactorer le test afin que ce dernier respecte la structure Given / When / Then
On peut isoler les méthodes actuelles dans une classe partial
On sait qu'à terme elle pourra être supprimée...
On décrit ce qu'on voudrait dans le Given / When / Then et l'extrait puis "générifie" le tout
Échoue car :
✅ La partie n'existe pas
- Le chasseur n'est pas dans la partie
- Le chasseur n'a plus de balle
- Les chasseurs sont à l'apéro
- La partie de chasse est terminée
Réussi pour :
- Un chassseur présent dans la partie et lui restant des balles
Échoue car :
✅ La partie n'existe pas
✅ Le chasseur n'est pas dans la partie
- Le chasseur n'a plus de balle
- Les chasseurs sont à l'apéro
- La partie de chasse est terminée
Réussi pour :
- Un chassseur présent dans la partie et lui restant des balles
Le chasseur n'a plus de balle
[Fact]publicvoidAvecUnChasseurNayantPlusDeBalles(){Given(UnePartieDeChasseExistante(SurUnTerrainRicheEnGalinettes() .Avec(Dédé(),Bernard().SansBalles(),Robert()) ));When(id =>_useCase.HandleSansException(newDomain.Commands.Tirer(id,Data.Bernard)));ThenFailWith("Bernard tire -> T'as plus de balles mon vieux, chasse à la main", savedPartieDeChasse =>savedPartieDeChasse.Should().HaveEmittedEvent(Now,$"Bernard tire -> T'as plus de balles mon vieux, chasse à la main") );}
publicEither<Error,VoidResponse> HandleSansException(Domain.Commands.Tirer command){var partieDeChasse =_repository.GetById(command.PartieDeChasseId);if (partieDeChasse ==null)returnAnError($"La partie de chasse {command.PartieDeChasseId} n'existe pas");try {partieDeChasse.Tirer(command.Chasseur, _timeProvider, _repository); }catch (ChasseurInconnu) {returnAnError($"Chasseur inconnu {command.Chasseur}"); }catch (TasPlusDeBallesMonVieuxChasseALaMain) {returnAnError($"{command.Chasseur} tire -> T'as plus de balles mon vieux, chasse à la main"); }returnVoidResponse.Empty;}
On va modifier la méthode du Domain pour qu'elle ressemble à celà :
Nous voulons quelque chose du genre dans le Use Case :
publicEither<Error,VoidResponse> HandleSansException(Domain.Commands.Tirer command){var partieDeChasse =_repository.GetById(command.PartieDeChasseId);if (partieDeChasse ==null)returnAnError($"La partie de chasse {command.PartieDeChasseId} n'existe pas");return partieDeChasse .TirerSansException(command.Chasseur, _timeProvider, _repository) .Map(_ =>VoidResponse.Empty);}
On adapte le code de la PartieDeChasse afin de couvrir les besoins actuelles :
publicEither<Error,PartieDeChasse> TirerSansException(string chasseur,Func<DateTime> timeProvider,IPartieDeChasseRepository repository)=>TirerSansException(chasseur, timeProvider, repository, debutMessageSiPlusDeBalles:$"{chasseur} tire");// Le Domain renvoie directement l'erreur s'il y en a plutôt que de lancer des exceptionsprivateEither<Error,PartieDeChasse> TirerSansException(string chasseur,Func<DateTime> timeProvider,IPartieDeChasseRepository repository,string debutMessageSiPlusDeBalles,Action<Chasseur>? continueWith =null){if (DuringApéro()) {EmitEventAndSave($"{chasseur} veut tirer -> On tire pas pendant l'apéro, c'est sacré !!!", timeProvider, repository);throw new OnTirePasPendantLapéroCestSacré(); }if (DéjàTerminée()) {EmitEventAndSave($"{chasseur} veut tirer -> On tire pas quand la partie est terminée", timeProvider, repository);throw new OnTirePasQuandLaPartieEstTerminée(); }if (!ChasseurExiste(chasseur)) {returnAnError($"Chasseur inconnu {chasseur}"); }var chasseurQuiTire =RetrieveChasseur(chasseur);if (!chasseurQuiTire.AEncoreDesBalles()) {EmitEventAndSave($"{debutMessageSiPlusDeBalles} -> T'as plus de balles mon vieux, chasse à la main", timeProvider, repository);returnAnError($"{debutMessageSiPlusDeBalles} -> T'as plus de balles mon vieux, chasse à la main"); }chasseurQuiTire.ATiré();continueWith?.Invoke(chasseurQuiTire);EmitEvent($"{chasseur} tire", timeProvider);returnthis;}
On a bien avancé :
Échoue car :
✅ La partie n'existe pas
✅ Le chasseur n'est pas dans la partie
✅ Le chasseur n'a plus de balle
- Les chasseurs sont à l'apéro
- La partie de chasse est terminée
Réussi pour :
- Un chassseur présent dans la partie et lui restant des balles
Finir la Test-List
En finissant la Test-List nous avonc le code du Domain qui ressemble à ça :
publicEither<Error,PartieDeChasse> TirerSansException(string chasseur,Func<DateTime> timeProvider,IPartieDeChasseRepository repository)=>TirerSansException(chasseur, timeProvider, repository, debutMessageSiPlusDeBalles:$"{chasseur} tire");privateEither<Error,PartieDeChasse> TirerSansException(string chasseur,Func<DateTime> timeProvider,IPartieDeChasseRepository repository,string debutMessageSiPlusDeBalles,Action<Chasseur>? continueWith =null){if (DuringApéro()) {EmitEventAndSave($"{chasseur} veut tirer -> On tire pas pendant l'apéro, c'est sacré !!!", timeProvider, repository);returnAnError($"{chasseur} veut tirer -> On tire pas pendant l'apéro, c'est sacré !!!"); }if (DéjàTerminée()) {EmitEventAndSave($"{chasseur} veut tirer -> On tire pas quand la partie est terminée", timeProvider, repository);returnAnError($"{chasseur} veut tirer -> On tire pas quand la partie est terminée"); }if (!ChasseurExiste(chasseur)) {returnAnError($"Chasseur inconnu {chasseur}"); }var chasseurQuiTire =RetrieveChasseur(chasseur);if (!chasseurQuiTire.AEncoreDesBalles()) {EmitEventAndSave($"{debutMessageSiPlusDeBalles} -> T'as plus de balles mon vieux, chasse à la main", timeProvider, repository);returnAnError($"{debutMessageSiPlusDeBalles} -> T'as plus de balles mon vieux, chasse à la main"); }chasseurQuiTire.ATiré();continueWith?.Invoke(chasseurQuiTire);EmitEvent($"{chasseur} tire", timeProvider);returnthis;}
On a pas mal de duplication à supprimer
Chaque message est construit 2 fois -> pour l'event et l'erreur
De la même manière, avons-nous encore besoin d'appeler le Save du repository ?
Celui-ci était présent car les exceptions "coupaient" le flow...
Notre Use Case peut très bien le faire de manière systématique
privateEither<Error,PartieDeChasse> TirerSansException(string chasseur,Func<DateTime> timeProvider,IPartieDeChasseRepository repository,string debutMessageSiPlusDeBalles,Action<Chasseur>? continueWith =null){if (DuringApéro()) {var message =$"{chasseur} veut tirer -> On tire pas pendant l'apéro, c'est sacré !!!";returnEmitAndReturn(timeProvider, repository, message); }...}private Either<Error, PartieDeChasse> EmitAndReturn(Func<DateTime> timeProvider, IPartieDeChasseRepository repository, string message)
{EmitEventAndSave(message, timeProvider, repository);returnAnError(message);}
On itère dessus, ainsi que sur le code du Use Case :
privateEither<Error,PartieDeChasse> TirerSansException(string chasseur,Func<DateTime> timeProvider,string debutMessageSiPlusDeBalles,Action<Chasseur>? continueWith =null){if (DuringApéro()) {returnEmitAndReturn($"{chasseur} veut tirer -> On tire pas pendant l'apéro, c'est sacré !!!", timeProvider); }if (DéjàTerminée()) {returnEmitAndReturn($"{chasseur} veut tirer -> On tire pas quand la partie est terminée", timeProvider); }if (!ChasseurExiste(chasseur)) {returnEmitAndReturn($"Chasseur inconnu {chasseur}", timeProvider); }var chasseurQuiTire =RetrieveChasseur(chasseur);if (!chasseurQuiTire.AEncoreDesBalles()) {returnEmitAndReturn($"{debutMessageSiPlusDeBalles} -> T'as plus de balles mon vieux, chasse à la main", timeProvider); }chasseurQuiTire.ATiré();continueWith?.Invoke(chasseurQuiTire);EmitEvent($"{chasseur} tire", timeProvider);returnthis;}privateEither<Error,PartieDeChasse> EmitAndReturn(string message,Func<DateTime> timeProvider){EmitEvent(message, timeProvider);returnAnError(message);}// Use CasepublicEither<Error,VoidResponse> HandleSansException(Domain.Commands.Tirer command){var partieDeChasse =_repository.GetById(command.PartieDeChasseId);if (partieDeChasse ==null)returnAnError($"La partie de chasse {command.PartieDeChasseId} n'existe pas");var result = partieDeChasse .TirerSansException(command.Chasseur, _timeProvider) .Map(_ =>VoidResponse.Empty); // On force le Save de la Partie de Chasse quelque soit le retour (Succès ou pas)_repository.Save(partieDeChasse);return result;}
On ajoute une méthode sur le repository afin de pouvoir construire 1 pipeline :
publicinterfaceIPartieDeChasseRepository{ ... // Renvoie auelque chose ou pasOption<PartieDeChasse> GetByIdOption(Guid partieDeChasseId);}publicEither<Error,VoidResponse> HandleSansException(Domain.Commands.Tirer command){PartieDeChasse? foundPartieDeChasse =null;var result =_repository.GetByIdOption(command.PartieDeChasseId) // affecte la Partie De Chasse .Do(p => foundPartieDeChasse = p) .ToEither(() =>AnError($"La partie de chasse {command.PartieDeChasseId} n'existe pas")) // Monadic Binding // Permet d'"aplatir" : avec Map on aurait 1 Either<Error, Either<Error, VoidResponse>> .Bind(partieDeChasse =>partieDeChasse.TirerSansException(command.Chasseur, _timeProvider));if (foundPartieDeChasse !=null) _repository.Save(foundPartieDeChasse);return result;}
Après avoir tout refactoré...
Une fois que l'on a fini de refactoré le Domain et tous les Use Cases pour retourner des monads il est temps de nettoyer le code
On commence par vérifier que les exceptions ne sont plus utilisées
On peut les supprimer de manière safe
On supprime le code mort comme la méthode GetById du repository
On supprime les paramètres inutiles (repository)
À la fin de cette étape le code du `PartieDeChasseUseCase` ressemble à ça :
publicabstractclassPartieDeChasseUseCase<TRequest,TResponse> :IUseCase<TRequest,TResponse>whereTRequest:PartieDeChasseCommand{privatereadonlyIPartieDeChasseRepository _repository;privatereadonlyFunc<PartieDeChasse,TRequest,Either<Error,TResponse>> _handler;protectedPartieDeChasseUseCase(IPartieDeChasseRepository repository,Func<PartieDeChasse,TRequest,Either<Error,TResponse>> handler) { _repository = repository; _handler = handler; }publicEither<Error,TResponse> Handle(TRequest command) => _repository .GetById(command.PartieDeChasseId) .ToEither(() =>AnError($"La partie de chasse {command.PartieDeChasseId} n'existe pas")) .Bind(p =>HandleCommand(p, command));privateEither<Error,TResponse> HandleCommand(PartieDeChasse partieDeChasse,TRequest command) =>_handler(partieDeChasse, command) // Let is a scope function inspired by Kotlin : https://kotlinlang.org/docs/scope-functions.html .Let(_ =>_repository.Save(partieDeChasse));protectedstaticEither<Error,VoidResponse> ToEmpty(Either<Error,PartieDeChasse> either)=>either.Map(_ =>VoidResponse.Empty);}