Nous avons encore du travail quant aux entrants de notre système.
Celui-ci ne prend en paramètre que des types primitifs avec des signatures de méthodes :
difficiles à faire évoluer
pouvant être cryptiques et donc avec un fort impact cognitif
Copy public Guid Handle (( string nom , int nbGalinettes) terrainDeChasse , List <( string nom , int nbBalles)> chasseurs)
public void Handle( Guid id , string chasseur)
Il existe un moyen d'éviter ce phénomène : les "objets".
En l'occurence nous allons encapsuler ce qui est handlé par nos Use Cases dans des objets de type Command
:
Commencer par refactorer le code du Use Case DemarrerPartieDeChasse
Si nous avions une couche d'exposition au-dessus, nous devrions mapper les DTOs en entrée vers nos commandes afin de préserver l'encapsulation et pouvoir faire évoluer notre Domain sans impacter les couches supérieures.
DemarrerPartieDeChasse
On peut commencer par transformer les paramètres entrants en class
Puis on configure l'extraction
Voici l'impact de ce refactoring automatisé sur notre code :
Notre IDE est capable de faire ces changements lui-même
Copy var id = _demarrerPartieDeChasse . Handle ( new DemarrerPartieDeChasseCommand ( command . Terrain , command . Chasseurs ));
On peut maintenant renommer et bouger cette classe dans notre Domain
Dans le namespace Commands
Copy using Commands = Bouchonnois . Domain . Commands ;
namespace Bouchonnois . UseCases
{
public sealed class DemarrerPartieDeChasse
{
private readonly IPartieDeChasseRepository _repository;
private readonly Func < DateTime > _timeProvider;
public DemarrerPartieDeChasse ( IPartieDeChasseRepository repository , Func < DateTime > timeProvider)
{
_repository = repository;
_timeProvider = timeProvider;
}
public Guid Handle ( Commands . DemarrerPartieDeChasse demarrerPartieDeChasse)
{
var partieDeChasse = PartieDeChasse.Create(_timeProvider, demarrerPartieDeChasse.TerrainDeChasse, demarrerPartieDeChasse.Chasseurs);
_repository . Save (partieDeChasse);
return partieDeChasse . Id ;
}
}
}
public record DemarrerPartieDeChasse((string nom, int nbGalinettes) TerrainDeChasse, List<(string nom, int nbBalles)> Chasseurs);
Nous pouvons continuer à travailler sur le record
afin de lui donner plus de sens métier
Malheureusement, à l'heure où j'écris ces lignes mon IDE n'est pas en capacité de faire ce refactoring automatiquement
Copy namespace Bouchonnois . Domain . Commands
{
public record DemarrerPartieDeChasse ( TerrainDeChasse TerrainDeChasse , IEnumerable < Chasseur > Chasseurs);
public record TerrainDeChasse ( string Nom , int NbGalinettes);
public record Chasseur ( string Nom , int NbBalles);
}
Nous allons donc adapter par nous-mêmes les appelants en nous laissant guider par les erreurs de compilation
On commence par "fixer" les tests
Copy [ Fact ]
public Task Dé roulerUnePartie ()
{
var command = Dé marrerUnePartieDeChasse ()
. Avec (( Data . D édé , 20 ) , ( Data . Bernard , 8 ) , ( Data . Robert , 12 ))
. SurUnTerrainRicheEnGalinettes ( 4 );
// C'est ce que l'on veut avec l'utilisation de notre CommandBuilder
var id = _demarrerPartieDeChasse . Handle ( command . Build ());
.. .
Copy public class CommandBuilder
{
private ( string , int )[] _chasseurs = Array . Empty <( string , int )>();
private int _nbGalinettes;
public static CommandBuilder Dé marrerUnePartieDeChasse () => new ();
public CommandBuilder Avec ( params ( string , int )[] chasseurs)
{
_chasseurs = chasseurs;
return this ;
}
public CommandBuilder SurUnTerrainRicheEnGalinettes ( int nbGalinettes = 3 )
{
_nbGalinettes = nbGalinettes;
return this ;
}
// On choisit de ne pas changer le contrat mais uniquement ajouter la méthode Build
public DemarrerPartieDeChasse Build ()
=> new (
new TerrainDeChasse ( "Pitibon sur Sauldre" , _nbGalinettes) ,
_chasseurs . Select (c => new Chasseur ( c . Item1 , c . Item2 ))
);
}
Refactoring du code de production
On se focalise maintenant sur le code de production
On utiliser le Strangler Pattern pour refactorer notre code
On génère un nouvel overload
🔴 5 tests sont maintenant rouges : ils vont nous servir de driver
Copy public static PartieDeChasse Create ( Func < DateTime > timeProvider , DemarrerPartieDeChasse demarrerPartieDeChasse)
{
throw new NotImplementedException ();
}
🟢 On commence par utiliser l'ancienne méthode dans à partir de la nouvelle pour faire passer les tests
Copy public static PartieDeChasse Create ( Func < DateTime > timeProvider , DemarrerPartieDeChasse demarrerPartieDeChasse) =>
Create (timeProvider ,
( demarrerPartieDeChasse . TerrainDeChasse . Nom , demarrerPartieDeChasse . TerrainDeChasse . NbGalinettes ) ,
demarrerPartieDeChasse . Chasseurs . Select (c => ( c . Nom , c . NbBalles )). ToList ()
);
public static PartieDeChasse Create (
Func < DateTime > timeProvider ,
( string nom , int nbGalinettes) terrainDeChasse ,
List <( string nom , int nbBalles)> chasseurs)
{
CheckTerrainValide (terrainDeChasse);
CheckChasseursValides (chasseurs);
return new PartieDeChasse (
Guid . NewGuid () ,
timeProvider ,
new Terrain ( terrainDeChasse . nom , terrainDeChasse . nbGalinettes ) ,
chasseurs
);
}
🔵 On peut maintenant "copier / coller" le code de l'ancienne méthode dans la nouvelle
Copy public static PartieDeChasse Create ( Func < DateTime > timeProvider , DemarrerPartieDeChasse demarrerPartieDeChasse)
{
CheckTerrainValide ( demarrerPartieDeChasse . TerrainDeChasse );
CheckChasseursValides ( demarrerPartieDeChasse . Chasseurs . ToArray ());
return new PartieDeChasse (
Guid . NewGuid () ,
timeProvider ,
new Terrain ( demarrerPartieDeChasse . TerrainDeChasse . Nom , demarrerPartieDeChasse . TerrainDeChasse . NbGalinettes ) ,
demarrerPartieDeChasse . Chasseurs . Select (c => new Chasseur ( c . Nom , c . NbBalles )). ToArray ()
);
}
public static PartieDeChasse Create (
Func < DateTime > timeProvider ,
( string nom , int nbGalinettes) terrainDeChasse ,
List <( string nom , int nbBalles)> chasseurs) =>
Create (timeProvider , new DemarrerPartieDeChasse (
new TerrainDeChasse ( terrainDeChasse . nom , terrainDeChasse . nbGalinettes ) ,
chasseurs . Select (c => new Commands . Chasseur ( c . nom , c . nbBalles ))
));
On regarde qui appelait l'ancienne méthode de création
On "plug" les appelants sur la nouvelle méthode
Copy var partieDeChasse = PartieDeChasse . Create (
timeProvider ,
new DemarrerPartieDeChasse (
new TerrainDeChasse ( "Pitibon sur Sauldre" , _nbGalinettes) ,
builtChasseurs
. Select (c => new Domain . Commands . Chasseur ( c . Nom , c . BallesRestantes > 0 ? c . BallesRestantes : 1 ))
. ToList ()
)
);
🔵 On peut supprimer de manière totalement safe
la méthode "étranglée "
Reproduire ces étapes pour les autres Commands
Après avoir extrait des Command
pour chaque Use Case
nous pouvons remarquer les patterns suivants :
1 Use Case
a pour définition : Command
-> Result
Chaque Use Case
implémente la même logique
Charge la PartieDeChasse
à partir du repository
Vérifie que celle-ci existe
Exécute l'action sur le Domain
1 seule exception à celà : DemarrerPartieDeChasse
Nous allons faire en sorte de factoriser cette logique grâce à notre nouveau typage d'entrée.
Factoriser le code des Use Cases
On commence par traduire le texte ci-dessus sous forme de "contrat".
On crée une nouvelle interface pour nos Use Cases
On crée également une interface pour contraindre qu'en entrée d'un Use Case
seule 1 Command
puisse être passée
Copy public interface IUseCase < in TRequest , out TResponse > where TRequest : ICommand
{
public TResponse Handle ( TRequest command);
}
public interface ICommand
{
}
On l'utilise pour ConsulterStatus
Le gain n'est pas flagrant...
On utilise uniquement les interfaces pour contraindre la méthode Handle
Copy public sealed class ConsulterStatus : IUseCase < Domain . Commands . ConsulterStatus , string >
{
private readonly IPartieDeChasseRepository _repository;
public ConsulterStatus ( IPartieDeChasseRepository repository)
=> _repository = repository;
public string Handle ( Domain . Commands . ConsulterStatus consulterStatus)
{
// On voudrait factoriser ce code...
var partieDeChasse = _repository . GetById ( consulterStatus . PartieDeChasseId );
if (partieDeChasse == null )
{
throw new LaPartieDeChasseNexistePas ();
}
return partieDeChasse . Consulter ();
}
}
On continue avec un autre Use Case
: PrendreLapéro
Le type de retour ici est void
Copy public sealed class PrendreLap éro : IUseCase < Domain . Commands . PrendreLap é ro , void >
On crée un type de retour pour ce besoin
Copy public class VoidResponse
{
public static readonly VoidResponse Empty = new ();
}
public sealed class PrendreLap éro : IUseCase < Domain . Commands . PrendreLap é ro , VoidResponse >
{
private readonly IPartieDeChasseRepository _repository;
private readonly Func < DateTime > _timeProvider;
public PrendreLapé ro ( IPartieDeChasseRepository repository , Func < DateTime > timeProvider)
{
_repository = repository;
_timeProvider = timeProvider;
}
public VoidResponse Handle (Domain.Commands.PrendreLapé ro prendreLapéro)
{
var partieDeChasse = _repository . GetById (prendreLapé ro . PartieDeChasseId );
if (partieDeChasse == null )
{
throw new LaPartieDeChasseNexistePas ();
}
partieDeChasse . PrendreLap é ro (_timeProvider);
_repository . Save (partieDeChasse);
return Empty;
}
}
On profite d'avoir 2 usages pour essayer de mutualiser du code
On extrait 1 squelette de classe
On extrait également 1 record
contenant le Guid
de la partie
Copy public record PartieDeChasseCommand ( Guid PartieDeChasseId) : ICommand ;
public class PartieDeChasseUseCase < TRequest , TResponse > : IUseCase < TRequest , TResponse >
where TRequest : PartieDeChasseCommand
{
public TResponse Handle ( TRequest command)
{
throw new NotImplementedException ();
}
}
On utilise cette classe dans 1 premier Use Case
Copy public sealed class ConsulterStatus : PartieDeChasseUseCase < Domain . Commands . ConsulterStatus , string >
{
private readonly IPartieDeChasseRepository _repository;
public ConsulterStatus ( IPartieDeChasseRepository repository)
=> _repository = repository;
}
On implémente la méthode Handle
Copy public abstract class PartieDeChasseUseCase < TRequest , TResponse > : IUseCase < TRequest , TResponse >
where TRequest : PartieDeChasseCommand
{
private readonly IPartieDeChasseRepository _repository;
public PartieDeChasseUseCase ( IPartieDeChasseRepository repository) => _repository = repository;
public TResponse Handle ( TRequest command)
{
var partieDeChasse = _repository . GetById ( command . PartieDeChasseId );
if (partieDeChasse == null )
{
throw new LaPartieDeChasseNexistePas ();
}
var response = Handle (partieDeChasse , command);
_repository . Save (partieDeChasse);
return response;
}
// Chaque Use Case devra implémenter cette méthode abstract
protected abstract TResponse Handle ( PartieDeChasse partieDeChasse , TRequest command);
}
On "fixe" la classe ConsulterStatus
Copy public sealed class ConsulterStatus : PartieDeChasseUseCase < Domain . Commands . ConsulterStatus , string >
{
public ConsulterStatus ( IPartieDeChasseRepository repository) : base(repository)
{
}
protected override string Handle ( PartieDeChasse partieDeChasse , Domain . Commands . ConsulterStatus command)
=> partieDeChasse . Consulter ();
}
Alternativement, nous pourrions utiliser 1 Higher Order Function plutôt qu'une méthode Abstract ici
Copy public abstract class PartieDeChasseUseCase < TRequest , TResponse > : IUseCase < TRequest , TResponse >
where TRequest : PartieDeChasseCommand
{
private readonly IPartieDeChasseRepository _repository;
private readonly Func < PartieDeChasse , TRequest , TResponse > _domainHandler;
protected PartieDeChasseUseCase ( IPartieDeChasseRepository repository ,
Func < PartieDeChasse , TRequest , TResponse > domainHandler)
{
_repository = repository;
_domainHandler = domainHandler;
}
...
}
public sealed class ConsulterStatus : PartieDeChasseUseCase < Domain . Commands . ConsulterStatus , string >
{
public ConsulterStatus ( IPartieDeChasseRepository repository) :
base(repository , (partieDeChasse , _) => partieDeChasse . Consulter ())
{
}
}
On refactor les autres Use Cases
Et apporte quelques améliorations
Finalement, voici le code de nos Use Cases
(sans avoir introduit aucune régression)
Copy public sealed class ConsulterStatus : PartieDeChasseUseCase < Domain . Commands . ConsulterStatus , string >
{
public ConsulterStatus ( IPartieDeChasseRepository repository) :
base(repository , (partieDeChasse , _) => partieDeChasse . Consulter ())
{
}
}
public sealed class PrendreLap éro : EmptyResponsePartieDeChasseUseCase < Domain . Commands . PrendreLap é ro >
{
public PrendreLapé ro ( IPartieDeChasseRepository repository , Func < DateTime > timeProvider)
: base(repository , (partieDeChasse , _) => partieDeChasse . PrendreLap é ro (timeProvider))
{
}
}
public sealed class ReprendreLaPartie : EmptyResponsePartieDeChasseUseCase < Domain . Commands . ReprendreLaPartie >
{
public ReprendreLaPartie ( IPartieDeChasseRepository repository , Func < DateTime > timeProvider)
: base(repository , (partieDeChasse , _) => partieDeChasse . Reprendre (timeProvider))
{
}
}
public sealed class TerminerLaPartie : PartieDeChasseUseCase < Domain . Commands . TerminerLaPartie , string >
{
public TerminerLaPartie ( IPartieDeChasseRepository repository , Func < DateTime > timeProvider)
: base(repository , (partieDeChasse , _) => partieDeChasse . Terminer (timeProvider))
{
}
}
public sealed class Tirer : EmptyResponsePartieDeChasseUseCase < Domain . Commands . Tirer >
{
public Tirer ( IPartieDeChasseRepository repository , Func < DateTime > timeProvider)
: base(repository ,
(partieDeChasse , command) => partieDeChasse . Tirer ( command . Chasseur , timeProvider , repository))
{
}
}
public sealed class TirerSurUneGalinette : EmptyResponsePartieDeChasseUseCase < Domain . Commands . TirerSurUneGalinette >
{
public TirerSurUneGalinette ( IPartieDeChasseRepository repository , Func < DateTime > timeProvider)
: base(repository ,
(partieDeChasse , command) =>
partieDeChasse . TirerSurUneGalinette ( command . Chasseur , timeProvider , repository))
{
}
}
// Use Case de création -> cas particulier
public sealed class DemarrerPartieDeChasse : IUseCase < Domain . Commands . DemarrerPartieDeChasse , Guid >
{
private readonly IPartieDeChasseRepository _repository;
private readonly Func < DateTime > _timeProvider;
public DemarrerPartieDeChasse ( IPartieDeChasseRepository repository , Func < DateTime > timeProvider)
{
_repository = repository;
_timeProvider = timeProvider;
}
public Guid Handle ( Domain . Commands . DemarrerPartieDeChasse demarrerPartieDeChasse)
{
var partieDeChasse = PartieDeChasse . Create (_timeProvider , demarrerPartieDeChasse);
_repository . Save (partieDeChasse);
return partieDeChasse . Id ;
}
}
Confinement des Commands
Nous pouvons ajouter 1 nouvelle règle d'architecture :
Copy Toutes les Commandes doivent être déclarées au sein du Domain (Domain.Commands)
Voici le code que l'on peut écrire à l'aide d'ArchUnit :
Copy [ Fact ]
public void CommandsShouldBePartOfDomain () =>
Classes (). That ()
. ImplementInterface ( typeof ( ICommand ))
. Or ()
. HaveNameEndingWith ( "Command" ). Should ()
. ResideInNamespace ( "Domain.Commands" , true )
. Check ();
Nouveau rapport SonarCloud
disponible ici .
Reflect
Où avez-vous placé les classes de type Command
dans votre solution ?
Quel impact ce refactoring a eu ?