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
privatereadonlyList<Event> _events;publicsealedrecordEvent(DateTime Date,string Message){publicoverridestringToString() =>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 :
Prendre du temps pour découvrir ce qu'est l'Event Sourcing
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
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
Ce code est fortement inspiré du travail fait sur NEventStore
Pour comprendre comment utiliser ce code, on peut se focaliser sur les tests qui nous en donnent une bonne idée
[Fact]publicclassAggregateShould{privatereadonlyGuid _id;privatereadonlyMovie _movie;publicAggregateShould() { _id =Guid.NewGuid(); _movie =Oppenheimer.Movie(_id); } [Fact]publicvoidhave_raised_creation_event() {_movie.HasRaisedEvent(newMovieCreated(_id,Data.Now,Oppenheimer.Title,Oppenheimer.ReleaseDate)) .Should() .BeTrue();_movie.Version.Should().Be(1);_movie.Id.Should().Be(_id); } [Fact]publicvoidhave_raised_casting_changed_event() {var newCasting =newList<string> {"Cillian Murphy","Florence Pugh"}.ToSeq();_movie.ChangeCast(newCasting);_movie.HasRaisedEvent(newCastingHasChanged(_id,Data.Now, newCasting)) .Should() .BeTrue();_movie.Version.Should().Be(2); } [Fact]publicvoidthrow_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."); } ...}publicclassMovie:Aggregate{ // public only for testing purposepublicstring? _title;publicDateTime? _releaseDate;publicSeq<string> _casting =Seq<string>.Empty;privateMovie(Guid id,Func<DateTime> timeProvider) : base(timeProvider,true) => Id = id;publicMovie(Guid id,Func<DateTime> timeProvider,string title,DateTime releaseDate) :this(id, timeProvider)=>RaiseEvent(newMovieCreated(id,Time(), title, releaseDate));privatevoidApply(MovieCreated @event) { _title =@event.Title; _releaseDate =@event.ReleaseDate; }publicvoidChangeCast(Seq<string> casting) =>RaiseEvent(newCastingHasChanged(Id,Time(), casting));privatevoidApply(CastingHasChanged @event) => _casting =@event.Casting;publicvoidNotWellImplementedBehavior() =>RaiseEvent(newNotWellImplementedDomainBehaviorRaised(Id,Time()));}publicrecordMovieCreated(Guid Id,DateTime Date,string Title,DateTime ReleaseDate) :Event(Id, 1,Date);publicrecordCastingHasChanged(Guid Id,DateTime Date,Seq<string> Casting) :Event(Id, 1,Date);publicrecordNotWellImplementedDomainBehaviorRaised(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()) {returnAnError("On est déjà en plein apéro"); }if (DéjàTerminée()) {returnAnError("La partie de chasse est déjà terminée"); } Status = Apéro;EmitEvent("Petit apéro", timeProvider);returnthis;}
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 commence par adapter 1 test existant afin de spécifier nos attentes vis-à-vis du système
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 lui fait hériter de Aggregate et on fixe les warnings
publicsealedclassPartieDeChasse:Aggregate{privatereadonlyArr<Chasseur> _chasseurs =Arr<Chasseur>.Empty; // TODO : à supprimer à termeprivatereadonlyList<Event> _events =new();publicIReadOnlyList<Chasseur> Chasseurs =>_chasseurs.ToImmutableArray();publicTerrain? Terrain { get; }publicPartieStatus Status { get; privateset; }publicIReadOnlyList<Event> Events =>_events.ToImmutableArray(); // Nouveau ctor privatePartieDeChasse(Guid id,Func<DateTime> timeProvider) : base(timeProvider) => Id = id;privatePartieDeChasse(Guid id,Func<DateTime> timeProvider,Terrain terrain,Chasseur[] chasseurs):this(id, timeProvider) { Id = id; _chasseurs =chasseurs.ToArr(); Terrain = terrain; Status = EnCours; _events =newList<Event>(); EmitPartieDémarrée(timeProvider); }
🔴 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
public Either<Error, PartieDeChasse> PrendreLapéro(Func<DateTime> timeProvider){if (DuringApéro()) {returnAnError("On est déjà en plein apéro"); }if (DéjàTerminée()) {returnAnError("La partie de chasse est déjà terminée"); } Status = Apéro;RaiseEvent(new ApéroDémarré(Id,timeProvider()));EmitEvent("Petit apéro", timeProvider);returnthis;}
🔵 On va désormais adapté notre code pour faire en sorte que cet événement puisse être rejoué sur l'aggrégat
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()) {returnAnError("On est déjà en plein apéro"); }if (DéjàTerminée()) {returnAnError("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égatEmitEvent("Petit apéro", timeProvider);returnthis;}// Attention : cette méthode sera appelé par Reflection -> votre IDE voudra la supprimée...// Vos tests diront le contraire 😉privatevoidApply(ApéroDémarré @event) => Status = Apéro;
🔵 On va changer le retour de la méthode à partir de l'appelant
protectedstaticEither<Error,VoidResponse> ToEmpty(Either<Error,Unit> either)=>either.Map(_ =>VoidResponse.Empty);public Either<Error, Unit> PrendreLapéro(Func<DateTime> timeProvider){if (DuringApéro()) {returnAnError("On est déjà en plein apéro"); }if (DéjàTerminée()) {returnAnError("La partie de chasse est déjà terminée"); }RaiseEvent(new ApéroDémarré(Id,timeProvider()));EmitEvent("Petit apéro", timeProvider);return Default;}
🔵 Quoi d'autre ?
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`## ContextDescribe 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.## DecisionOn 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 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
publicEither<Error,string> Consulter(IPartieDeChasseRepository repository) // RunSync : on a fait le choix de garder notre Domain synchrone=>RunSync(() =>repository.EventsFor(Id) .Map(FormatEvents) .ValueUnsafe() );privatestaticstringFormatEvents(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
publicsealedclassPartieDeChasse:Aggregate{privateArr<Chasseur> _chasseurs =Arr<Chasseur>.Empty;publicIReadOnlyList<Chasseur> Chasseurs =>_chasseurs.ToImmutableArray();publicTerrain? Terrain { get; privateset; }publicPartieStatus Status { get; privateset; }privatePartieDeChasse(Guid id,Func<DateTime> timeProvider) : base(timeProvider) => Id = id;#regionCreateprivatePartieDeChasse(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() ) ); }publicstaticEither<Error,PartieDeChasse> Create(Func<DateTime> timeProvider,DemarrerPartieDeChasse demarrerPartieDeChasse) {if (!IsTerrainValide(demarrerPartieDeChasse.TerrainDeChasse)) {returnAnError("Impossible de démarrer une partie de chasse sur un terrain sans galinettes"); }if (!ContainsChasseurs(demarrerPartieDeChasse.Chasseurs.ToArray())) {returnAnError("Impossible de démarrer une partie de chasse sans chasseurs..."); }if (AuMoinsUnChasseurNaPasDeBalles(demarrerPartieDeChasse.Chasseurs.ToArray())) {returnAnError("Impossible de démarrer une partie de chasse avec un chasseur sans balle(s)..."); }returnnewPartieDeChasse(Guid.NewGuid(), timeProvider,newTerrain(demarrerPartieDeChasse.TerrainDeChasse.Nom,demarrerPartieDeChasse.TerrainDeChasse.NbGalinettes ),demarrerPartieDeChasse.Chasseurs.Select(c =>newChasseur(c.Nom,c.NbBalles)).ToArray() ); }privatevoidApply(PartieDeChasseDémarrée @event) { Id =@event.Id; _chasseurs =@event.Chasseurs.Map(c =>newChasseur(c.Nom,c.BallesRestantes)).ToArray(); Terrain =newTerrain(@event.Terrain.Nom,@event.Terrain.NbGalinettes); Status = EnCours; }privatestaticboolIsTerrainValide(TerrainDeChasse terrainDeChasse) =>terrainDeChasse.NbGalinettes>0;privatestaticboolContainsChasseurs(Démarrer.Chasseur[] chasseurs) =>chasseurs.Any();privatestaticboolAuMoinsUnChasseurNaPasDeBalles(Démarrer.Chasseur[] chasseurs)=>chasseurs.Exists(c =>c.NbBalles==0);#endregion#regionApéropublic Either<Error, Unit> PrendreLapéro() {if (DuringApéro()) {returnAnError("On est déjà en plein apéro"); }if (DéjàTerminée()) {returnAnError("La partie de chasse est déjà terminée"); }RaiseEvent((id, time) => new ApéroDémarré(id, time));return Default; }privatevoidApply(ApéroDémarré @event) => Status =PartieStatus.Apéro;#endregion#regionReprendrepublicEither<Error,Unit> Reprendre() {if (DéjàEnCours()) {returnAnError("La partie de chasse est déjà en cours"); }if (DéjàTerminée()) {returnAnError("La partie de chasse est déjà terminée"); }RaiseEvent((id, time) =>newPartieReprise(id, time));return Default; }privatevoidApply(PartieReprise @event) => Status = EnCours;#endregion#regionConsulterpublicEither<Error,string> Consulter(IPartieDeChasseRepository repository)=>RunSync(() =>repository.EventsFor(Id) .Map(FormatEvents) .ValueUnsafe() );privatestaticstringFormatEvents(Seq<IEvent> events)=>Join(Environment.NewLine,events.Map(@event =>$"{@event.Date:HH:mm} - {@event}") );#endregion#regionTerminerpublicEither<Error,string> Terminer() {if (DéjàTerminée()) {returnAnError("Quand c'est fini, c'est fini"); }var classement =Classement();var (winners, nbGalinettes) =TousBrocouilles(classement)? (newList<string> {"Brocouille"},0): (classement[0].Map(c =>c.Nom),classement[0].First().NbGalinettes);RaiseEvent((id, time) => new PartieTerminée(id, time,winners.ToSeq(), nbGalinettes));returnJoin(", ", winners); }privateList<IGrouping<int,Chasseur>> Classement()=> _chasseurs .GroupBy(c =>c.NbGalinettes) .OrderByDescending(g =>g.Key) .ToList();privatestaticboolTousBrocouilles(IEnumerable<IGrouping<int,Chasseur>> classement) =>classement.All(group =>group.Key==0);privatevoidApply(PartieTerminée @event) => Status = Terminée;#endregion#regionTirerpublicEither<Error,Unit> Tirer(string chasseur)=>Tirer(chasseur, intention:"tire", _ =>RaiseEvent((id, time) => new ChasseurATiré(id, time, chasseur)));privateEither<Error,Unit> Tirer(string chasseur,string intention,Action<Chasseur>? continueWith =null) {if (DuringApéro()) {returnRaiseEventAndReturnAnError((id, time) => new ChasseurAVouluTiréPendantLApéro(id, time, chasseur)); }if (DéjàTerminée()) {returnRaiseEventAndReturnAnError((id, time) => new ChasseurAVouluTiréQuandPartieTerminée(id, time, chasseur)); }if (!ChasseurExiste(chasseur)) {returnRaiseEventAndReturnAnError((id, time) => new ChasseurInconnuAVouluTiré(id, time, chasseur)); }var chasseurQuiTire =RetrieveChasseur(chasseur);if (!chasseurQuiTire.AEncoreDesBalles()) {returnRaiseEventAndReturnAnError((id, time) => new ChasseurSansBallesAVouluTiré(id, time, chasseur, intention)); }continueWith?.Invoke(chasseurQuiTire);return Default; }privatevoidApply(ChasseurATiré @event) =>RetrieveChasseur(@event.Chasseur).ATiré();#endregion#regionTirer sur une GalinettepublicEither<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)));privatevoidApply(ChasseurATiréSurUneGalinette @event) {var chasseur =RetrieveChasseur(@event.Chasseur);chasseur.ATiré();chasseur.ATué(); Terrain!.UneGalinetteEnMoins(); }#endregionprivate bool DuringApéro() => Status ==PartieStatus.Apéro;private bool DéjàTerminée() => Status == Terminée;private bool DéjàEnCours() => Status == EnCours;privateboolChasseurExiste(string chasseur) =>_chasseurs.Exists(c =>c.Nom== chasseur);privateChasseurRetrieveChasseur(string chasseur) =>_chasseurs.ToList().Find(c =>c.Nom== chasseur)!;privateIEventRaiseEvent(Func<Guid,DateTime,IEvent> eventFactory) {var @event =eventFactory(Id,Time());RaiseEvent(@event);return @event; }privateErrorRaiseEventAndReturnAnError(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