9) Tell Don't Ask

Le code des Use Cases ressemble pour le moment furieusement à du code procédural en :

  • interrogeant des objets

  • prenant des décisions basées sur l'état de ces objets

Voici un exemple avec un Use Case existant :

public sealed class ReprendreLaPartie
{
    private readonly IPartieDeChasseRepository _repository;
    private readonly Func<DateTime> _timeProvider;

    public ReprendreLaPartie(IPartieDeChasseRepository repository, Func<DateTime> timeProvider)
    {
        _repository = repository;
        _timeProvider = timeProvider;
    }

    public void Handle(Guid id)
    {
        var partieDeChasse = _repository.GetById(id);

        // Prise de décision
        if (partieDeChasse == null)
        {
            throw new LaPartieDeChasseNexistePas();
        }

        // Prise de décision
        if (partieDeChasse.Status == PartieStatus.EnCours)
        {
            throw new LaChasseEstDéjàEnCours();
        }

        // Prise de décision
        if (partieDeChasse.Status == PartieStatus.Terminée)
        {
            throw new QuandCestFiniCestFini();
        }

        // Changement d'état pas encapsulé
        partieDeChasse.Status = PartieStatus.EnCours;
        partieDeChasse.Events.Add(new Event(_timeProvider(), "Reprise de la chasse"));
        
        _repository.Save(partieDeChasse);
    }
}

Nous allons encapsuler la prise de décision au niveau du Domain et faire en sorte que les Use Cases respectent le principe Tell Don't Ask :

  • Prendre du temps pour comprendre ce qu'est le principe Tell Don't Ask

  • Encapsuler le code Business des Use Cases dans le Domain

  • Revoir l'encapsulation des objets afin de préserver l'état du Domain

    • Rendre impossible de représenter un état invalide

    • Avoir des objets métiers porteurs de sens

Refactorer le Use Case : ReprendreLaPartie

  • On commence par extraire le contenu business du Use Case

    • Refactor -> Extract -> Extract Method

public sealed class ReprendreLaPartie
{
    ...
    public void Handle(Guid id)
    {
        var partieDeChasse = _repository.GetById(id);

        if (partieDeChasse == null)
        {
            throw new LaPartieDeChasseNexistePas();
        }

        Reprendre(partieDeChasse);
        _repository.Save(partieDeChasse);
    }

    private void Reprendre(PartieDeChasse partieDeChasse)
    {
        if (partieDeChasse.Status == PartieStatus.EnCours)
        {
            throw new LaChasseEstDéjàEnCours();
        }

        if (partieDeChasse.Status == PartieStatus.Terminée)
        {
            throw new QuandCestFiniCestFini();
        }

        partieDeChasse.Status = PartieStatus.EnCours;
        // passer en paramètre le timeprovider
        partieDeChasse.Events.Add(new Event(_timeProvider(), "Reprise de la chasse"));
    }
}
  • Nous devons passer la fonction _timeProvider en paramètre de la méthode

private void Reprendre(Func<DateTime> timeProvider, PartieDeChasse partieDeChasse)
{
    if (partieDeChasse.Status == PartieStatus.EnCours)
    {
        throw new LaChasseEstDéjàEnCours();
    }

    if (partieDeChasse.Status == PartieStatus.Terminée)
    {
        throw new QuandCestFiniCestFini();
    }

    partieDeChasse.Status = PartieStatus.EnCours;
    partieDeChasse.Events.Add(new Event(timeProvider(), "Reprise de la chasse"));
}
  • Nous pouvons maintenant déplacer la méthode dans la classe PartieDeChasse

    • Refactor -> Move

public sealed class PartieDeChasse
{
    ...
    public void Reprendre(Func<DateTime> timeProvider)
    {
        if (this.Status == PartieStatus.EnCours)
        {
            throw new LaChasseEstDéjàEnCours();
        }

        if (this.Status == PartieStatus.Terminée)
        {
            throw new QuandCestFiniCestFini();
        }

        this.Status = PartieStatus.EnCours;
        this.Events.Add(new Event(timeProvider(), "Reprise de la chasse"));
    }
}
  • En déplaçant cette méthode dans le Domain, un test d'Architecture échoue :

    • Les exceptions lancées ne sont effectivement pas au sein du Domain

  • Nous devons déplacer ces exceptions métiers au sein du Domain

Découvertes

  • On continue ce refactoring pour chaque Use Case et faisons quelques "découvertes" :

    • 1 vérification manquante

public sealed class TerminerLaPartie
{
    private readonly IPartieDeChasseRepository _repository;
    private readonly Func<DateTime> _timeProvider;

    public TerminerLaPartie(IPartieDeChasseRepository repository, Func<DateTime> timeProvider)
    {
        _repository = repository;
        _timeProvider = timeProvider;
    }

    public string Handle(Guid id)
    {
        // TODO : missing null check here
        var partieDeChasse = _repository.GetById(id);
        var result = partieDeChasse.Terminer(_timeProvider);

        _repository.Save(partieDeChasse);

        return result;
    }
}
  • De la duplication de code dans chaque Use Case :

public void Handle(Guid id)
{
    // Retrieve aggregate from repository
    var partieDeChasse = _repository.GetById(id);

    // Check if exists
    if (partieDeChasse == null)
    {
        throw new LaPartieDeChasseNexistePas();
    }

    // Call domain method
    ...
    
    // Save new state
    _repository.Save(partieDeChasse);
}
  • Les méthodes Tirer et TirerSurUneGalinette contiennent du code dupliqué

    • Les appels au Save du repository se font dès qu'on ajoute un événement dans la liste d'events (même si une exception est lancée)

public void Tirer(string chasseur, Func<DateTime> timeProvider,
            IPartieDeChasseRepository repository)
{
    if (this.Status != PartieStatus.Apéro)
    {
        if (this.Status != PartieStatus.Terminée)
        {
            if (this.Chasseurs.Exists(c => c.Nom == chasseur))
            {
                var chasseurQuiTire = this.Chasseurs.Find(c => c.Nom == chasseur)!;

                if (chasseurQuiTire.BallesRestantes == 0)
                {
                    this.Events.Add(new Event(timeProvider(),
                        $"{chasseur} tire -> T'as plus de balles mon vieux, chasse à la main"));
                    repository.Save(this);

                    throw new TasPlusDeBallesMonVieuxChasseALaMain();
                }

                this.Events.Add(new Event(timeProvider(), $"{chasseur} tire"));
                chasseurQuiTire.BallesRestantes--;
            }
            else
            {
                throw new ChasseurInconnu(chasseur);
            }
        }
        else
        {
            this.Events.Add(new Event(timeProvider(),
                $"{chasseur} veut tirer -> On tire pas quand la partie est terminée"));
            repository.Save(this);

            throw new OnTirePasQuandLaPartieEstTerminée();
        }
    }
    else
    {
        this.Events.Add(new Event(timeProvider(),
            $"{chasseur} veut tirer -> On tire pas pendant l'apéro, c'est sacré !!!"));
        repository.Save(this);

        throw new OnTirePasPendantLapéroCestSacré();
    }
}

public void TirerSurUneGalinette(string chasseur,
    Func<DateTime> timeProvider,
    IPartieDeChasseRepository repository)
{
    if (this.Terrain.NbGalinettes != 0)
    {
        if (this.Status != PartieStatus.Apéro)
        {
            if (this.Status != PartieStatus.Terminée)
            {
                if (this.Chasseurs.Exists(c => c.Nom == chasseur))
                {
                    var chasseurQuiTire = this.Chasseurs.Find(c => c.Nom == chasseur)!;

                    if (chasseurQuiTire.BallesRestantes == 0)
                    {
                        this.Events.Add(new Event(timeProvider(),
                            $"{chasseur} veut tirer sur une galinette -> T'as plus de balles mon vieux, chasse à la main"));
                        repository.Save(this);

                        throw new TasPlusDeBallesMonVieuxChasseALaMain();
                    }

                    chasseurQuiTire.BallesRestantes--;
                    chasseurQuiTire.NbGalinettes++;
                    this.Terrain.NbGalinettes--;
                    this.Events.Add(new Event(timeProvider(), $"{chasseur} tire sur une galinette"));
                }
                else
                {
                    throw new ChasseurInconnu(chasseur);
                }
            }
            else
            {
                this.Events.Add(new Event(timeProvider(),
                    $"{chasseur} veut tirer -> On tire pas quand la partie est terminée"));
                repository.Save(this);

                throw new OnTirePasQuandLaPartieEstTerminée();
            }
        }
        else
        {
            this.Events.Add(new Event(timeProvider(),
                $"{chasseur} veut tirer -> On tire pas pendant l'apéro, c'est sacré !!!"));
            repository.Save(this);
            throw new OnTirePasPendantLapéroCestSacré();
        }
    }
    else
    {
        throw new TasTropPicoléMonVieuxTasRienTouché();
    }
}

Refactorer le Domain

  • Après les différents refactorings voici l'état de la classe PartieDeChasse :

using Bouchonnois.Domain.Exceptions;
using static System.String;
using static Bouchonnois.Domain.PartieStatus;

namespace Bouchonnois.Domain
{
    public sealed class PartieDeChasse
    {
        public PartieDeChasse(Guid id, Terrain terrain)
        {
            Id = id;
            Chasseurs = new List<Chasseur>();
            Terrain = terrain;
            Status = EnCours;
            Events = new List<Event>();
        }

        public PartieDeChasse(Guid id, Terrain terrain, List<Chasseur> chasseurs)
            : this(id, terrain)
        {
            Chasseurs = chasseurs;
        }

        public PartieDeChasse(Guid id, Terrain terrain, List<Chasseur> chasseurs, List<Event> events,
            PartieStatus status)
            : this(id, terrain, chasseurs, status)
        {
            Events = events;
        }

        public PartieDeChasse(Guid id, Terrain terrain, List<Chasseur> chasseurs, PartieStatus status)
            : this(id, terrain, chasseurs)
        {
            Status = status;
        }


        public Guid Id { get; }
        public List<Chasseur> Chasseurs { get; }
        public Terrain Terrain { get; }
        public PartieStatus Status { get; set; }
        public List<Event> Events { get; init; }

        public static PartieDeChasse CreatePartieDeChasse(
            Func<DateTime> timeProvider,
            (string nom, int nbGalinettes) terrainDeChasse,
            List<(string nom, int nbBalles)> chasseurs)
        {
            if (terrainDeChasse.nbGalinettes <= 0)
            {
                throw new ImpossibleDeDémarrerUnePartieSansGalinettes();
            }

            var partieDeChasse =
                new PartieDeChasse(Guid.NewGuid(),
                    new Terrain(terrainDeChasse.nom)
                    {
                        NbGalinettes = terrainDeChasse.nbGalinettes
                    }
                );

            foreach (var chasseur in chasseurs)
            {
                if (chasseur.nbBalles == 0)
                {
                    throw new ImpossibleDeDémarrerUnePartieAvecUnChasseurSansBalle();
                }

                partieDeChasse.Chasseurs.Add(new Chasseur(chasseur.nom)
                {
                    BallesRestantes = chasseur.nbBalles
                });
            }

            if (partieDeChasse.Chasseurs.Count == 0)
            {
                throw new ImpossibleDeDémarrerUnePartieSansChasseur();
            }

            string chasseursToString = string.Join(
                ", ",
                partieDeChasse.Chasseurs.Select(c => c.Nom + $" ({c.BallesRestantes} balles)")
            );

            partieDeChasse.Events.Add(new Event(timeProvider(),
                $"La partie de chasse commence à {partieDeChasse.Terrain.Nom} avec {chasseursToString}")
            );
            return partieDeChasse;
        }

        public void PrendreLapéro(Func<DateTime> timeProvider)
        {
            if (Status == PartieStatus.Apéro)
            {
                throw new OnEstDéjàEnTrainDePrendreLapéro();
            }
            else if (Status == PartieStatus.Terminée)
            {
                throw new OnPrendPasLapéroQuandLaPartieEstTerminée();
            }

            Status = PartieStatus.Apéro;
            Events.Add(new Event(timeProvider(), "Petit apéro"));
        }

        public void Reprendre(Func<DateTime> timeProvider)
        {
            if (Status == EnCours)
            {
                throw new LaChasseEstDéjàEnCours();
            }

            if (Status == Terminée)
            {
                throw new QuandCestFiniCestFini();
            }

            Status = EnCours;
            Events.Add(new Event(timeProvider(), "Reprise de la chasse"));
        }

        public string Consulter() =>
            Join(
                Environment.NewLine,
                Events
                    .OrderByDescending(@event => @event.Date)
                    .Select(@event => @event.ToString())
            );

        public string Terminer(Func<DateTime> timeProvider)
        {
            var classement = this
                .Chasseurs
                .GroupBy(c => c.NbGalinettes)
                .OrderByDescending(g => g.Key);

            if (this.Status == PartieStatus.Terminée)
            {
                throw new QuandCestFiniCestFini();
            }

            this.Status = PartieStatus.Terminée;

            string result;

            if (classement.All(group => group.Key == 0))
            {
                result = "Brocouille";
                this.Events.Add(
                    new Event(timeProvider(), "La partie de chasse est terminée, vainqueur : Brocouille")
                );
            }
            else
            {
                result = string.Join(", ", classement.ElementAt(0).Select(c => c.Nom));
                this.Events.Add(
                    new Event(timeProvider(),
                        $"La partie de chasse est terminée, vainqueur : {string.Join(", ", classement.ElementAt(0).Select(c => $"{c.Nom} - {c.NbGalinettes} galinettes"))}"
                    )
                );
            }

            return result;
        }

        public void Tirer(string chasseur, Func<DateTime> timeProvider,
            IPartieDeChasseRepository repository)
        {
            if (this.Status != PartieStatus.Apéro)
            {
                if (this.Status != PartieStatus.Terminée)
                {
                    if (this.Chasseurs.Exists(c => c.Nom == chasseur))
                    {
                        var chasseurQuiTire = this.Chasseurs.Find(c => c.Nom == chasseur)!;

                        if (chasseurQuiTire.BallesRestantes == 0)
                        {
                            this.Events.Add(new Event(timeProvider(),
                                $"{chasseur} tire -> T'as plus de balles mon vieux, chasse à la main"));
                            repository.Save(this);

                            throw new TasPlusDeBallesMonVieuxChasseALaMain();
                        }

                        this.Events.Add(new Event(timeProvider(), $"{chasseur} tire"));
                        chasseurQuiTire.BallesRestantes--;
                    }
                    else
                    {
                        throw new ChasseurInconnu(chasseur);
                    }
                }
                else
                {
                    this.Events.Add(new Event(timeProvider(),
                        $"{chasseur} veut tirer -> On tire pas quand la partie est terminée"));
                    repository.Save(this);

                    throw new OnTirePasQuandLaPartieEstTerminée();
                }
            }
            else
            {
                this.Events.Add(new Event(timeProvider(),
                    $"{chasseur} veut tirer -> On tire pas pendant l'apéro, c'est sacré !!!"));
                repository.Save(this);

                throw new OnTirePasPendantLapéroCestSacré();
            }
        }

        public void TirerSurUneGalinette(string chasseur,
            Func<DateTime> timeProvider,
            IPartieDeChasseRepository repository)
        {
            if (this.Terrain.NbGalinettes != 0)
            {
                if (this.Status != PartieStatus.Apéro)
                {
                    if (this.Status != PartieStatus.Terminée)
                    {
                        if (this.Chasseurs.Exists(c => c.Nom == chasseur))
                        {
                            var chasseurQuiTire = this.Chasseurs.Find(c => c.Nom == chasseur)!;

                            if (chasseurQuiTire.BallesRestantes == 0)
                            {
                                this.Events.Add(new Event(timeProvider(),
                                    $"{chasseur} veut tirer sur une galinette -> T'as plus de balles mon vieux, chasse à la main"));
                                repository.Save(this);

                                throw new TasPlusDeBallesMonVieuxChasseALaMain();
                            }

                            chasseurQuiTire.BallesRestantes--;
                            chasseurQuiTire.NbGalinettes++;
                            this.Terrain.NbGalinettes--;
                            this.Events.Add(new Event(timeProvider(), $"{chasseur} tire sur une galinette"));
                        }
                        else
                        {
                            throw new ChasseurInconnu(chasseur);
                        }
                    }
                    else
                    {
                        this.Events.Add(new Event(timeProvider(),
                            $"{chasseur} veut tirer -> On tire pas quand la partie est terminée"));
                        repository.Save(this);

                        throw new OnTirePasQuandLaPartieEstTerminée();
                    }
                }
                else
                {
                    this.Events.Add(new Event(timeProvider(),
                        $"{chasseur} veut tirer -> On tire pas pendant l'apéro, c'est sacré !!!"));
                    repository.Save(this);
                    throw new OnTirePasPendantLapéroCestSacré();
                }
            }
            else
            {
                throw new TasTropPicoléMonVieuxTasRienTouché();
            }
        }
    }
}

Supprimer les constructeurs (utiliser la Factory)

  • Nous sommes couverts par les tests, nous allons pouvoir nous amuser en terme de refactoring 😊

    • On commence par le feedback fourni par notre IDE sur les constructeurs

  • Nous n'avons plus qu'un seul constructeur public :

public sealed class PartieDeChasse
{
    private PartieDeChasse(Guid id, Terrain terrain)
    {
        Id = id;
        Chasseurs = new List<Chasseur>();
        Terrain = terrain;
        Status = EnCours;
        Events = new List<Event>();
    }

    private PartieDeChasse(Guid id, Terrain terrain, List<Chasseur> chasseurs)
        : this(id, terrain)
    {
        Chasseurs = chasseurs;
    }

    private PartieDeChasse(Guid id, Terrain terrain, List<Chasseur> chasseurs, PartieStatus status)
        : this(id, terrain, chasseurs)
    {
        Status = status;
    }

    public PartieDeChasse(Guid id, Terrain terrain, List<Chasseur> chasseurs, List<Event> events,
        PartieStatus status)
        : this(id, terrain, chasseurs, status)
    {
        Events = events;
    }
    ...
} 
  • Le constructeur est appelé par notre Test Data Builder :

public PartieDeChasse Build() => new(
    Guid.NewGuid(),
    new Terrain("Pitibon sur Sauldre") {NbGalinettes = _nbGalinettes},
    _chasseurs.Select(c => c.Build()).ToList(),
    _events.ToList(),
    _status
);
  • Nous voulons forcer l'instantiation de cette classe par sa factory afin de ne plus pouvoir instancié une PartieDeChasse dans un état invalide

    • Ex : Une partie de chasse démarre avec 0 galinettes sur le terrain et des chasseurs sans balles...

    • Nous allons donc faire appel à la Factory Method plutôt qu'à 1 constructeur

public PartieDeChasse Build() =>
    PartieDeChasse.Create(
        () => DateTime.Now,
        ("Pitibon sur Sauldre", _nbGalinettes),
        _chasseurs
            .Select(c => c.Build())
            .Select(c => (c.Nom, c.BallesRestantes > 0 ? c.BallesRestantes : 1))
            .ToList()
    );
  • En effectuant ce refactoring, nous avons 22 tests qui échouent...

  • Pourquoi ? on ne set plus les Events ni le Status à l'instantiation

  • On va faire en sorte d'avancer en mettant en place une solution transitoire

public PartieDeChasse Build()
{
    var partieDeChasse = PartieDeChasse.Create(
        () => DateTime.Now,
        ("Pitibon sur Sauldre", _nbGalinettes),
        _chasseurs
            .Select(c => c.Build())
            .Select(c => (c.Nom, c.BallesRestantes))
            .ToList()
    );

    // TODO : ces setters devraient être privates
    partieDeChasse.Status = _status;
    partieDeChasse.Events = _events.ToList();

    return partieDeChasse;
}
  • Nous n'avons plus que 7 tests qui échouent

    • Ils échouent car l'état des chasseurs n'est pas bon...

    • On ne set pas les galinettes tuées

 public void QuandLaPartieEstEnCoursEt1SeulChasseurDansLaPartie()
{
    Given(
        UnePartieDeChasseExistante(
            SurUnTerrainRicheEnGalinettes()
                .Avec(Robert().AyantTué(2))
        )
    );

    string? winner = null;
    When(id => winner = _useCase.Handle(id));

    Then(savedPartieDeChasse =>
            savedPartieDeChasse.Should()
                .HaveEmittedEvent(Now, "La partie de chasse est terminée, vainqueur : Robert - 2 galinettes"),
        () => winner.Should().Be(Data.Robert));
}
  • On va simuler le fait que le chasseur a tué des galinettes en appelant les méthodes du Domain

// On passe les instances de repository et du timeprovider
public PartieDeChasse Build(Func<DateTime> timeProvider, IPartieDeChasseRepository repository)
{
    var builtChasseurs = _chasseurs.Select(c => c.Build());

    var partieDeChasse = PartieDeChasse.Create(
        timeProvider,
        ("Pitibon sur Sauldre", _nbGalinettes),
        builtChasseurs
            .Select(c => (c.Nom, c.BallesRestantes))
            .ToList()
    );

    partieDeChasse.Status = _status;
    partieDeChasse.Events = _events.ToList();

    partieDeChasse.Chasseurs
        .ForEach(c =>
        {
            var built = builtChasseurs.First(x => x.Nom == c.Nom);
            var repeat = built.NbGalinettes;
            while (repeat > 0)
            {
                partieDeChasse.TirerSurUneGalinette(built.Nom, timeProvider, repository);
                repeat--;
            }
        });

    return partieDeChasse;
}
  • En continuant à fixer les tests, on identifie des tests qui ne sont pas consistants

[Fact]
public void QuandLaPartieEstEnCoursEt2ChasseursExAequo()
{
    Given(
        UnePartieDeChasseExistante(
            // Le terrain riche en galinettes en contient 3
            SurUnTerrainRicheEnGalinettes()
                // Comment Dédé et Bernard ont fait pour en tuer 4 🤔
                .Avec(Dédé().AyantTué(2), Bernard().AyantTué(2), Robert())
        )
    );

    string? winner = null;
    When(id => winner = _useCase.Handle(id));

    Then(savedPartieDeChasse =>
            savedPartieDeChasse.Should()
                .HaveEmittedEvent(Now,
                    "La partie de chasse est terminée, vainqueur : Dédé - 2 galinettes, Bernard - 2 galinettes"),
        () => winner.Should().Be("Dédé, Bernard"));
}
  • On itère sur ce test :

[Fact]
public void QuandLaPartieEstEnCoursEt2ChasseursExAequo()
{
    Given(
        UnePartieDeChasseExistante(
            // Changer le nombre de galinettes sur le terrain
            SurUnTerrainRicheEnGalinettes(4)
                .Avec(Dédé().AyantTué(2), Bernard().AyantTué(2), Robert())
        )
    );

    string? winner = null;
    When(id => winner = _useCase.Handle(id));

    Then(savedPartieDeChasse =>
            savedPartieDeChasse.Should()
                .HaveEmittedEvent(Now,
                    "La partie de chasse est terminée, vainqueur : Dédé - 2 galinettes, Bernard - 2 galinettes"),
        () => winner.Should().Be("Dédé, Bernard"));
}
  • D'autres tests posent problème dû à une mauvaise initialisation :

[Fact]
public void AvecUnChasseurNayantPlusDeBalles()
{
    Given(
        UnePartieDeChasseExistante(
            // Pas relevant pour le test d'avoir 1 terrain sans galinettes
            SurUnTerrainSansGalinettes()
                // Comment on initialise une partie avec Bernard qui n'a pas de balles
                .Avec(Dédé(), Bernard().SansBalles(), Robert())
        ));

    When(id => _useCase.Handle(id, Data.Bernard));

    ThenThrow<TasPlusDeBallesMonVieuxChasseALaMain>(savedPartieDeChasse =>
        savedPartieDeChasse.Should()
            .HaveEmittedEvent(Now, "Bernard tire -> T'as plus de balles mon vieux, chasse à la main"));
}
  • On adapte une nouvelle fois la méthode Build() pour être capable d'être dans l'état décrit :

public PartieDeChasse Build(Func<DateTime> timeProvider, IPartieDeChasseRepository repository)
{
    var builtChasseurs = _chasseurs.Select(c => c.Build());
    var chasseursSansBalles = builtChasseurs.Where(c => c.BallesRestantes == 0).Select(c => c.Nom);

    var partieDeChasse = PartieDeChasse.Create(
        timeProvider,
        ("Pitibon sur Sauldre", _nbGalinettes),
        builtChasseurs
            // Initialise avec 1 balle s'il n'en a pas
            .Select(c => (c.Nom, c.BallesRestantes > 0 ? c.BallesRestantes : 1))
            .ToList()
    );

    partieDeChasse.Status = _status;
    partieDeChasse.Events = _events.ToList();

    partieDeChasse.Chasseurs
        .ForEach(c =>
        {
            var built = builtChasseurs.First(x => x.Nom == c.Nom);
            var repeat = built.NbGalinettes;
            while (repeat > 0)
            {
                partieDeChasse.TirerSurUneGalinette(built.Nom, timeProvider, repository);
                repeat--;
            }
        });

    // Force le tir de la balle
    chasseursSansBalles.ForEach(c => partieDeChasse.Tirer(c, timeProvider, repository));

    return partieDeChasse;
}
  • Nos tests sont maintenant verts et on peut supprimer les constructeurs inutiles :

public sealed class PartieDeChasse
{
    private PartieDeChasse(Guid id, Terrain terrain)
    {
        Id = id;
        Chasseurs = new List<Chasseur>();
        Terrain = terrain;
        Status = EnCours;
        Events = new List<Event>();
    }
    
    ...

    public static PartieDeChasse Create(
        Func<DateTime> timeProvider,
        (string nom, int nbGalinettes) terrainDeChasse,
        List<(string nom, int nbBalles)> chasseurs)
    {
        ...
    }

Encapsuler le Status

  • On commence par passer le set en private pour identifier les impacts

public PartieStatus Status { get; private set; }
  • Nous devons retravailler le Builder pour qu'il supporte l'encapsulation

public PartieDeChasse Build(Func<DateTime> timeProvider, IPartieDeChasseRepository repository)
{
    var builtChasseurs = _chasseurs.Select(c => c.Build());
    var chasseursSansBalles = builtChasseurs.Where(c => c.BallesRestantes == 0).Select(c => c.Nom);

    var partieDeChasse = PartieDeChasse.Create(
        timeProvider,
        ("Pitibon sur Sauldre", _nbGalinettes),
        builtChasseurs
            .Select(c => (c.Nom, c.BallesRestantes > 0 ? c.BallesRestantes : 1))
            .ToList()
    );

    TirerSurLesGalinettes(partieDeChasse, timeProvider, repository, builtChasseurs);
    TirerDansLeVide(partieDeChasse, timeProvider, repository, chasseursSansBalles);

    // Ne pas utiliser de setter mais appelé la bonne méthode métier
    partieDeChasse.Status = _status;
    partieDeChasse.Events = _events.ToList();

    return partieDeChasse;
}
  • On adapte le Builder

public class PartieDeChasseBuilder
{
    // On log les changements de status dans une liste
    private List<PartieStatus> _status = new();
    ...

    public PartieDeChasse Build(Func<DateTime> timeProvider, IPartieDeChasseRepository repository)
    {
        var builtChasseurs = _chasseurs.Select(c => c.Build());
        var chasseursSansBalles = builtChasseurs.Where(c => c.BallesRestantes == 0).Select(c => c.Nom);

        var partieDeChasse = PartieDeChasse.Create(
            timeProvider,
            ("Pitibon sur Sauldre", _nbGalinettes),
            builtChasseurs
                .Select(c => (c.Nom, c.BallesRestantes > 0 ? c.BallesRestantes : 1))
                .ToList()
        );

        TirerSurLesGalinettes(partieDeChasse, timeProvider, repository, builtChasseurs);
        TirerDansLeVide(partieDeChasse, timeProvider, repository, chasseursSansBalles);
        ChangeStatus(partieDeChasse, timeProvider);

        partieDeChasse.Events = _events.ToList();

        return partieDeChasse;
    }
    
    ...

    private void ChangeStatus(PartieDeChasse partieDeChasse, Func<DateTime> timeProvider) =>
        _status.ForEach(status => ChangeStatus(partieDeChasse, status, timeProvider));

    private void ChangeStatus(PartieDeChasse partieDeChasse, PartieStatus status, Func<DateTime> timeProvider)
    {
        if (status == PartieStatus.Terminée) partieDeChasse.Terminer(timeProvider);
        else if (status == Apéro) partieDeChasse.PrendreLapéro(timeProvider);
    }
    
    ...
}

Encapsuler les Events

  • En passant le setter des Events à private on observe que seuls les tests de consultation de status échouent

    • La consultation du status est déjà testé par ailleurs dans les tests d'Acceptance

    • Ces tests se recoupant, on peut dès lors supprimer les Tests Unitaires ConsulterStatus sans impact sur notre couverture et notre confiance

Encapsuler les collections

  • Nous avons grandement amélioré l'encapsulation mais nous pouvons aller plus loin

public sealed class PartieDeChasse
{
    private PartieDeChasse(Guid id, Terrain terrain)
    {
        Id = id;
        Chasseurs = new List<Chasseur>();
        Terrain = terrain;
        Status = EnCours;
        Events = new List<Event>();
    }

    public Guid Id { get; }
    // Quid de cette liste
    public List<Chasseur> Chasseurs { get; }
    public Terrain Terrain { get; }
    public PartieStatus Status { get; private set; }
    // Et de celle-ci
    public List<Event> Events { get; }
    
    ...
  • On va créer des backing fields pour ces 2 collections

  • Les "sécuriser" via l'utilisation d'une collection immutable

public IReadOnlyList<Chasseur> Chasseurs => _chasseurs.ToImmutableArray();
public IReadOnlyList<Event> Events => _events.ToImmutableArray();
  • Ce changement va nous "forcer" à bouger une partie de la logique de la Factory dans le constructeur

  • On conserve les Guards dans la Factory et bouge l'instantiation dans le constructeur

public sealed class PartieDeChasse
{
    private readonly List<Chasseur> _chasseurs;
    private readonly List<Event> _events;
    
    public Guid Id { get; }
    public IReadOnlyList<Chasseur> Chasseurs => _chasseurs.ToImmutableArray();
    public Terrain Terrain { get; }
    public PartieStatus Status { get; private set; }
    public IReadOnlyList<Event> Events => _events.ToImmutableArray();

    private PartieDeChasse(Guid id,
        Func<DateTime> timeProvider,
        Terrain terrain,
        List<(string nom, int nbBalles)> chasseurs)
    {
        Id = id;
        _chasseurs = new List<Chasseur>();
        Terrain = terrain;
        Status = EnCours;
        _events = new List<Event>();
        
        foreach (var chasseur in chasseurs)
        {
            _chasseurs.Add(new Chasseur(chasseur.nom)
            {
                BallesRestantes = chasseur.nbBalles
            });
        }

        string chasseursToString = Join(
            ", ",
            _chasseurs.Select(c => c.Nom + $" ({c.BallesRestantes} balles)")
        );

        _events.Add(new Event(timeProvider(),
            $"La partie de chasse commence à {Terrain.Nom} avec {chasseursToString}")
        );
    }
    
    public static PartieDeChasse Create(
        Func<DateTime> timeProvider,
        (string nom, int nbGalinettes) terrainDeChasse,
        List<(string nom, int nbBalles)> chasseurs)
    {
        if (terrainDeChasse.nbGalinettes <= 0)
        {
            throw new ImpossibleDeDémarrerUnePartieSansGalinettes();
        }

        if (chasseurs.Count == 0)
        {
            throw new ImpossibleDeDémarrerUnePartieSansChasseur();
        }

        if (chasseurs.Any(c => c.nbBalles == 0))
        {
            throw new ImpossibleDeDémarrerUnePartieAvecUnChasseurSansBalle();
        }

        return new PartieDeChasse(Guid.NewGuid(),
            timeProvider,
            new Terrain(terrainDeChasse.nom)
            {
                NbGalinettes = terrainDeChasse.nbGalinettes
            },
            chasseurs
        );
    }
    
    ...

Simplification des méthodes de Tir

  • La méthode Tirer ressemble à cela

 public void Tirer(string chasseur, Func<DateTime> timeProvider,
            IPartieDeChasseRepository repository)
{
    if (Status != Apéro)
    {
        if (Status != Terminée)
        {
            if (_chasseurs.Exists(c => c.Nom == chasseur))
            {
                var chasseurQuiTire = _chasseurs.Find(c => c.Nom == chasseur)!;

                if (chasseurQuiTire.BallesRestantes == 0)
                {
                    _events.Add(new Event(timeProvider(),
                        $"{chasseur} tire -> T'as plus de balles mon vieux, chasse à la main"));
                    repository.Save(this);

                    throw new TasPlusDeBallesMonVieuxChasseALaMain();
                }

                _events.Add(new Event(timeProvider(), $"{chasseur} tire"));
                chasseurQuiTire.BallesRestantes--;
            }
            else
            {
                throw new ChasseurInconnu(chasseur);
            }
        }
        else
        {
            _events.Add(new Event(timeProvider(),
                $"{chasseur} veut tirer -> On tire pas quand la partie est terminée"));
            repository.Save(this);

            throw new OnTirePasQuandLaPartieEstTerminée();
        }
    }
    else
    {
        _events.Add(new Event(timeProvider(),
            $"{chasseur} veut tirer -> On tire pas pendant l'apéro, c'est sacré !!!"));
        repository.Save(this);

        throw new OnTirePasPendantLapéroCestSacré();
    }
}
  • On commence par inverser les if pour faire diminiuer la cyclomatic complexity

public void Tirer(
            string chasseur,
            Func<DateTime> timeProvider,
            IPartieDeChasseRepository repository)
{
    if (Status == Apéro)
    {
        _events.Add(new Event(timeProvider(),
            $"{chasseur} veut tirer -> On tire pas pendant l'apéro, c'est sacré !!!"));
        repository.Save(this);

        throw new OnTirePasPendantLapéroCestSacré();
    }

    if (Status == Terminée)
    {
        _events.Add(new Event(timeProvider(),
            $"{chasseur} veut tirer -> On tire pas quand la partie est terminée"));
        repository.Save(this);

        throw new OnTirePasQuandLaPartieEstTerminée();
    }

    if (!_chasseurs.Exists(c => c.Nom == chasseur))
    {
        throw new ChasseurInconnu(chasseur);
    }

    var chasseurQuiTire = _chasseurs.Find(c => c.Nom == chasseur)!;

    if (chasseurQuiTire.BallesRestantes == 0)
    {
        _events.Add(new Event(timeProvider(),
            $"{chasseur} tire -> T'as plus de balles mon vieux, chasse à la main"));
        repository.Save(this);

        throw new TasPlusDeBallesMonVieuxChasseALaMain();
    }

    _events.Add(new Event(timeProvider(), $"{chasseur} tire"));
    chasseurQuiTire.BallesRestantes--;
}
  • On Save en même temps que l'Emit de l'Event

public void Tirer(
            string chasseur,
            Func<DateTime> timeProvider,
            IPartieDeChasseRepository repository)
{
    if (Status == Apéro)
    {
        EmitEventAndSave(timeProvider, repository, $"{chasseur} veut tirer -> On tire pas pendant l'apéro, c'est sacré !!!");
        throw new OnTirePasPendantLapéroCestSacré();
    }

    if (Status == Terminée)
    {
        EmitEventAndSave(timeProvider, repository,
            $"{chasseur} veut tirer -> On tire pas quand la partie est terminée");
        throw new OnTirePasQuandLaPartieEstTerminée();
    }

    if (!_chasseurs.Exists(c => c.Nom == chasseur))
    {
        throw new ChasseurInconnu(chasseur);
    }

    var chasseurQuiTire = _chasseurs.Find(c => c.Nom == chasseur)!;

    if (chasseurQuiTire.BallesRestantes == 0)
    {
        EmitEventAndSave(timeProvider, repository, $"{chasseur} tire -> T'as plus de balles mon vieux, chasse à la main");
        throw new TasPlusDeBallesMonVieuxChasseALaMain();
    }

    EmitEvent(timeProvider, $"{chasseur} tire");
    chasseurQuiTire.BallesRestantes--;
}
  • On nomme les guards pour rendre le code plus explicite

public void Tirer(
    string chasseur,
    Func<DateTime> timeProvider,
    IPartieDeChasseRepository repository)
{
    if (DuringApéro())
    {
        EmitEventAndSave(timeProvider, repository,
            $"{chasseur} veut tirer -> On tire pas pendant l'apéro, c'est sacré !!!");
        throw new OnTirePasPendantLapéroCestSacré();
    }

    if (DéjàTerminée())
    {
        EmitEventAndSave(timeProvider, repository,
            $"{chasseur} veut tirer -> On tire pas quand la partie est terminée");
        throw new OnTirePasQuandLaPartieEstTerminée();
    }

    if (!ChasseurExiste(chasseur))
    {
        throw new ChasseurInconnu(chasseur);
    }

    var chasseurQuiTire = RetrieveChasseur(chasseur);

    if (!chasseurQuiTire.AEncoreDesBalles())
    {
        EmitEventAndSave(timeProvider, repository,
            $"{chasseur} tire -> T'as plus de balles mon vieux, chasse à la main");
        throw new TasPlusDeBallesMonVieuxChasseALaMain();
    }

    EmitEvent(timeProvider, $"{chasseur} tire");
    chasseurQuiTire.BallesRestantes--;
}
  • En appliquant la même technique sur TirerSurUneGalinette on se rend compte de la duplication :

public void TirerSurUneGalinette(string chasseur,
            Func<DateTime> timeProvider,
            IPartieDeChasseRepository repository)
{
    if (Terrain.NbGalinettes == 0)
    {
        throw new TasTropPicoléMonVieuxTasRienTouché();
    }

    if (Status == Apéro)
    {
        _events.Add(new Event(timeProvider(),
            $"{chasseur} veut tirer -> On tire pas pendant l'apéro, c'est sacré !!!"));
        repository.Save(this);
        throw new OnTirePasPendantLapéroCestSacré();
    }

    if (Status == Terminée)
    {
        _events.Add(new Event(timeProvider(),
            $"{chasseur} veut tirer -> On tire pas quand la partie est terminée"));
        repository.Save(this);

        throw new OnTirePasQuandLaPartieEstTerminée();
    }

    if (!ChasseurExiste(chasseur))
    {
        throw new ChasseurInconnu(chasseur);
    }

    var chasseurQuiTire = RetrieveChasseur(chasseur);

    if (chasseurQuiTire.BallesRestantes == 0)
    {
        _events.Add(new Event(timeProvider(),
            $"{chasseur} veut tirer sur une galinette -> T'as plus de balles mon vieux, chasse à la main"));
        repository.Save(this);

        throw new TasPlusDeBallesMonVieuxChasseALaMain();
    }

    chasseurQuiTire.BallesRestantes--;
    chasseurQuiTire.NbGalinettes++;
    Terrain.NbGalinettes--;
    _events.Add(new Event(timeProvider(), $"{chasseur} tire sur une galinette"));
}
  • On va créer une méthode private de tir permettant de mutualiser le code

public void Tirer(
    string chasseur,
    Func<DateTime> timeProvider,
    IPartieDeChasseRepository repository)
{
    Tirer(chasseur,
        timeProvider,
        repository,
        debutMessageSiPlusDeBalles: $"{chasseur} tire");

    EmitEvent(timeProvider, $"{chasseur} tire");
}

public void TirerSurUneGalinette(string chasseur,
    Func<DateTime> timeProvider,
    IPartieDeChasseRepository repository)
{
    if (Terrain.NbGalinettes == 0)
    {
        throw new TasTropPicoléMonVieuxTasRienTouché();
    }

    Tirer(chasseur,
        timeProvider,
        repository,
        debutMessageSiPlusDeBalles: $"{chasseur} veut tirer sur une galinette",
        c =>
        {
            c.NbGalinettes++;
            Terrain.NbGalinettes--;
            EmitEvent(timeProvider, $"{chasseur} tire sur une galinette");
        });
}

private void Tirer(
    string chasseur,
    Func<DateTime> timeProvider,
    IPartieDeChasseRepository repository,
    string debutMessageSiPlusDeBalles,
    Action<Chasseur>? continueWith = null)
{
    if (DuringApéro())
    {
        EmitEventAndSave(timeProvider, repository, $"{chasseur} veut tirer -> On tire pas pendant l'apéro, c'est sacré !!!");
        throw new OnTirePasPendantLapéroCestSacré();
    }

    if (DéjàTerminée())
    {
        EmitEventAndSave(timeProvider, repository, $"{chasseur} veut tirer -> On tire pas quand la partie est terminée");
        throw new OnTirePasQuandLaPartieEstTerminée();
    }

    if (!ChasseurExiste(chasseur))
    {
        throw new ChasseurInconnu(chasseur);
    }

    var chasseurQuiTire = RetrieveChasseur(chasseur);

    if (!chasseurQuiTire.AEncoreDesBalles())
    {
        EmitEventAndSave(timeProvider, repository, $"{debutMessageSiPlusDeBalles} -> T'as plus de balles mon vieux, chasse à la main");
        throw new TasPlusDeBallesMonVieuxChasseALaMain();
    }

    chasseurQuiTire.BallesRestantes--;
    continueWith?.Invoke(chasseurQuiTire);
}

Encapsulation du Chasseur

  • Depuis les méthodes de Tir on va appeler une méthode ATiré() plutôt que d'utiliser 1 setter

private void Tirer(
    string chasseur,
    Func<DateTime> timeProvider,
    IPartieDeChasseRepository repository,
    string debutMessageSiPlusDeBalles,
    Action<Chasseur>? continueWith = null)
{
    ...
    chasseurQuiTire.ATiré();
    continueWith?.Invoke(chasseurQuiTire);
}

public sealed class Chasseur
{
    public Chasseur(string nom, int ballesRestantes)
    {
        Nom = nom;
        BallesRestantes = ballesRestantes;
    }

    public bool AEncoreDesBalles() => BallesRestantes > 0;

    public string Nom { get; }
    public int BallesRestantes { get; private set; }
    public int NbGalinettes { get; set; }

    public void ATiré() => BallesRestantes--;
}
  • On fait de même pour les NbGalinettes

public sealed class Terrain
{
    public Terrain(string nom, int nbGalinettes)
    {
        Nom = nom;
        NbGalinettes = nbGalinettes;
    }

    public string Nom { get; }
    public int NbGalinettes { get; private set; }

    public void UneGalinetteEnMoins() => NbGalinettes--;
}

Un mot sur la duplication

En redescendant la logique dans le Domain et en refactorant ce dernier on a grandement amélioré la qualité du code :

Nouveau rapport SonarCloud disponible ici.

Reflect

  • Quel impact ce refactoring a eu sur les tests ?

  • Qu'est-ce qui est plus simple / compliqué maintenant ?

Last updated