Knowledge-base
  • Home
  • Samman Technical Coaching
  • Software craftsmanship
    • Practices
      • Pair Programming
      • Code Review
      • Co-designs
      • Design sessions
      • Interview Domain Experts
      • Dev ethics
    • The Software Craftsman
    • Egoless Crafting
    • Technical debt workshop
    • Functional Programming made easy in C# with Language-ext
    • F# for OO programmers
    • Domain Modeling Made Functional
    • Testing
      • Clean Tests
      • Improve the design and testing of your micro-services through CDC Tests
        • CDC testing made simple with Pact
        • Pact broker : the missing piece of your Consumer-Driven Contract approach
      • Improve your test quality with Mutation testing
      • How to name our Unit Tests
      • How to write better assertions
    • Katas
      • TDD
        • Stack kata
        • Fizzbuzz
        • Outside-in TDD (London Style)
      • Improve your software quality with Property-Based Testing
        • A journey to Property-Based Testing
      • Clean Code
      • Clean Architecture
      • Write S.O.L.I.D code
      • Mocking
      • Gilded Rose (Approval Testing)
      • Mikado method
        • Mikado kata
      • Pure functions
      • Theatrical players refactoring Kata
        • Let's refactor (OOP style)
        • Let's refactor (FP style)
      • Functional Programming made easy in Java & C#
      • Refactoring journey
      • Refactoring du Bouchonnois
        • 1) Se faire une idée du code
        • 2) "Treat warnings as errors"
        • 3) Let's kill some mutants
        • 4) Améliorer la lisibilité des tests
        • 5) "Approve Everything"
        • 6) Définir des propriétés
        • 7) Tests d'architecture
        • 8) Use Cases
        • 9) Tell Don't Ask
        • 10) "Avoid Primitives" - Commands
        • 11) "Avoid Exceptions"
        • 12) "Event Sourcing"
    • Software Design X-Rays
      • Workshop
    • The Programmer's Brain
      • How to read code better
  • Software Architecture
    • Fundamentals of Software Architecture
    • Aligning Product & Software Design
    • DDD re-distilled
    • Test your architecture with Archunit
    • NoSQL
  • Agile coaching
    • How to run a Community of Practices (COP)
    • The developers — the forgotten of agility
      • The secrets to re-on-board the devs in agility
    • Coaching toolbox
      • Echelle
      • Learning expedition
    • How to improve Team Decision making ?
      • Decision Making Principles and Practices
    • Learning 3.0
    • Retrospectives
      • Back to the Future
      • Mission Impossible
      • Movie themes
      • Rétro dont vous êtes le héros
      • Sad/Mad/Glad
      • Speed boat
      • Star wars theme
      • Story cubes
    • Technical Agile Coaching with the Samman Method
    • Xanpan - a team centric agile method story
    • XTREM WATCH — Découvrez la puissance de la veille collective
    • Become a better speaker through peer feedback
    • Project-to-Product Principles
  • Leadership
    • Bref. J'ai pris une tarte dans la gueule (et ça fait extrêmement de bien)
    • Forward Summit 2020
    • Learn leadership from the Navy SEALs
    • Learn to lead and help your team(s) to be successful
    • Towards a learning organization and beyond
    • Leadership is language
  • Serious games
    • My serious games
    • Libérez vos entretiens d’embauche avec la gamification
    • How to create a game
    • How to debrief a game ?
    • Lego Serious Play (LSP)
      • LSP in your job interviews
  • Xtrem Reading
    • Cultivate Team Learning with Xtrem Reading
    • My Book Infographics
    • How to make book infographics
    • En route vers l’apprenance avec Xtrem Reading
    • Resources
      • Book notes
        • Agile People: A Radical Approach for HR & Managers
        • Agile testing : A Practical Guide for Testers and Agile Teams
        • Boite à outils de l'intelligence émotionnelle
        • Building a better business using Lego Serious Play method
        • Building evolutionary architectures
        • Code that fits in your head
        • Culture Agile
        • Culture is everything
        • Domain-Driven Design: The First 15 Years
        • Dynamic Reteaming - The Art and Wisdom of Changing Teams
        • How to avoid a Climate Disaster
        • La liberté du commandement
        • Réaliser ses rêves, ça s'apprend
        • Refactoring at Scale
        • Succeeding with OKRs in Agile
        • Team Topologies
        • The Good Life
        • Tu fais quoi dans la vie
        • Who Does What By How Much?
  • My Activity
    • Retour sur mon année 2020
Powered by GitBook
On this page
  • Changer l'implémentation de Prendre LApéro
  • Après avoir tout refactoré...
  • Revue de l'encapsulation
  • Nouveau rapport Sonar Cloud
  • CodeScene
  • Reflect

Was this helpful?

  1. Software craftsmanship
  2. Katas
  3. Refactoring du Bouchonnois

12) "Event Sourcing"

Previous11) "Avoid Exceptions"NextSoftware Design X-Rays

Last updated 1 year ago

Was this helpful?

Nous avons des ersatzs d'événements au sein de notre PartieDeChasse.

Ceux-ci sont extrêmement limités :

  • ils ne portent aucune sémantique métier

  • pas structurés : ce sont de simples string

private readonly List<Event> _events;
public sealed record Event(DateTime Date, string Message)
{
    public override string ToString() => string.Format("{0:HH:mm} - {1}", Date, Message);
}

if (TousBrocouilles(classement))
{
    result = "Brocouille";
    EmitEvent("La partie de chasse est terminée, vainqueur : Brocouille", timeProvider);
}
else
{
    result = Join(", ", classement[0].Select(c => c.Nom));
    EmitEvent(
        $"La partie de chasse est terminée, vainqueur : {Join(", ", classement[0].Select(c => $"{c.Nom} - {c.NbGalinettes} galinettes"))}",
        timeProvider);
}

On va revoir cette gestion des événements et allons en profiter pour Event-sourcer notre Aggregate. Celà signifie que nous n'allons plus stocker l'état de notre Aggregate mais tous ses événements.

Pour celà, on va :

  • Quelques classes ont déjà été implémenté afin de faciliter l'utilisation d'1 Event Store in memory

    • Faire 1 checkout du commit 6efde7c3e470e7c84c50da2715c255bd9acd3d6c

git checkout 5b3129f2bc384ccc707b3f6bb730ff2ef9999167
  • Cette version est très minimaliste et ne résolve pas des problématiques telles que la concurrence

  • Prendre du temps pour comprendre le code du Domain.Core

    • Pour comprendre comment utiliser ce code, on peut se focaliser sur les tests qui nous en donnent une bonne idée

[Fact]
public class AggregateShould
{
    private readonly Guid _id;
    private readonly Movie _movie;

    public AggregateShould()
    {
        _id = Guid.NewGuid();
        _movie = Oppenheimer.Movie(_id);
    }

    [Fact]
    public void have_raised_creation_event()
    {
        _movie.HasRaisedEvent(new MovieCreated(_id, Data.Now, Oppenheimer.Title, Oppenheimer.ReleaseDate))
            .Should()
            .BeTrue();
        _movie.Version.Should().Be(1);
        _movie.Id.Should().Be(_id);
    }

    [Fact]
    public void have_raised_casting_changed_event()
    {
        var newCasting = new List<string> {"Cillian Murphy", "Florence Pugh"}.ToSeq();

        _movie.ChangeCast(newCasting);

        _movie.HasRaisedEvent(new CastingHasChanged(_id, Data.Now, newCasting))
            .Should()
            .BeTrue();

        _movie.Version.Should().Be(2);
    }

    [Fact]
    public void throw_handler_not_found_when_apply_method_not_defined()
    {
        var act = () => _movie.NotWellImplementedBehavior();
        act.Should()
            .Throw<HandlerForDomainEventNotFoundException>()
            .WithMessage(
                "Aggregate of type 'Movie' raised an event of type 'NotWellImplementedDomainBehaviorRaised' but no handler could be found to handle the event.");
    }
    ...
}

public class Movie : Aggregate
{
    // public only for testing purpose
    public string? _title;
    public DateTime? _releaseDate;
    public Seq<string> _casting = Seq<string>.Empty;
    private Movie(Guid id, Func<DateTime> timeProvider) : base(timeProvider, true) => Id = id;

    public Movie(Guid id, Func<DateTime> timeProvider, string title, DateTime releaseDate) : this(id, timeProvider)
        => RaiseEvent(new MovieCreated(id, Time(), title, releaseDate));

    private void Apply(MovieCreated @event)
    {
        _title = @event.Title;
        _releaseDate = @event.ReleaseDate;
    }

    public void ChangeCast(Seq<string> casting) => RaiseEvent(new CastingHasChanged(Id, Time(), casting));

    private void Apply(CastingHasChanged @event) => _casting = @event.Casting;

    public void NotWellImplementedBehavior() => RaiseEvent(new NotWellImplementedDomainBehaviorRaised(Id, Time()));
}

public record MovieCreated(Guid Id, DateTime Date, string Title, DateTime ReleaseDate) : Event(Id, 1, Date);

public record CastingHasChanged(Guid Id, DateTime Date, Seq<string> Casting) : Event(Id, 1, Date);

public record NotWellImplementedDomainBehaviorRaised(Guid Id, DateTime Date) : Event(Id, 1, Date);
  • Identifier quels sont les éléments fondamentaux à mettre en place pour avoir 1 Aggregate "Event-Sourcé"

Changer l'implémentation de Prendre LApéro

Faire en sorte que le flux ressemble à cela :

  • Pour le moment au sein de notre Domain son implémentation ressemble à ça :

public Either<Error, PartieDeChasse> PrendreLapéro(Func<DateTime> timeProvider)
{
    if (DuringApéro())
    {
        return AnError("On est déjà en plein apéro");
    }

    if (DéjàTerminée())
    {
        return AnError("La partie de chasse est déjà terminée");
    }

    Status = Apéro;
    EmitEvent("Petit apéro", timeProvider);

    return this;
}
  • Soyons plus explicite en retournant Either<Error, Unit>

    • On ne stockera plus l'état mais que les Events donc plus besoin de retourner le nouvel état de l'objet

    • On change le test

    • On utilise 1 verbe au passé pour décrire notre événement -> quelque chose d'immuable

  • On génère l'Event "structuré" depuis le test

    • On choisi d'utiliser 1 record parce qu'ils sont immuables par design

    • On ajoute la référence sur le projet Domain.Core

public record ApéroDémarré(Guid Id, DateTime Date) : Event(Id, 1, Date);
  • On doit maintenant ajouter 1 overload sur notre class PartieDeChasseAssertions afin de pouvoir faire des assertions sur des Domain Events :

public AndConstraint<PartieDeChasseAssertions> HaveEmittedEvent<TEvent>(TEvent expectedEvent) where TEvent : class, IEvent 
    => // Comment écrire cette Assertion ???
  • On doit vérifier que l'événemt a bien été émis par notre aggrégat et commité dans notre EventStore

    • On va donc vérifier que dans le stream d'events associé à l'instance de notre aggrégat notre event est bien présent

    • On ajoute alors dans l'assertion 1 dépendance sur le repository

public void HaveEmittedEvent<TEvent>(PartieDeChasseRepositoryForTests repository,
    TEvent expectedEvent) where TEvent : class, IEvent
    => Call(() =>
        Assertion.Given(() => Subject!.Id)
            .ForCondition(_ => repository.LastEvent().Equals(expectedEvent))
            .FailWith($"Les events devraient contenir {expectedEvent}.")
    );
  • Pour continuer on doit itérer sur notre interface de repository

    • On commence à inclure de l'asynchronisme en utilisant 1 OptionAsync (toujours de LanguageExt)

    • On adapte aussi l'assertion (on va utiliser l'AsyncHelper pour se faciliter la tâche)

public interface IPartieDeChasseRepository
{
    void Save(PartieDeChasse partieDeChasse);
    Option<PartieDeChasse> GetById(Guid partieDeChasseId);
    // On va utiliser des mécanismes Async pour refléter l'asynchronisme de notre Event Store
    OptionAsync<Seq<IEvent>> EventsFor(Guid partieDeChasseId);
}

public AndConstraint<PartieDeChasseAssertions> HaveEmittedEvent<TEvent>(
    IPartieDeChasseRepository repository,
    TEvent expectedEvent) where TEvent : class, IEvent =>
    Call(() => Assertion
            .Given(() => repository.EventsFor(Subject!.Id))
            .ForCondition(events => AsyncHelper.RunSync(() => events.Exists(stream => stream.Exists(@event => @event.Equals(expectedEvent)))))
            .FailWith($"Les events devraient contenir {expectedEvent}.")
    );
  • On ajoute 1 instance d'EventStore au sein du PartieDeChasseRepositoryForTests

    • Sachant que pour les besoins de testing nous utiliserons l'instance InMemory

    • Ce repository permettra de manière transiente de pouvoir faire du state-based et de l'event sourcing

public class PartieDeChasseRepositoryForTests : IPartieDeChasseRepository
{
    private readonly IEventStore _eventStore;
    private Map<Guid, PartieDeChasse> _partiesDeChasse = Map<Guid, PartieDeChasse>.Empty;
    private PartieDeChasse? _savedPartieDeChasse;

    public PartieDeChasseRepositoryForTests(IEventStore eventStore)
        => _eventStore = eventStore;

    public void Save(PartieDeChasse partieDeChasse)
    {
        ((IAggregate) partieDeChasse).GetUncommittedEvents().ToSeq();
        // On sauvegarde l'aggrégat via l'EventStore
        AsyncHelper.RunSync(() => _eventStore.Save(partieDeChasse));

        _savedPartieDeChasse = partieDeChasse;
        Add(partieDeChasse);
    }
    
    ...
    
    public OptionAsync<Seq<IEvent>> EventsFor(Guid partieDeChasseId)
        => _eventStore
            .GetEventsById<PartieDeChasse>(partieDeChasseId)
            .Map(events => events.OrderByDescending(e => e.Date).ToSeq());
            
    ...
}
  • On adapte ses instantiations

protected UseCaseTest(Func<IPartieDeChasseRepository, Func<DateTime>, TUseCase> useCaseFactory)
{
    Repository = new PartieDeChasseRepositoryForTests(new InMemoryEventStore(TimeProvider));
    _useCase = useCaseFactory(Repository, TimeProvider);
}
  • On doit maintenant travailler sur l'Aggregate

  • On lui fait hériter de Aggregate et on fixe les warnings

public sealed class PartieDeChasse : Aggregate
{
    private readonly Arr<Chasseur> _chasseurs = Arr<Chasseur>.Empty;
    // TODO : à supprimer à terme
    private readonly List<Event> _events = new();
    public IReadOnlyList<Chasseur> Chasseurs => _chasseurs.ToImmutableArray();
    public Terrain? Terrain { get; }
    public PartieStatus Status { get; private set; }
    public IReadOnlyList<Event> Events => _events.ToImmutableArray();

    // Nouveau ctor    
    private PartieDeChasse(Guid id, Func<DateTime> timeProvider) : base(timeProvider) => Id = id;

    private PartieDeChasse(Guid id,
        Func<DateTime> timeProvider,
        Terrain terrain,
        Chasseur[] chasseurs)
        : this(id, timeProvider)
    {
        Id = id;
        _chasseurs = chasseurs.ToArr();
        Terrain = terrain;
        Status = EnCours;
        _events = new List<Event>();

        EmitPartieDémarrée(timeProvider);
    }
public Either<Error, PartieDeChasse> PrendreLapéro(Func<DateTime> timeProvider)
{
    if (DuringApéro())
    {
        return AnError("On est déjà en plein apéro");
    }

    if (DéjàTerminée())
    {
        return AnError("La partie de chasse est déjà terminée");
    }

    Status = Apéro;
    RaiseEvent(new ApéroDémarré(Id, timeProvider()));
    EmitEvent("Petit apéro", timeProvider);

    return this;
}
    • La transition (mutation / changement d'état doit se faire au chargement de l'event) ici Status = Apéro

public Either<Error, PartieDeChasse> PrendreLapéro(Func<DateTime> timeProvider)
{
    if (DuringApéro())
    {
        return AnError("On est déjà en plein apéro");
    }

    if (DéjàTerminée())
    {
        return AnError("La partie de chasse est déjà terminée");
    }

    RaiseEvent(new ApéroDémarré(Id, timeProvider()));
    // On supprimera la méthode EmitEvent une fois qu'on aura finit de changer chaque behavior de l'aggrégat
    EmitEvent("Petit apéro", timeProvider);

    return this;
}

// Attention : cette méthode sera appelé par Reflection -> votre IDE voudra la supprimée...
// Vos tests diront le contraire 😉
private void Apply(ApéroDémarré @event) => Status = Apéro;
protected static Either<Error, VoidResponse> ToEmpty(Either<Error, Unit> either)
    => either.Map(_ => VoidResponse.Empty);
    

public Either<Error, Unit> PrendreLapéro(Func<DateTime> timeProvider)
{
    if (DuringApéro())
    {
        return AnError("On est déjà en plein apéro");
    }

    if (DéjàTerminée())
    {
        return AnError("La partie de chasse est déjà terminée");
    }

    RaiseEvent(new ApéroDémarré(Id, timeProvider()));
    EmitEvent("Petit apéro", timeProvider);

    return Default;
}
    • On peut changer l'organisation du Domain afin de grouper ensemble les couples Command | Event

  • En faisant cela, on brise une règle d'architecture définie précédemment :

  • On va alors créer 1 ADR (Architecture Decision Record) pour expliquer pourquoi on a voulu dévier de cette règle

    • Bien sûr, ce genre de décisions doivent être discutées et prises en équipe

# Grouper les Commands et Events
- Date : 23/08/2023
- Who were involved in the decision : `Yoan Thirion`

## Context
Describe the decision context :

Nous avions pris la décision de localiser les commandes dans le Domain dans 1 répertoire `Commands`.  
Maintenant que nous utilisons des `Event` pour réponse aux `Command`, nous devrions peut-être faire évoluer notre desgin.

## Decision
On préfère grouper ces couples `Command | Event` avec une sémantique métier.  
Celà permet de créer une `Screaming Architecture`.

Exemple pour `Prendre l'apéro` :  
![Prendre l'apéro](../facilitation/steps/img/12.event-sourcing/event-with-command.webp)

## Status
`Accepted`
  • On change la règle ArchUnit

private readonly GivenClassesConjunction _commands = Classes().That()
    .ImplementInterface(typeof(ICommand)).Or()
    .HaveNameEndingWith("Command");

private readonly GivenClassesConjunction _events = Classes().That().ImplementInterface(typeof(IEvent));

[Fact]
public void CommandsAndEventsShouldBePartOfDomain() 
    => create(_commands, _events)
        .ForEach(ShouldBePartOfDomain);

private static void ShouldBePartOfDomain(GivenClassesConjunction classes)
    => classes.Should()
        .ResideInNamespace("Domain", true)
        .Check();

Après avoir tout refactoré...

  • Pour la consultation du status

    • On a choisi de conserver cette méthode au niveau du Domain

      • En construisant le status basé sur le stream de l'aggrégat

    • On aurait pu, depuis le Use Case passé par une projection

public Either<Error, string> Consulter(IPartieDeChasseRepository repository)
    // RunSync : on a fait le choix de garder notre Domain synchrone
    => RunSync(() => repository.EventsFor(Id)
        .Map(FormatEvents)
        .ValueUnsafe()
    );

private static string FormatEvents(Seq<IEvent> events)
    => Join(Environment.NewLine,
        events.Map(@event => $"{@event.Date:HH:mm} - {@event}")
    );
  • On va pouvoir "cleaner" notre PartieDeChasse

    • Plus besoin de gérer les mytho events au sein de l'aggrégat avec la méthode EmitEvent

    • La gestion du temps est complètement faites via le TimeProvider fournit en entrée

      • On a plus besoin de passer de référence dans nos méthodes

public sealed class PartieDeChasse : Aggregate
{
    private Arr<Chasseur> _chasseurs = Arr<Chasseur>.Empty;
    public IReadOnlyList<Chasseur> Chasseurs => _chasseurs.ToImmutableArray();
    public Terrain? Terrain { get; private set; }
    public PartieStatus Status { get; private set; }
    private PartieDeChasse(Guid id, Func<DateTime> timeProvider) : base(timeProvider) => Id = id;

    #region Create

    private PartieDeChasse(Guid id,
        Func<DateTime> timeProvider,
        Terrain terrain,
        Chasseur[] chasseurs) : this(id, timeProvider)
    {
        RaiseEvent((_, time) =>
            new PartieDeChasseDémarrée(id,
                time,
                new TerrainCréé(terrain.Nom, terrain.NbGalinettes),
                chasseurs.Map(c => new ChasseurCréé(c.Nom, c.BallesRestantes)).ToArray()
            )
        );
    }

    public static Either<Error, PartieDeChasse> Create(
        Func<DateTime> timeProvider,
        DemarrerPartieDeChasse demarrerPartieDeChasse)
    {
        if (!IsTerrainValide(demarrerPartieDeChasse.TerrainDeChasse))
        {
            return AnError("Impossible de démarrer une partie de chasse sur un terrain sans galinettes");
        }

        if (!ContainsChasseurs(demarrerPartieDeChasse.Chasseurs.ToArray()))
        {
            return AnError("Impossible de démarrer une partie de chasse sans chasseurs...");
        }

        if (AuMoinsUnChasseurNaPasDeBalles(demarrerPartieDeChasse.Chasseurs.ToArray()))
        {
            return AnError("Impossible de démarrer une partie de chasse avec un chasseur sans balle(s)...");
        }

        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()
        );
    }

    private void Apply(PartieDeChasseDémarrée @event)
    {
        Id = @event.Id;
        _chasseurs = @event.Chasseurs.Map(c => new Chasseur(c.Nom, c.BallesRestantes)).ToArray();
        Terrain = new Terrain(@event.Terrain.Nom, @event.Terrain.NbGalinettes);
        Status = EnCours;
    }

    private static bool IsTerrainValide(TerrainDeChasse terrainDeChasse) => terrainDeChasse.NbGalinettes > 0;
    private static bool ContainsChasseurs(Démarrer.Chasseur[] chasseurs) => chasseurs.Any();

    private static bool AuMoinsUnChasseurNaPasDeBalles(Démarrer.Chasseur[] chasseurs)
        => chasseurs.Exists(c => c.NbBalles == 0);

    #endregion

    #region Apéro

    public Either<Error, Unit> PrendreLapéro()
    {
        if (DuringApéro())
        {
            return AnError("On est déjà en plein apéro");
        }

        if (DéjàTerminée())
        {
            return AnError("La partie de chasse est déjà terminée");
        }

        RaiseEvent((id, time) => new ApéroDémarré(id, time));

        return Default;
    }

    private void Apply(ApéroDémarré @event) => Status = PartieStatus.Apéro;

    #endregion

    #region Reprendre

    public Either<Error, Unit> Reprendre()
    {
        if (DéjàEnCours())
        {
            return AnError("La partie de chasse est déjà en cours");
        }

        if (DéjàTerminée())
        {
            return AnError("La partie de chasse est déjà terminée");
        }

        RaiseEvent((id, time) => new PartieReprise(id, time));

        return Default;
    }

    private void Apply(PartieReprise @event) => Status = EnCours;

    #endregion

    #region Consulter

    public Either<Error, string> Consulter(IPartieDeChasseRepository repository)
        => RunSync(() => repository.EventsFor(Id)
            .Map(FormatEvents)
            .ValueUnsafe()
        );

    private static string FormatEvents(Seq<IEvent> events)
        => Join(Environment.NewLine,
            events.Map(@event => $"{@event.Date:HH:mm} - {@event}")
        );

    #endregion

    #region Terminer

    public Either<Error, string> Terminer()
    {
        if (DéjàTerminée())
        {
            return AnError("Quand c'est fini, c'est fini");
        }

        var classement = Classement();
        var (winners, nbGalinettes) = TousBrocouilles(classement)
            ? (new List<string> {"Brocouille"}, 0)
            : (classement[0].Map(c => c.Nom), classement[0].First().NbGalinettes);

        RaiseEvent((id, time) => new PartieTerminée(id, time, winners.ToSeq(), nbGalinettes));

        return Join(", ", winners);
    }

    private List<IGrouping<int, Chasseur>> Classement()
        => _chasseurs
            .GroupBy(c => c.NbGalinettes)
            .OrderByDescending(g => g.Key)
            .ToList();

    private static bool TousBrocouilles(IEnumerable<IGrouping<int, Chasseur>> classement) =>
        classement.All(group => group.Key == 0);

    private void Apply(PartieTerminée @event) => Status = Terminée;

    #endregion

    #region Tirer

    public Either<Error, Unit> Tirer(
        string chasseur)
        => Tirer(chasseur,
            intention: "tire",
            _ => RaiseEvent((id, time) => new ChasseurATiré(id, time, chasseur)));

    private Either<Error, Unit> Tirer(
        string chasseur,
        string intention,
        Action<Chasseur>? continueWith = null)
    {
        if (DuringApéro())
        {
            return RaiseEventAndReturnAnError((id, time) =>
                new ChasseurAVouluTiréPendantLApéro(id, time, chasseur));
        }

        if (DéjàTerminée())
        {
            return RaiseEventAndReturnAnError((id, time) =>
                new ChasseurAVouluTiréQuandPartieTerminée(id, time, chasseur));
        }

        if (!ChasseurExiste(chasseur))
        {
            return RaiseEventAndReturnAnError((id, time) => new ChasseurInconnuAVouluTiré(id, time, chasseur));
        }

        var chasseurQuiTire = RetrieveChasseur(chasseur);

        if (!chasseurQuiTire.AEncoreDesBalles())
        {
            return RaiseEventAndReturnAnError((id, time) =>
                new ChasseurSansBallesAVouluTiré(id, time, chasseur, intention));
        }

        continueWith?.Invoke(chasseurQuiTire);

        return Default;
    }

    private void Apply(ChasseurATiré @event) => RetrieveChasseur(@event.Chasseur).ATiré();

    #endregion

    #region Tirer sur une Galinette

    public Either<Error, Unit> TirerSurUneGalinette(string chasseur)
        => Terrain is {NbGalinettes: 0}
            ? RaiseEventAndReturnAnError((id, time) => new ChasseurACruTiréSurGalinette(id, time, chasseur))
            : Tirer(chasseur,
                intention: "veut tirer sur une galinette",
                c => RaiseEvent((id, time) => new ChasseurATiréSurUneGalinette(id, time, chasseur)));

    private void Apply(ChasseurATiréSurUneGalinette @event)
    {
        var chasseur = RetrieveChasseur(@event.Chasseur);
        chasseur.ATiré();
        chasseur.ATué();
        Terrain!.UneGalinetteEnMoins();
    }

    #endregion

    private bool DuringApéro() => Status == PartieStatus.Apéro;
    private bool DéjàTerminée() => Status == Terminée;
    private bool DéjàEnCours() => Status == EnCours;
    private bool ChasseurExiste(string chasseur) => _chasseurs.Exists(c => c.Nom == chasseur);
    private Chasseur RetrieveChasseur(string chasseur) => _chasseurs.ToList().Find(c => c.Nom == chasseur)!;

    private IEvent RaiseEvent(Func<Guid, DateTime, IEvent> eventFactory)
    {
        var @event = eventFactory(Id, Time());
        RaiseEvent(@event);

        return @event;
    }

    private Error RaiseEventAndReturnAnError(Func<Guid, DateTime, IEvent> eventFactory) =>
        AnError(RaiseEvent(eventFactory).ToString()!);
}
  • Concernant le test utilisant le mécanisme d'Approval sur le démarrage d'une partie

    • On effectue l'approbation, non plus sur l'aggrégat, mais sur le dernier event émis

[Fact]
public Task AvecPlusieursChasseurs()
{
    var command = DémarrerUnePartieDeChasse()
        .Avec((Data.Dédé, 20), (Data.Bernard, 8), (Data.Robert, 12))
        .SurUnTerrainRicheEnGalinettes()
        .Build();

    UseCase.Handle(command);

    return Verify(Repository.LastEvent())
        .DontScrubDateTimes();
}

Revue de l'encapsulation

  • Au niveau de la partie de chasse on expose quelques propriétés mais uniquement pour des besoins de testing

  • On doit se poser la question : Sommes nous assez confiant en testant uniquement que les events sont bien raised par notre PartieDeChasse ?

private Arr<Chasseur> _chasseurs = Arr<Chasseur>.Empty;
// Exposé uniquement pour des besoins de testing
public IReadOnlyList<Chasseur> Chasseurs => _chasseurs.ToImmutableArray();
public Terrain? Terrain { get; private set; }
public PartieStatus Status { get; private set; }
private PartieDeChasse(Guid id, Func<DateTime> timeProvider) : base(timeProvider) => Id = id;

// Exemple de test
Then((_, savedPartieDeChasse) =>
    savedPartieDeChasse
        .Should()
        .HaveEmittedEvent(Repository, new PartieReprise(savedPartieDeChasse!.Id, Now))
        .And
        .BeEnCours()); 
  • Pour moi, on peut se dire que oui :

    • On a couvert les transitions qui, intrinsèquement, vont valider l'application des events

    • On encapsule les fields et corrigent les tests

private PartieStatus _status;
private Arr<Chasseur> _chasseurs = Arr<Chasseur>.Empty;
private Terrain? _terrain;
  • On peut splitter la classe PartieDeChasse par comportement en utilisant des partial classes

    • N'a absolument aucun impact sur les consommateurs mais peut être plus facile à comprendre / maintenir

  • Exemple de partial class :

public sealed partial class PartieDeChasse
{
    public Either<Error, string> Terminer()
    {
        if (DéjàTerminée())
        {
            return Error.AnError("Quand c'est fini, c'est fini");
        }

        var classement = Classement();
        var (winners, nbGalinettes) = TousBrocouilles(classement)
            ? (new List<string> {"Brocouille"}, 0)
            : (classement[0].Map(c => c.Nom), classement[0].First().NbGalinettes);

        RaiseEvent((id, time) => new PartieTerminée(id, time, winners.ToSeq(), nbGalinettes));

        return String.Join(", ", winners);
    }

    private List<IGrouping<int, Chasseur>> Classement()
        => _chasseurs
            .GroupBy(c => c.NbGalinettes)
            .OrderByDescending(g => g.Key)
            .ToList();

    private static bool TousBrocouilles(IEnumerable<IGrouping<int, Chasseur>> classement) =>
        classement.All(group => group.Key == 0);

    private void Apply(PartieTerminée @event) => _status = PartieStatus.Terminée;
}

Nouveau rapport Sonar Cloud

  • L'outil identifie un problème avec les méthodes Apply

    • Elles sont appelées uniquement via réflexion

  • Si on veut by-passer cette règle on peut utiliser une des stratégies définies avec la règle

    • On choisit de flagger avec 1 attribut les méthodes Apply

[EventSourced]
private void Apply(ChasseurATiréSurUneGalinette @event)
    => RetrieveChasseur(@event.Chasseur)
        .Let(chasseur =>
        {
            chasseur.ATiré();
            chasseur.ATué();
            _terrain!.UneGalinetteEnMoins();
        });

[AttributeUsage(AttributeTargets.Method)]
public class EventSourcedAttribute : Attribute
{
}
  • On en profite pour changer la manière de récupérer les méthodes Apply dans le ConventionEventRouter

private static Arr<(MethodInfo infos, Type eventType)> ApplyMethodsFor(IAggregate aggregate)
=> aggregate
    .GetType()
    .GetMethods(BindingFlags.Default
                | BindingFlags.Instance
                | BindingFlags.NonPublic
                | BindingFlags.Public)
    .Where(m => m.GetCustomAttribute<EventSourcedAttribute>() != null
                && m.GetParameters().Length == 1
                && m.ReturnParameter.ParameterType == typeof(void))
    .Map(m => (m, m.GetParameters().Single().ParameterType))
    .ToArr();

CodeScene

Après tous ces refactorings on observe la santé du code via codescene :

Félicitations sa santé est en nette amélioration 🎉🎉🎉

Reflect

  • Qu'est-ce que cela a simplifié ?

    • Au contraire complexifié ?

  • Qu'est ce que tu en penses ?

  • Qu'est-ce que tu changerais ?

Prendre du temps pour découvrir ce qu'est l'

Ce code est fortement inspiré du travail fait sur

On commence par adapter 1 test existant afin de spécifier nos attentes vis-à-vis du système

On fail maintenant plus pour des erreurs de compilation mais bien parce qu'aucun événement n'est présent dans l'Event Store

On raise l'event

On va désormais adapté notre code pour faire en sorte que cet événement puisse être rejoué sur l'aggrégat

On va changer le retour de la méthode à partir de l'appelant

Quoi d'autre ?

Plus d'informations sur les ADR

Le rapport est disponible .

Plus d'informations

🔴
🔴
🟢
🔵
🔵
🔵
Event Sourcing
NEventStore
ici
ici
ici
Step 12 : "Event Sourcing"
Command / Event(s)
First red test
PartieDeChasse n'est pas 1 Aggregate
Fail ca aucun événement trouvé
Event avec Command
Broken Architecture Rule
public methods sur PartieDeChasse
Partial classes
Major issues SonarCloud
Codescene debriefe
Fail car aucun événement trouvé